diff --git a/.gitignore b/.gitignore index 57f5f00935..e9deaa0b10 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,15 @@ __pycache__/ *.py[cod] *.class unsloth_compiled_cache/ +# ML artifacts (large files) +feature/ +outputs/ +exports/ +/datasets/ +studio/backend/assets/datasets/ +unsloth_training_checkpoints/ +*.gguf +*.safetensors # C extensions *.so @@ -136,6 +145,9 @@ venv/ ENV/ env.bak/ venv.bak/ +.venv_overlay/ +.venv_t5/ +environment.yaml # Spyder project settings .spyderproject @@ -172,6 +184,34 @@ cython_debug/ .ruff_cache/ .pre-commit-cache/ -# PyPI configuration file +# PyPI configuration file and IDE/Editors .pypirc .vscode +.idea/ +.claude/ +*.swp +*.swo + +# oh-my-codex +.omx/ + +# Firebase +firebase-debug.log + +# Other +resources/ +tmp/ +**/node_modules/ +auth.db + +# Local working docs +**/CLAUDE.md +**/claude.md +**/AGENT.md +**/agent.md +docs/canvas-lab-architecture.md +log_rtx.txt +log.txt +setup_leo.sh +server.pid +*.log diff --git a/COPYING b/COPYING new file mode 100644 index 0000000000..cb7331bd35 --- /dev/null +++ b/COPYING @@ -0,0 +1,664 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. + +Files under unsloth/*, tests/*, scripts/* are Apache 2.0 licensed. +Files under studio/*, cli/* which is optional to install are AGPLv3 licensed. \ No newline at end of file diff --git a/LICENSE b/LICENSE index 8894f17a3b..8c3c43e374 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,9 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [2024-] [Unsloth AI, Daniel Han-Chen & Michael Han-Chen] + Copyright [2024-] [Unsloth AI. Inc team, Daniel Han-Chen & Michael Han-Chen] + Files under unsloth/*, tests/*, scripts/* are Apache 2.0 licensed. + Files under studio/*, cli/* which is optional to install are AGPLv3 licensed. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/build.sh b/build.sh new file mode 100644 index 0000000000..4948a73ae4 --- /dev/null +++ b/build.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# 1. Build frontend (Vite outputs to dist/) +cd studio/frontend +npm install +npm run build # outputs to studio/frontend/dist/ +cd ../.. + +# 2. Clean old artifacts +rm -rf build dist *.egg-info + +# 3. Build wheel +python -m build + +# 4. Optionally publish +if [ "${1:-}" = "publish" ]; then + python -m twine upload dist/* +fi diff --git a/cli.py b/cli.py new file mode 100644 index 0000000000..0965b424e8 --- /dev/null +++ b/cli.py @@ -0,0 +1,7 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +from cli import app + +if __name__ == "__main__": + app() diff --git a/cli/__init__.py b/cli/__init__.py new file mode 100644 index 0000000000..434ee4d2ed --- /dev/null +++ b/cli/__init__.py @@ -0,0 +1,22 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import typer + +from cli.commands.train import train +from cli.commands.inference import inference +from cli.commands.export import export, list_checkpoints +from cli.commands.ui import ui +from cli.commands.studio import studio_app + +app = typer.Typer( + help = "Command-line interface for Unsloth training, inference, and export.", + context_settings = {"help_option_names": ["-h", "--help"]}, +) + +app.command()(train) +app.command()(inference) +app.command()(export) +app.command("list-checkpoints")(list_checkpoints) +app.command()(ui) +app.add_typer(studio_app, name = "studio", help = "Unsloth Studio commands.") diff --git a/cli/commands/__init__.py b/cli/commands/__init__.py new file mode 100644 index 0000000000..32014236c6 --- /dev/null +++ b/cli/commands/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 diff --git a/cli/commands/export.py b/cli/commands/export.py new file mode 100644 index 0000000000..785f309e8e --- /dev/null +++ b/cli/commands/export.py @@ -0,0 +1,132 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +from pathlib import Path +from typing import Optional + +import typer + + +EXPORT_FORMATS = ["merged-16bit", "merged-4bit", "gguf", "lora"] +GGUF_QUANTS = ["q4_k_m", "q5_k_m", "q8_0", "f16"] + + +def list_checkpoints( + outputs_dir: Path = typer.Option( + Path("./outputs"), "--outputs-dir", help = "Directory that holds training runs." + ), +): + """List checkpoints detected in the outputs directory.""" + from studio.backend.core.export import ExportBackend + + backend = ExportBackend() + checkpoints = backend.scan_checkpoints(outputs_dir = str(outputs_dir)) + if not checkpoints: + typer.echo("No checkpoints found.") + raise typer.Exit() + + for model_name, ckpt_list, metadata in checkpoints: + typer.echo(f"\n{model_name}:") + for display, path, loss in ckpt_list: + loss_str = f" (loss: {loss:.4f})" if loss is not None else "" + typer.echo(f" {display}{loss_str}: {path}") + + +def export( + checkpoint: Path = typer.Argument(..., help = "Path to checkpoint directory."), + output_dir: Path = typer.Argument(..., help = "Directory to save exported model."), + format: str = typer.Option( + "merged-16bit", + "--format", + "-f", + help = f"Export format: {', '.join(EXPORT_FORMATS)}", + ), + quantization: str = typer.Option( + "q4_k_m", + "--quantization", + "-q", + help = f"GGUF quantization method: {', '.join(GGUF_QUANTS)}", + ), + push_to_hub: bool = typer.Option( + False, "--push-to-hub", help = "Push exported model to HuggingFace Hub." + ), + repo_id: Optional[str] = typer.Option( + None, "--repo-id", help = "HuggingFace repo ID (username/model-name)." + ), + hf_token: Optional[str] = typer.Option( + None, "--hf-token", envvar = "HF_TOKEN", help = "HuggingFace token." + ), + private: bool = typer.Option( + False, "--private", help = "Make the HuggingFace repo private." + ), + max_seq_length: int = typer.Option(2048, "--max-seq-length"), + load_in_4bit: bool = typer.Option(True, "--load-in-4bit/--no-load-in-4bit"), +): + """Export a checkpoint to various formats (merged, GGUF, LoRA adapter).""" + if format not in EXPORT_FORMATS: + typer.echo( + f"Error: Invalid format '{format}'. Choose from: {', '.join(EXPORT_FORMATS)}", + err = True, + ) + raise typer.Exit(code = 2) + + if push_to_hub and not repo_id: + typer.echo("Error: --repo-id required when using --push-to-hub", err = True) + raise typer.Exit(code = 2) + + from studio.backend.core.export import ExportBackend + + backend = ExportBackend() + + typer.echo(f"Loading checkpoint: {checkpoint}") + success, message = backend.load_checkpoint( + checkpoint_path = str(checkpoint), + max_seq_length = max_seq_length, + load_in_4bit = load_in_4bit, + ) + if not success: + typer.echo(f"Error: {message}", err = True) + raise typer.Exit(code = 1) + typer.echo(message) + + typer.echo(f"Exporting as {format}...") + if format == "merged-16bit": + success, message = backend.export_merged_model( + save_directory = str(output_dir), + format_type = "16-bit (FP16)", + push_to_hub = push_to_hub, + repo_id = repo_id, + hf_token = hf_token, + private = private, + ) + elif format == "merged-4bit": + success, message = backend.export_merged_model( + save_directory = str(output_dir), + format_type = "4-bit (FP4)", + push_to_hub = push_to_hub, + repo_id = repo_id, + hf_token = hf_token, + private = private, + ) + elif format == "gguf": + success, message = backend.export_gguf( + save_directory = str(output_dir), + quantization_method = quantization.upper(), + push_to_hub = push_to_hub, + repo_id = repo_id, + hf_token = hf_token, + ) + elif format == "lora": + success, message = backend.export_lora_adapter( + save_directory = str(output_dir), + push_to_hub = push_to_hub, + repo_id = repo_id, + hf_token = hf_token, + private = private, + ) + + if not success: + typer.echo(f"Error: {message}", err = True) + raise typer.Exit(code = 1) + + typer.echo(message) diff --git a/cli/commands/inference.py b/cli/commands/inference.py new file mode 100644 index 0000000000..18de68c4d9 --- /dev/null +++ b/cli/commands/inference.py @@ -0,0 +1,69 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import sys +from typing import Optional + +import typer + + +def inference( + model: str = typer.Argument(..., help = "HF model id or local path."), + prompt: str = typer.Argument(..., help = "Prompt to send to the model."), + hf_token: Optional[str] = typer.Option( + None, "--hf-token", envvar = "HF_TOKEN", help = "Hugging Face token if needed." + ), + temperature: float = typer.Option(0.7, "--temperature"), + top_p: float = typer.Option(0.9, "--top-p"), + top_k: int = typer.Option(40, "--top-k"), + max_new_tokens: int = typer.Option(256, "--max-new-tokens"), + repetition_penalty: float = typer.Option(1.1, "--repetition-penalty"), + system_prompt: str = typer.Option( + "", + "--system-prompt", + help = "Optional system prompt to prepend.", + ), + max_seq_length: int = typer.Option(2048, "--max-seq-length"), + load_in_4bit: bool = typer.Option(True, "--load-in-4bit/--no-load-in-4bit"), +): + """Run a single inference using the specified model.""" + from studio.backend.core import ModelConfig, get_inference_backend + + inference_backend = get_inference_backend() + model_config = ModelConfig.from_ui_selection( + dropdown_value = model, search_value = None, hf_token = hf_token, is_lora = False + ) + if not model_config: + typer.echo("Could not resolve model config", err = True) + raise typer.Exit(code = 1) + + if not inference_backend.load_model( + config = model_config, + max_seq_length = max_seq_length, + load_in_4bit = load_in_4bit, + hf_token = hf_token, + ): + typer.echo("Model load failed", err = True) + raise typer.Exit(code = 1) + + messages = [{"role": "user", "content": prompt}] + stream = inference_backend.generate_chat_response( + messages = messages, + system_prompt = system_prompt, + temperature = temperature, + top_p = top_p, + top_k = top_k, + max_new_tokens = max_new_tokens, + repetition_penalty = repetition_penalty, + ) + + typer.echo("Assistant:", nl = True) + previous = "" + for chunk in stream: + delta = chunk[len(previous) :] + if delta: + sys.stdout.write(delta) + sys.stdout.flush() + previous = chunk + sys.stdout.write("\n") + sys.stdout.flush() diff --git a/cli/commands/studio.py b/cli/commands/studio.py new file mode 100644 index 0000000000..45b2e4fd13 --- /dev/null +++ b/cli/commands/studio.py @@ -0,0 +1,374 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import os +import platform +import subprocess +import sys +import time +from pathlib import Path +from typing import Optional +import typer + +studio_app = typer.Typer(help = "Unsloth Studio commands.") + +STUDIO_HOME = Path.home() / ".unsloth" / "studio" + +# __file__ is cli/commands/studio.py — two parents up is the package root +# (either site-packages or the repo root for editable installs). +_PACKAGE_ROOT = Path(__file__).resolve().parent.parent.parent + + +def _is_repo_root(path: Path) -> bool: + """Check if a directory looks like the repo root (actual git clone, not site-packages).""" + return ( + (path / ".git").exists() + and (path / "pyproject.toml").is_file() + and ( + (path / "studio" / "setup.sh").is_file() + or (path / "studio" / "setup.ps1").is_file() + ) + ) + + +def _get_repo_root() -> Optional[Path]: + """Find the git clone repo root. + + Used only by setup() — checks __file__ first (editable install), + then walks CWD parents (wheel install, user is inside the clone). + """ + # Check 1: __file__ is in the repo (editable install) + if _is_repo_root(_PACKAGE_ROOT): + return _PACKAGE_ROOT + # Check 2: CWD or any parent is the repo + cwd = Path.cwd().resolve() + for parent in (cwd, *cwd.parents): + if _is_repo_root(parent): + return parent + return None + + +def _studio_venv_python() -> Optional[Path]: + """Return the studio venv Python binary, or None if not set up.""" + if platform.system() == "Windows": + p = STUDIO_HOME / ".venv" / "Scripts" / "python.exe" + else: + p = STUDIO_HOME / ".venv" / "bin" / "python" + return p if p.is_file() else None + + +def _find_run_py() -> Optional[Path]: + """Find studio/backend/run.py. + + No CWD dependency — works from any directory. + Since studio/ is now a proper package (has __init__.py), it lives in + site-packages after pip install, right next to cli/. + """ + # 1. Relative to __file__ (site-packages or editable repo root) + run_py = _PACKAGE_ROOT / "studio" / "backend" / "run.py" + if run_py.is_file(): + return run_py + # 2. Studio venv's site-packages (Linux + Windows layouts) + for pattern in ( + "lib/python*/site-packages/studio/backend/run.py", + "Lib/site-packages/studio/backend/run.py", + ): + for match in (STUDIO_HOME / ".venv").glob(pattern): + return match + return None + + +def _find_install_script() -> Optional[Path]: + """Find studio/install_python_stack.py. + + No CWD dependency — works from any directory. + """ + # 1. Relative to __file__ (site-packages or editable repo root) + s = _PACKAGE_ROOT / "studio" / "install_python_stack.py" + if s.is_file(): + return s + # 2. Studio venv's site-packages + for pattern in ( + "lib/python*/site-packages/studio/install_python_stack.py", + "Lib/site-packages/studio/install_python_stack.py", + ): + for match in (STUDIO_HOME / ".venv").glob(pattern): + return match + return None + + +def _find_setup_script() -> Optional[Path]: + """Find studio/setup.sh or studio/setup.ps1. + + No CWD dependency — works from any directory. + """ + name = "setup.ps1" if platform.system() == "Windows" else "setup.sh" + # 1. Relative to __file__ (site-packages or editable repo root) + s = _PACKAGE_ROOT / "studio" / name + if s.is_file(): + return s + # 2. Studio venv's site-packages + for pattern in ( + f"lib/python*/site-packages/studio/{name}", + f"Lib/site-packages/studio/{name}", + ): + for match in (STUDIO_HOME / ".venv").glob(pattern): + return match + return None + + +# ── unsloth studio (server) ────────────────────────────────────────── + + +@studio_app.callback(invoke_without_command = True) +def studio_default( + ctx: typer.Context, + port: int = typer.Option(8000, "--port", "-p"), + host: str = typer.Option("0.0.0.0", "--host", "-H"), + frontend: Optional[Path] = typer.Option(None, "--frontend", "-f"), + silent: bool = typer.Option(False, "--silent", "-q"), +): + """Launch the Unsloth Studio server.""" + if ctx.invoked_subcommand is not None: + return + + # Always use the studio venv if it exists and we're not already in it + studio_venv_dir = STUDIO_HOME / ".venv" + in_studio_venv = sys.prefix.startswith(str(studio_venv_dir)) + + if not in_studio_venv: + studio_python = _studio_venv_python() + run_py = _find_run_py() + if studio_python and run_py: + if not silent: + typer.echo("Launching with studio venv...") + args = [ + str(studio_python), + str(run_py), + "--host", + host, + "--port", + str(port), + ] + if frontend: + args.extend(["--frontend", str(frontend)]) + if silent: + args.append("--silent") + os.execvp(str(studio_python), args) + else: + typer.echo("Studio not set up. Run 'unsloth studio setup' first.") + raise typer.Exit(1) + + from studio.backend.run import run_server + + if not silent: + from studio.backend.run import _resolve_external_ip + + display_host = _resolve_external_ip() if host == "0.0.0.0" else host + typer.echo(f"Starting Unsloth Studio on http://{display_host}:{port}") + + run_server( + host = host, + port = port, + frontend_path = frontend, + silent = silent, + ) + + try: + while True: + time.sleep(1) + except KeyboardInterrupt: + typer.echo("\nShutting down...") + + +# ── unsloth studio setup ───────────────────────────────────────────── + + +@studio_app.command() +def setup(): + """Run one-time Studio environment setup.""" + # If we're inside a git clone, use the full setup script (builds frontend, etc.) + repo = _get_repo_root() + if repo: + _dev_setup(repo) + else: + _pip_setup() + + +def _dev_setup(repo_root: Path): + """Git-clone: run setup.sh / setup.ps1.""" + studio_dir = repo_root / "studio" + if platform.system() == "Windows": + script = studio_dir / "setup.ps1" + subprocess.run( + ["powershell", "-ExecutionPolicy", "Bypass", "-File", str(script)], + check = True, + ) + else: + script = studio_dir / "setup.sh" + subprocess.run(["bash", str(script)], check = True) + + +def _pip_setup(): + """Pip-install: create studio venv, install all deps, build extras.""" + import venv as _venv + + venv_dir = STUDIO_HOME / ".venv" + venv_t5_dir = STUDIO_HOME / ".venv_t5" + + if platform.system() == "Windows": + venv_python = venv_dir / "Scripts" / "python.exe" + venv_pip = venv_dir / "Scripts" / "pip.exe" + else: + venv_python = venv_dir / "bin" / "python" + venv_pip = venv_dir / "bin" / "pip" + + typer.echo("Setting up Unsloth Studio...") + + # 1. Create venv + if not venv_python.is_file(): + typer.echo(f" Creating venv at {venv_dir}...") + STUDIO_HOME.mkdir(parents = True, exist_ok = True) + _venv.create(str(venv_dir), with_pip = True) + + # 2. Install all Python deps via install_python_stack.py + install_script = _find_install_script() + if install_script: + typer.echo(" Installing Python dependencies...") + subprocess.run([str(venv_python), str(install_script)], check = True) + else: + typer.echo("Error: Could not find install_python_stack.py") + raise typer.Exit(1) + + # 3. Pre-install transformers 5.x overlay + if venv_t5_dir.is_dir() and any(venv_t5_dir.iterdir()): + typer.echo(f" Transformers 5.x overlay already at {venv_t5_dir}") + else: + typer.echo(" Installing transformers 5.x overlay...") + venv_t5_dir.mkdir(parents = True, exist_ok = True) + subprocess.run( + [ + str(venv_pip), + "install", + "--target", + str(venv_t5_dir), + "--no-deps", + "transformers==5.2.0", + ], + check = True, + ) + subprocess.run( + [ + str(venv_pip), + "install", + "--target", + str(venv_t5_dir), + "--no-deps", + "huggingface_hub==1.3.0", + ], + check = True, + ) + typer.echo(f" Installed to {venv_t5_dir}") + + # 4. Build llama.cpp + _build_llama_cpp() + + typer.echo("") + typer.echo("Setup complete! Run 'unsloth studio' to start.") + + +def _build_llama_cpp(): + """Build llama.cpp at ~/.unsloth/llama.cpp/.""" + import shutil + + unsloth_home = Path.home() / ".unsloth" + llama_dir = unsloth_home / "llama.cpp" + + if not shutil.which("cmake"): + typer.echo(" cmake not found — skipping llama.cpp build") + return + if not shutil.which("git"): + typer.echo(" git not found — skipping llama.cpp build") + return + + typer.echo(" Building llama.cpp for GGUF inference...") + + if llama_dir.exists(): + shutil.rmtree(llama_dir) + unsloth_home.mkdir(parents = True, exist_ok = True) + + result = subprocess.run( + [ + "git", + "clone", + "--depth", + "1", + "https://github.com/ggml-org/llama.cpp.git", + str(llama_dir), + ], + stdout = subprocess.PIPE, + stderr = subprocess.STDOUT, + ) + if result.returncode != 0: + typer.echo(" Failed to clone llama.cpp") + return + + cmake_args = [] + nvcc_path = shutil.which("nvcc") + if not nvcc_path and Path("/usr/local/cuda/bin/nvcc").is_file(): + nvcc_path = "/usr/local/cuda/bin/nvcc" + if nvcc_path: + typer.echo(f" Building with CUDA (nvcc: {nvcc_path})...") + cmake_args.append("-DGGML_CUDA=ON") + else: + typer.echo(" Building CPU-only...") + + build_dir = llama_dir / "build" + result = subprocess.run( + ["cmake", "-S", str(llama_dir), "-B", str(build_dir)] + cmake_args, + stdout = subprocess.PIPE, + stderr = subprocess.STDOUT, + ) + if result.returncode != 0: + typer.echo(" cmake configure failed") + return + + ncpu = str(os.cpu_count() or 4) + result = subprocess.run( + [ + "cmake", + "--build", + str(build_dir), + "--config", + "Release", + "--target", + "llama-server", + f"-j{ncpu}", + ], + stdout = subprocess.PIPE, + stderr = subprocess.STDOUT, + ) + if result.returncode != 0: + typer.echo(" llama-server build failed") + return + + subprocess.run( + [ + "cmake", + "--build", + str(build_dir), + "--config", + "Release", + "--target", + "llama-quantize", + f"-j{ncpu}", + ], + stdout = subprocess.PIPE, + stderr = subprocess.STDOUT, + ) + + server_bin = build_dir / "bin" / "llama-server" + if server_bin.is_file(): + typer.echo(f" llama-server built at {server_bin}") + else: + typer.echo(" llama-server binary not found after build") diff --git a/cli/commands/train.py b/cli/commands/train.py new file mode 100644 index 0000000000..665956a80c --- /dev/null +++ b/cli/commands/train.py @@ -0,0 +1,144 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import time +from pathlib import Path +from typing import Optional + +import typer + +from cli.config import Config, load_config +from cli.options import add_options_from_config + + +@add_options_from_config(Config) +def train( + config: Optional[Path] = typer.Option( + None, + "--config", + "-c", + help = "Path to YAML/JSON config file. CLI flags override config values.", + ), + hf_token: Optional[str] = typer.Option( + None, "--hf-token", envvar = "HF_TOKEN", help = "Hugging Face token if needed." + ), + wandb_token: Optional[str] = typer.Option( + None, "--wandb-token", envvar = "WANDB_API_KEY", help = "Weights & Biases API key." + ), + dry_run: bool = typer.Option( + False, + "--dry-run", + help = "Show resolved config and exit without training.", + ), + config_overrides: dict = None, +): + """Launch training using the existing Unsloth training backend.""" + try: + cfg = load_config(config) + except FileNotFoundError as e: + typer.echo(f"Error: {e}", err = True) + raise typer.Exit(code = 2) + + cfg.apply_overrides(**config_overrides) + + # CLI/env tokens take precedence over config + # Handle case where typer.Option isn't resolved (decorator interaction) + from typer.models import OptionInfo + + if isinstance(hf_token, OptionInfo): + hf_token = None + if isinstance(wandb_token, OptionInfo): + wandb_token = None + hf_token = hf_token or cfg.logging.hf_token + wandb_token = wandb_token or cfg.logging.wandb_token + + if dry_run: + import yaml + + data = cfg.model_dump() + data["training"]["output_dir"] = str(data["training"]["output_dir"]) + typer.echo(yaml.dump(data, default_flow_style = False, sort_keys = False)) + raise typer.Exit(code = 0) + + if not cfg.model: + typer.echo("Error: provide --model or set model in --config", err = True) + raise typer.Exit(code = 2) + + if not cfg.data.dataset and not cfg.data.local_dataset: + typer.echo( + "Error: provide --dataset or --local-dataset (or via --config)", err = True + ) + raise typer.Exit(code = 2) + + # Check if the model path is a LoRA adapter (has adapter_config.json) + model_path = Path(cfg.model) if cfg.model else None + model_is_lora = ( + model_path + and model_path.is_dir() + and (model_path / "adapter_config.json").exists() + ) + use_lora = cfg.training.training_type.lower() == "lora" + + if model_is_lora and not use_lora: + typer.echo( + "Error: Cannot do full finetuning on a LoRA adapter. " + "Use --training-type lora or provide a base model.", + err = True, + ) + raise typer.Exit(code = 2) + + from studio.backend.core.training.trainer import UnslothTrainer + + trainer = UnslothTrainer() + + # Load model (trainer.is_vlm is set after this) + if not trainer.load_model( + model_name = cfg.model, + max_seq_length = cfg.training.max_seq_length, + load_in_4bit = cfg.training.load_in_4bit if use_lora else False, + hf_token = hf_token, + ): + typer.echo("Model load failed", err = True) + raise typer.Exit(code = 1) + + is_vision = trainer.is_vlm + + if not trainer.prepare_model_for_training(**cfg.model_kwargs(use_lora, is_vision)): + typer.echo("Model preparation failed", err = True) + raise typer.Exit(code = 1) + + result = trainer.load_and_format_dataset( + dataset_source = cfg.data.dataset or "", + format_type = cfg.data.format_type, + local_datasets = cfg.data.local_dataset, + ) + if result is None: + typer.echo("Dataset load failed", err = True) + raise typer.Exit(code = 1) + + ds, eval_ds = result + + training_kwargs = cfg.training_kwargs() + training_kwargs["wandb_token"] = wandb_token # CLI/env takes precedence + started = trainer.start_training( + dataset = ds, eval_dataset = eval_ds, **training_kwargs + ) + + if not started: + typer.echo("Training failed to start", err = True) + raise typer.Exit(code = 1) + + try: + while trainer.training_thread and trainer.training_thread.is_alive(): + time.sleep(1) + except KeyboardInterrupt: + typer.echo("Stopping training (Ctrl+C detected)...") + trainer.stop_training() + finally: + if trainer.training_thread: + trainer.training_thread.join() + + final = trainer.get_training_progress() + if getattr(final, "error", None): + typer.echo(f"Training error: {final.error}", err = True) + raise typer.Exit(code = 1) diff --git a/cli/commands/ui.py b/cli/commands/ui.py new file mode 100644 index 0000000000..10db9e243e --- /dev/null +++ b/cli/commands/ui.py @@ -0,0 +1,76 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import os +import sys +import time +from pathlib import Path +from typing import Optional + +import typer + + +def ui( + port: int = typer.Option( + 8000, "--port", "-p", help = "Port to run the UI server on." + ), + host: str = typer.Option( + "0.0.0.0", "--host", "-H", help = "Host address to bind to." + ), + frontend: Optional[Path] = typer.Option( + None, "--frontend", "-f", help = "Path to frontend build directory." + ), + silent: bool = typer.Option( + False, "--silent", "-q", help = "Suppress startup messages." + ), +): + """Launch the Unsloth web UI backend server (alias for 'unsloth studio').""" + from cli.commands.studio import _studio_venv_python, _find_run_py, STUDIO_HOME + + # Re-execute in studio venv if available and not already inside it + studio_venv_dir = STUDIO_HOME / ".venv" + in_studio_venv = sys.prefix.startswith(str(studio_venv_dir)) + + if not in_studio_venv: + studio_python = _studio_venv_python() + run_py = _find_run_py() + if studio_python and run_py: + if not silent: + typer.echo("Launching with studio venv...") + args = [ + str(studio_python), + str(run_py), + "--host", + host, + "--port", + str(port), + ] + if frontend: + args.extend(["--frontend", str(frontend)]) + if silent: + args.append("--silent") + os.execvp(str(studio_python), args) + else: + typer.echo("Studio not set up. Run 'unsloth studio setup' first.") + raise typer.Exit(1) + + from studio.backend.run import run_server + + if not silent: + from studio.backend.run import _resolve_external_ip + + display_host = _resolve_external_ip() if host == "0.0.0.0" else host + typer.echo(f"Starting Unsloth Studio on http://{display_host}:{port}") + + run_server( + host = host, + port = port, + frontend_path = frontend, + silent = silent, + ) + + try: + while True: + time.sleep(1) + except KeyboardInterrupt: + typer.echo("\nShutting down...") diff --git a/cli/config.py b/cli/config.py new file mode 100644 index 0000000000..d5362f5673 --- /dev/null +++ b/cli/config.py @@ -0,0 +1,149 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +from pathlib import Path +from typing import Literal, Optional, List + +import yaml +from pydantic import BaseModel, Field + + +class DataConfig(BaseModel): + dataset: Optional[str] = None + local_dataset: Optional[List[str]] = None + format_type: Literal["auto", "alpaca", "chatml", "sharegpt"] = "auto" + + +class TrainingConfig(BaseModel): + training_type: Literal["lora", "full"] = "lora" + max_seq_length: int = 2048 + load_in_4bit: bool = True + output_dir: Path = Path("./outputs") + num_epochs: int = 3 + learning_rate: float = 2e-4 + batch_size: int = 2 + gradient_accumulation_steps: int = 4 + warmup_steps: int = 5 + max_steps: int = 0 + save_steps: int = 0 + weight_decay: float = 0.01 + random_seed: int = 3407 + packing: bool = False + train_on_completions: bool = False + gradient_checkpointing: Literal["unsloth", "true", "none"] = "unsloth" + + +class LoraConfig(BaseModel): + lora_r: int = 64 + lora_alpha: int = 16 + lora_dropout: float = 0.0 + target_modules: str = "q_proj,k_proj,v_proj,o_proj,gate_proj,up_proj,down_proj" + vision_all_linear: bool = False + use_rslora: bool = False + use_loftq: bool = False + finetune_vision_layers: bool = True + finetune_language_layers: bool = True + finetune_attention_modules: bool = True + finetune_mlp_modules: bool = True + + +class LoggingConfig(BaseModel): + enable_wandb: bool = False + wandb_project: str = "unsloth-training" + wandb_token: Optional[str] = None + enable_tensorboard: bool = False + tensorboard_dir: str = "runs" + hf_token: Optional[str] = None + + +class Config(BaseModel): + model: Optional[str] = None + data: DataConfig = Field(default_factory = DataConfig) + training: TrainingConfig = Field(default_factory = TrainingConfig) + lora: LoraConfig = Field(default_factory = LoraConfig) + logging: LoggingConfig = Field(default_factory = LoggingConfig) + + def apply_overrides(self, **kwargs): + """Apply CLI overrides by matching arg names to config fields.""" + for key, value in kwargs.items(): + if value is None: + continue + if hasattr(self, key): + setattr(self, key, value) + else: + for section in (self.data, self.training, self.lora, self.logging): + if hasattr(section, key): + setattr(section, key, value) + break + + def model_kwargs(self, use_lora: bool, is_vision: bool) -> dict: + """Return kwargs for trainer.prepare_model_for_training().""" + # Determine target modules based on model type + if use_lora and is_vision: + # Vision models expect a string (e.g., "all-linear"); fall back to None to use trainer defaults + target_modules = "all-linear" if self.lora.vision_all_linear else None + else: + parsed = [ + m.strip() + for m in str(self.lora.target_modules).split(",") + if m and m.strip() + ] + target_modules = parsed or None + + return { + "use_lora": use_lora, + "finetune_vision_layers": self.lora.finetune_vision_layers, + "finetune_language_layers": self.lora.finetune_language_layers, + "finetune_attention_modules": self.lora.finetune_attention_modules, + "finetune_mlp_modules": self.lora.finetune_mlp_modules, + "target_modules": target_modules, + "lora_r": self.lora.lora_r, + "lora_alpha": self.lora.lora_alpha, + "lora_dropout": self.lora.lora_dropout, + "use_gradient_checkpointing": self.training.gradient_checkpointing, + "use_rslora": self.lora.use_rslora, + "use_loftq": self.lora.use_loftq, + } + + def training_kwargs(self) -> dict: + """Return kwargs for trainer.start_training().""" + return { + "output_dir": str(self.training.output_dir), + "num_epochs": self.training.num_epochs, + "learning_rate": self.training.learning_rate, + "batch_size": self.training.batch_size, + "gradient_accumulation_steps": self.training.gradient_accumulation_steps, + "warmup_steps": self.training.warmup_steps, + "max_steps": self.training.max_steps, + "save_steps": self.training.save_steps, + "weight_decay": self.training.weight_decay, + "random_seed": self.training.random_seed, + "packing": self.training.packing, + "train_on_completions": self.training.train_on_completions, + "max_seq_length": self.training.max_seq_length, + "enable_wandb": self.logging.enable_wandb, + "wandb_project": self.logging.wandb_project, + "wandb_token": self.logging.wandb_token, + "enable_tensorboard": self.logging.enable_tensorboard, + "tensorboard_dir": self.logging.tensorboard_dir, + } + + +def load_config(path: Optional[Path]) -> Config: + """Load config from YAML/JSON file, or return defaults if no path given.""" + if not path: + return Config() + + path = Path(path) + if not path.exists(): + raise FileNotFoundError(f"Config file not found: {path}") + + text = path.read_text(encoding = "utf-8") + if path.suffix.lower() in {".yaml", ".yml"}: + data = yaml.safe_load(text) or {} + else: + import json + + data = json.loads(text or "{}") + + return Config(**data) diff --git a/cli/options.py b/cli/options.py new file mode 100644 index 0000000000..f1ab586ee5 --- /dev/null +++ b/cli/options.py @@ -0,0 +1,153 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Generate Typer CLI options from Pydantic models.""" + +import functools +import inspect +from pathlib import Path +from typing import Any, Callable, Optional, get_args, get_origin + +import typer +from pydantic import BaseModel + + +def _python_name_to_cli_flag(name: str) -> str: + """Convert python_name to --cli-flag.""" + return "--" + name.replace("_", "-") + + +def _unwrap_optional(annotation: Any) -> Any: + """Unwrap Optional[X] to X.""" + origin = get_origin(annotation) + if origin is not None: + args = get_args(annotation) + if type(None) in args: + non_none = [a for a in args if a is not type(None)] + if non_none: + return non_none[0] + return annotation + + +def _is_bool_field(annotation: Any) -> bool: + """Check if field is a boolean (including Optional[bool]).""" + return _unwrap_optional(annotation) is bool + + +def _is_list_type(annotation: Any) -> bool: + """Check if type is a List.""" + return get_origin(annotation) is list + + +def _get_python_type(annotation: Any) -> type: + """Get the Python type for annotation.""" + unwrapped = _unwrap_optional(annotation) + if unwrapped in (str, int, float, bool, Path): + return unwrapped + return str + + +def _collect_config_fields(config_class: type[BaseModel]) -> list[tuple[str, Any]]: + """ + Collect all fields from a config class, flattening nested models. Returns list of + (name, field_info) tuples. Raises ValueError on duplicate field names. + """ + fields = [] + seen_names: set[str] = set() + + for name, field_info in config_class.model_fields.items(): + annotation = field_info.annotation + # Skip nested models - recurse into them + if isinstance(annotation, type) and issubclass(annotation, BaseModel): + for nested_name, nested_field in annotation.model_fields.items(): + if nested_name in seen_names: + raise ValueError(f"Duplicate field name '{nested_name}' in config") + seen_names.add(nested_name) + fields.append((nested_name, nested_field)) + else: + if name in seen_names: + raise ValueError(f"Duplicate field name '{name}' in config") + seen_names.add(name) + fields.append((name, field_info)) + return fields + + +def add_options_from_config(config_class: type[BaseModel]) -> Callable: + """ + Decorator that adds CLI options for all fields in a Pydantic config model. + + The decorated function should declare a `config_overrides: dict = None` parameter + which will receive a dict of all CLI-provided config values. + """ + fields = _collect_config_fields(config_class) + field_names = { + name for name, field_info in fields if not _is_list_type(field_info.annotation) + } + + def decorator(func: Callable) -> Callable: + sig = inspect.signature(func) + original_params = list(sig.parameters.values()) + original_param_names = {p.name for p in original_params} + + # Build new parameters: config fields first, then original params + new_params = [] + + for field_name, field_info in fields: + # Skip fields already defined in function signature (e.g., with envvar) + if field_name in original_param_names: + continue + annotation = field_info.annotation + if _is_list_type(annotation): + continue + + flag_name = _python_name_to_cli_flag(field_name) + help_text = field_info.description or "" + + if _is_bool_field(annotation): + default = typer.Option( + None, + f"{flag_name}/--no-{field_name.replace('_', '-')}", + help = help_text, + ) + param = inspect.Parameter( + field_name, + inspect.Parameter.POSITIONAL_OR_KEYWORD, + default = default, + annotation = Optional[bool], + ) + else: + py_type = _get_python_type(annotation) + default = typer.Option(None, flag_name, help = help_text) + param = inspect.Parameter( + field_name, + inspect.Parameter.POSITIONAL_OR_KEYWORD, + default = default, + annotation = Optional[py_type], + ) + new_params.append(param) + + # Add original params, excluding config_overrides (will be injected) + for param in original_params: + if param.name != "config_overrides": + new_params.append(param) + + new_sig = sig.replace(parameters = new_params) + + @functools.wraps(func) + def wrapper(*args, **kwargs): + config_overrides = {} + for key in list(kwargs.keys()): + if key in field_names: + if kwargs[key] is not None: + config_overrides[key] = kwargs[key] + # Only delete if not an explicitly declared parameter + if key not in original_param_names: + del kwargs[key] + + kwargs["config_overrides"] = config_overrides + return func(*args, **kwargs) + + wrapper.__signature__ = new_sig + return wrapper + + return decorator diff --git a/pyproject.toml b/pyproject.toml index 9dc99866cf..5ee66153f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ authors = [ {name = "Unsloth AI team"}, ] maintainers = [ - {name = "Daniel Han", email = "danielhanchen@gmail.com"}, + {name = "Daniel Han", email = "daniel@unsloth.ai"}, {name = "Michael Han", email = "info@unsloth.ai"}, ] classifiers = [ @@ -24,12 +24,24 @@ classifiers = [ "Environment :: GPU :: NVIDIA CUDA", "Topic :: Scientific/Engineering :: Artificial Intelligence", ] +dependencies = [ + "typer", + "pydantic", + "pyyaml", + "nest-asyncio", +] + +[project.scripts] +unsloth = "cli:app" [tool.setuptools.dynamic] version = {attr = "unsloth.models._utils.__version__"} [tool.setuptools] -include-package-data = false +include-package-data = true + +[tool.setuptools.package-data] +studio = ["frontend/dist/**/*"] [tool.setuptools.packages.find] exclude = ["images*", "tests*", "kernels/moe*"] diff --git a/studio/LICENSE.AGPL-3.0 b/studio/LICENSE.AGPL-3.0 new file mode 100644 index 0000000000..be3f7b28e5 --- /dev/null +++ b/studio/LICENSE.AGPL-3.0 @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/studio/Unsloth_Studio_Colab.ipynb b/studio/Unsloth_Studio_Colab.ipynb new file mode 100644 index 0000000000..15bc7d4822 --- /dev/null +++ b/studio/Unsloth_Studio_Colab.ipynb @@ -0,0 +1,111 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "f2b0c6a1", + "metadata": {}, + "source": [ + "**License Notice**\n", + "\n", + "SPDX-License-Identifier: AGPL-3.0-only\n", + "\n", + "Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "447c1156", + "metadata": {}, + "outputs": [], + "source": [ + "# ===========================================\n", + "# ⚠️ GPU Check - Run This First!\n", + "# ===========================================\n", + "import torch\n", + "\n", + "print(\"🔍 Checking for GPU...\")\n", + "if not torch.cuda.is_available():\n", + " print(\"❌ ERROR: No GPU detected!\")\n", + " print(\"\\n📋 To enable GPU:\")\n", + " print(\" 1. Go to: Runtime → Change runtime type\")\n", + " print(\" 2. Select: Hardware accelerator → GPU (T4 is free)\")\n", + " print(\" 3. Click: Save\")\n", + " print(\" 4. Restart and re-run all cells\")\n", + " raise RuntimeError(\"⛔ GPU required for Unsloth Studio\")\n", + "else:\n", + " gpu_name = torch.cuda.get_device_name(0)\n", + " print(f\"✅ GPU detected: {gpu_name}\")\n", + " print(\" Ready to proceed!\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f04a9b46", + "metadata": {}, + "outputs": [], + "source": [ + "# ===========================================\n", + "# GitHub Authentication (Private Repo)\n", + "# ===========================================\n", + "from getpass import getpass\n", + "import os\n", + "\n", + "print(\"🔐 GitHub Token Required\")\n", + "print(\"Get token: https://github.com/settings/tokens\")\n", + "print(\"Scope needed: 'repo'\")\n", + "print(\"-\" * 50)\n", + "\n", + "github_token = getpass(\"Enter GitHub Token: \")\n", + "os.environ['GITHUB_TOKEN'] = github_token\n", + "print(\"✅ Token stored\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "27e68f91", + "metadata": {}, + "outputs": [], + "source": [ + "# ===========================================\n", + "# Setup: Clone repo and run setup\n", + "# ===========================================\n", + "\n", + "import os\n", + "github_token = os.environ['GITHUB_TOKEN']\n", + "!git clone https://{github_token}@github.com/unslothai/new-ui-prototype.git\n", + "%cd /content/new-ui-prototype\n", + "\n", + "# Run setup script\n", + "!chmod +x setup.sh\n", + "!./setup.sh" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "277e431e", + "metadata": {}, + "outputs": [], + "source": [ + "# ===========================================\n", + "# Start Unsloth Studio\n", + "# ===========================================\n", + "import sys\n", + "sys.path.insert(0, '/content/new-ui-prototype/studio/backend')\n", + "\n", + "from colab import start\n", + "start()" + ] + } + ], + "metadata": { + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/studio/__init__.py b/studio/__init__.py new file mode 100644 index 0000000000..32014236c6 --- /dev/null +++ b/studio/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 diff --git a/studio/backend/__init__.py b/studio/backend/__init__.py new file mode 100644 index 0000000000..32014236c6 --- /dev/null +++ b/studio/backend/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 diff --git a/studio/backend/assets/__init__.py b/studio/backend/assets/__init__.py new file mode 100644 index 0000000000..32014236c6 --- /dev/null +++ b/studio/backend/assets/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 diff --git a/studio/backend/assets/configs/__init__.py b/studio/backend/assets/configs/__init__.py new file mode 100644 index 0000000000..32014236c6 --- /dev/null +++ b/studio/backend/assets/configs/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 diff --git a/studio/backend/assets/configs/full_finetune.yaml b/studio/backend/assets/configs/full_finetune.yaml new file mode 100644 index 0000000000..7536ed1f11 --- /dev/null +++ b/studio/backend/assets/configs/full_finetune.yaml @@ -0,0 +1,42 @@ +model: unsloth/Qwen2.5-0.5B + +data: + dataset: tatsu-lab/alpaca + format_type: auto + +training: + training_type: full + max_seq_length: 2048 + load_in_4bit: false + output_dir: outputs + num_epochs: 1 + learning_rate: 0.0002 + batch_size: 1 + gradient_accumulation_steps: 4 + warmup_steps: 5 + max_steps: 0 + save_steps: 0 + weight_decay: 0.01 + random_seed: 3407 + packing: false + train_on_completions: false + gradient_checkpointing: "unsloth" + +lora: + lora_r: 64 + lora_alpha: 16 + lora_dropout: 0.0 + target_modules: "" + vision_all_linear: false + use_rslora: false + use_loftq: false + finetune_vision_layers: true + finetune_language_layers: true + finetune_attention_modules: true + finetune_mlp_modules: true + +logging: + enable_wandb: false + wandb_project: unsloth-training + enable_tensorboard: false + tensorboard_dir: runs diff --git a/studio/backend/assets/configs/lora_text.yaml b/studio/backend/assets/configs/lora_text.yaml new file mode 100644 index 0000000000..7101a00e85 --- /dev/null +++ b/studio/backend/assets/configs/lora_text.yaml @@ -0,0 +1,42 @@ +model: unsloth/Qwen2.5-0.5B + +data: + dataset: tatsu-lab/alpaca + format_type: auto + +training: + training_type: lora + max_seq_length: 2048 + load_in_4bit: true + output_dir: outputs + num_epochs: 1 + learning_rate: 0.0002 + batch_size: 2 + gradient_accumulation_steps: 4 + warmup_steps: 5 + max_steps: 0 + save_steps: 0 + weight_decay: 0.01 + random_seed: 3407 + packing: false + train_on_completions: false + gradient_checkpointing: "unsloth" + +lora: + lora_r: 64 + lora_alpha: 16 + lora_dropout: 0.0 + target_modules: "q_proj,k_proj,v_proj,o_proj,gate_proj,up_proj,down_proj" + vision_all_linear: false + use_rslora: false + use_loftq: false + finetune_vision_layers: true + finetune_language_layers: true + finetune_attention_modules: true + finetune_mlp_modules: true + +logging: + enable_wandb: false + wandb_project: unsloth-training + enable_tensorboard: false + tensorboard_dir: runs diff --git a/studio/backend/assets/configs/model_defaults/default.yaml b/studio/backend/assets/configs/model_defaults/default.yaml new file mode 100644 index 0000000000..d96e5077b2 --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/default.yaml @@ -0,0 +1,56 @@ +# Default model training parameters +# Used for models without specific configurations + +training: + trust_remote_code: false + max_seq_length: 2048 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 5e-5 + batch_size: 2 + gradient_accumulation_steps: 4 + warmup_ratio: 0.1 + max_steps: 30 + save_steps: 30 + weight_decay: 0.01 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 16 + lora_alpha: 16 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "k_proj" + - "v_proj" + - "o_proj" + - "gate_proj" + - "up_proj" + - "down_proj" + use_rslora: false + use_loftq: false + finetune_vision_layers: true + finetune_language_layers: true + finetune_attention_modules: true + finetune_mlp_modules: true + + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false + temperature: 0.7 + top_p: 0.95 + top_k: -1 + min_p: 0.01 + diff --git a/studio/backend/assets/configs/model_defaults/embedding/unsloth_Qwen3-Embedding-0.6B.yaml b/studio/backend/assets/configs/model_defaults/embedding/unsloth_Qwen3-Embedding-0.6B.yaml new file mode 100644 index 0000000000..1219648a8a --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/embedding/unsloth_Qwen3-Embedding-0.6B.yaml @@ -0,0 +1,43 @@ +# Model defaults for unsloth/Qwen3-Embedding-0.6B +# Based on Qwen3_Embedding_(0_6B).py embedding notebook +# Also applies to: unsloth/Qwen3-Embedding-4B + +training: + max_seq_length: 512 + # num_epochs: 2 + num_epochs: 0 + learning_rate: 3e-5 + batch_size: 256 + gradient_accumulation_steps: 1 + warmup_ratio: 0.03 + max_steps: 30 + save_steps: 30 + weight_decay: 0.01 + random_seed: 3407 + packing: false + train_on_completions: false + gradient_checkpointing: false + optim: "adamw_8bit" + lr_scheduler_type: "constant_with_warmup" + +lora: + lora_r: 32 + lora_alpha: 32 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "k_proj" + - "v_proj" + - "o_proj" + - "gate_proj" + - "up_proj" + - "down_proj" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "embedding-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 50 diff --git a/studio/backend/assets/configs/model_defaults/embedding/unsloth_all-MiniLM-L6-v2.yaml b/studio/backend/assets/configs/model_defaults/embedding/unsloth_all-MiniLM-L6-v2.yaml new file mode 100644 index 0000000000..db742e11b5 --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/embedding/unsloth_all-MiniLM-L6-v2.yaml @@ -0,0 +1,39 @@ +# Model defaults for unsloth/all-MiniLM-L6-v2 +# Based on All_MiniLM_L6_v2.py embedding notebook + +training: + max_seq_length: 512 + # num_epochs: 2 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 256 + gradient_accumulation_steps: 1 + warmup_ratio: 0.03 + max_steps: 30 + save_steps: 30 + weight_decay: 0.01 + random_seed: 3407 + packing: false + train_on_completions: false + gradient_checkpointing: false + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 64 + lora_alpha: 128 + lora_dropout: 0.0 + target_modules: + - "value" + - "key" + - "dense" + - "query" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "embedding-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 50 diff --git a/studio/backend/assets/configs/model_defaults/embedding/unsloth_bge-m3.yaml b/studio/backend/assets/configs/model_defaults/embedding/unsloth_bge-m3.yaml new file mode 100644 index 0000000000..499c112929 --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/embedding/unsloth_bge-m3.yaml @@ -0,0 +1,39 @@ +# Model defaults for unsloth/bge-m3 +# Based on BGE_M3.py embedding notebook + +training: + max_seq_length: 512 + # num_epochs: 2 + num_epochs: 0 + learning_rate: 3e-5 + batch_size: 256 + gradient_accumulation_steps: 1 + warmup_ratio: 0.03 + max_steps: 30 + save_steps: 30 + weight_decay: 0.01 + random_seed: 3407 + packing: false + train_on_completions: false + gradient_checkpointing: false + optim: "adamw_8bit" + lr_scheduler_type: "constant_with_warmup" + +lora: + lora_r: 32 + lora_alpha: 64 + lora_dropout: 0.0 + target_modules: + - "key" + - "query" + - "dense" + - "value" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "embedding-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 50 diff --git a/studio/backend/assets/configs/model_defaults/embedding/unsloth_embeddinggemma-300m.yaml b/studio/backend/assets/configs/model_defaults/embedding/unsloth_embeddinggemma-300m.yaml new file mode 100644 index 0000000000..016d284a16 --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/embedding/unsloth_embeddinggemma-300m.yaml @@ -0,0 +1,42 @@ +# Model defaults for unsloth/embeddinggemma-300m +# Based on EmbeddingGemma_(300M).py embedding notebook + +training: + max_seq_length: 1024 + # num_epochs: 1 + num_epochs: 0 + learning_rate: 2e-5 + batch_size: 64 + gradient_accumulation_steps: 2 + warmup_ratio: 0.03 + max_steps: 30 + save_steps: 30 + weight_decay: 0.01 + random_seed: 3407 + packing: false + train_on_completions: false + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 32 + lora_alpha: 64 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "k_proj" + - "v_proj" + - "o_proj" + - "gate_proj" + - "up_proj" + - "down_proj" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "embedding-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 5 diff --git a/studio/backend/assets/configs/model_defaults/embedding/unsloth_gte-modernbert-base.yaml b/studio/backend/assets/configs/model_defaults/embedding/unsloth_gte-modernbert-base.yaml new file mode 100644 index 0000000000..eefaecc8cd --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/embedding/unsloth_gte-modernbert-base.yaml @@ -0,0 +1,38 @@ +# Model defaults for unsloth/gte-modernbert-base +# Based on ModernBert.py embedding notebook + +training: + max_seq_length: 512 + # num_epochs: 2 + num_epochs: 0 + learning_rate: 3e-5 + batch_size: 256 + gradient_accumulation_steps: 1 + warmup_ratio: 0.03 + max_steps: 30 + save_steps: 30 + weight_decay: 0.01 + random_seed: 3407 + packing: false + train_on_completions: false + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "constant_with_warmup" + +lora: + lora_r: 64 + lora_alpha: 128 + lora_dropout: 0.0 + target_modules: + - "Wi" + - "Wo" + - "Wqkv" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "embedding-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 50 diff --git a/studio/backend/assets/configs/model_defaults/ernie/unsloth_ERNIE-4.5-21B-A3B-PT.yaml b/studio/backend/assets/configs/model_defaults/ernie/unsloth_ERNIE-4.5-21B-A3B-PT.yaml new file mode 100644 index 0000000000..52511c6eaf --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/ernie/unsloth_ERNIE-4.5-21B-A3B-PT.yaml @@ -0,0 +1,47 @@ +# Model defaults for unsloth/ERNIE-4.5-21B-A3B-PT +# Based on ERNIE_4_5_21B_A3B_PT-Conversational.ipynb +# Also applies to: unsloth/ERNIE-4.5-21B-A3B-PT + +training: + trust_remote_code: false + max_seq_length: 2048 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 4 + gradient_accumulation_steps: 2 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 8 + lora_alpha: 16 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "k_proj" + - "v_proj" + - "o_proj" + - "gate_proj" + - "up_proj" + - "down_proj" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/ernie/unsloth_ERNIE-4.5-VL-28B-A3B-PT.yaml b/studio/backend/assets/configs/model_defaults/ernie/unsloth_ERNIE-4.5-VL-28B-A3B-PT.yaml new file mode 100644 index 0000000000..524a723dc2 --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/ernie/unsloth_ERNIE-4.5-VL-28B-A3B-PT.yaml @@ -0,0 +1,55 @@ +# Model defaults for unsloth/ERNIE-4.5-VL-28B-A3B-PT +# Based on ERNIE_4_5_VL_28B_A3B_PT_Vision.ipynb +# Also applies to: unsloth/ERNIE-4.5-VL-28B-A3B-PT +# added inference parameters from unsloth notebook + +training: + trust_remote_code: true + max_seq_length: 2048 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 2 + gradient_accumulation_steps: 2 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 8 + lora_alpha: 16 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "k_proj" + - "v_proj" + - "o_proj" + - "gate_proj" + - "up_proj" + - "down_proj" + use_rslora: false + use_loftq: false + finetune_vision_layers: true + finetune_language_layers: true + finetune_attention_modules: true + finetune_mlp_modules: true + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: true + temperature: 1.5 + min_p: 0.1 + diff --git a/studio/backend/assets/configs/model_defaults/falcon/tiiuae_Falcon-H1-0.5B-Instruct.yaml b/studio/backend/assets/configs/model_defaults/falcon/tiiuae_Falcon-H1-0.5B-Instruct.yaml new file mode 100644 index 0000000000..c45b71b4ae --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/falcon/tiiuae_Falcon-H1-0.5B-Instruct.yaml @@ -0,0 +1,47 @@ +# Model defaults for tiiuae/Falcon-H1-0.5B-Instruct +# Based on Falcon_H1_(0.5B)-Alpaca.ipynb +# Also applies to: tiiuae/Falcon-H1-0.5B-Instruct, unsloth/Falcon-H1-0.5B-Instruct + +training: + trust_remote_code: false + max_seq_length: 2048 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 2 + gradient_accumulation_steps: 8 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.01 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: false + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 16 + lora_alpha: 16 + lora_dropout: 0.1 + target_modules: + - "q_proj" + - "k_proj" + - "v_proj" + - "o_proj" + - "gate_proj" + - "up_proj" + - "down_proj" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_codegemma-7b-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_codegemma-7b-bnb-4bit.yaml new file mode 100644 index 0000000000..62836dc0cd --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_codegemma-7b-bnb-4bit.yaml @@ -0,0 +1,50 @@ +# Model defaults for unsloth/codegemma-7b-bnb-4bit +# Based on CodeGemma_(7B)-Conversational.ipynb +# Also applies to: unsloth/codegemma-7b, google/codegemma-7b +# added inference parameters from Ollama + +training: + trust_remote_code: false + max_seq_length: 4096 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 1 + gradient_accumulation_steps: 4 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 16 + lora_alpha: 16 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "k_proj" + - "v_proj" + - "o_proj" + - "gate_proj" + - "up_proj" + - "down_proj" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false + temperature: 0 + top_p: 0.9 diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_functiongemma-270m-it.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_functiongemma-270m-it.yaml new file mode 100644 index 0000000000..f97a842d2a --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_functiongemma-270m-it.yaml @@ -0,0 +1,53 @@ +# Model defaults for unsloth/functiongemma-270m-it +# Based on FunctionGemma_(270M).ipynb +# Also applies to: unsloth/functiongemma-270m-it-unsloth-bnb-4bit, google/functiongemma-270m-it, unsloth/functiongemma-270m-it-unsloth-bnb-4bit +# added inference parameters from unsloth guides + +training: + trust_remote_code: false + max_seq_length: 4096 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 4 + gradient_accumulation_steps: 2 + warmup_steps: 10 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 128 + lora_alpha: 256 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "k_proj" + - "v_proj" + - "o_proj" + - "gate_proj" + - "up_proj" + - "down_proj" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false + temperature: 1.0 + top_k: 64 + top_p: 0.95 + min_p: 0.0 + diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-2-27b-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-2-27b-bnb-4bit.yaml new file mode 100644 index 0000000000..56f10cdc4f --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-2-27b-bnb-4bit.yaml @@ -0,0 +1,46 @@ +# Model defaults for unsloth/gemma-2-27b-bnb-4bit +# Based on Gemma2_(9B)-Alpaca.ipynb (same defaults for larger models) + +training: + trust_remote_code: false + max_seq_length: 2048 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 2 + gradient_accumulation_steps: 4 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 16 + lora_alpha: 16 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "k_proj" + - "v_proj" + - "o_proj" + - "gate_proj" + - "up_proj" + - "down_proj" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-2-2b.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-2-2b.yaml new file mode 100644 index 0000000000..f8f78f5edc --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-2-2b.yaml @@ -0,0 +1,47 @@ +# Model defaults for unsloth/gemma-2-2b +# Based on Gemma2_(2B)-Alpaca.ipynb +# Also applies to: unsloth/gemma-2-2b-bnb-4bit, google/gemma-2-2b + +training: + trust_remote_code: false + max_seq_length: 2048 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 2 + gradient_accumulation_steps: 4 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.01 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 16 + lora_alpha: 16 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "k_proj" + - "v_proj" + - "o_proj" + - "gate_proj" + - "up_proj" + - "down_proj" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-270m-it.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-270m-it.yaml new file mode 100644 index 0000000000..455407abf8 --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-270m-it.yaml @@ -0,0 +1,53 @@ +# Model defaults for unsloth/gemma-3-270m-it +# Based on Gemma3_(270M).ipynb +# Also applies to: unsloth/gemma-3-270m-it-unsloth-bnb-4bit, google/gemma-3-270m-it, unsloth/gemma-3-270m-it-bnb-4bit +# added inference parameters from unsloth guides + +training: + trust_remote_code: false + max_seq_length: 2048 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 5e-5 + batch_size: 4 + gradient_accumulation_steps: 1 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 128 + lora_alpha: 128 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "k_proj" + - "v_proj" + - "o_proj" + - "gate_proj" + - "up_proj" + - "down_proj" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false + temperature: 1.0 + top_k: 64 + top_p: 0.95 + min_p: 0.0 + diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-27b-it.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-27b-it.yaml new file mode 100644 index 0000000000..2bcdf67c15 --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-27b-it.yaml @@ -0,0 +1,51 @@ +# Model defaults for unsloth/gemma-3-27b-it +# Based on Gemma3_(27B)_A100-Conversational.ipynb +# Also applies to: unsloth/gemma-3-27b-it-unsloth-bnb-4bit, google/gemma-3-27b-it, unsloth/gemma-3-27b-it-bnb-4bit +# added inference parameters from unsloth guides + +training: + trust_remote_code: false + max_seq_length: 2048 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 2 + gradient_accumulation_steps: 4 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 8 + lora_alpha: 8 + lora_dropout: 0.0 + target_modules: + - "all-linear" + use_rslora: false + use_loftq: false + finetune_vision_layers: true + finetune_language_layers: true + finetune_attention_modules: true + finetune_mlp_modules: true + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false + temperature: 1.0 + top_k: 64 + top_p: 0.95 + min_p: 0.0 + diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-4b-it.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-4b-it.yaml new file mode 100644 index 0000000000..7c123da0b8 --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-4b-it.yaml @@ -0,0 +1,51 @@ +# Model defaults for unsloth/gemma-3-4b-it +# Based on Gemma3_(4B).ipynb +# Also applies to: unsloth/gemma-3-4b-it-unsloth-bnb-4bit, google/gemma-3-4b-it, unsloth/gemma-3-4b-it-bnb-4bit +# added inference parameters from unsloth guides + +training: + trust_remote_code: false + max_seq_length: 2048 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 2 + gradient_accumulation_steps: 4 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 8 + lora_alpha: 8 + lora_dropout: 0.0 + target_modules: + - "all-linear" + use_rslora: false + use_loftq: false + finetune_vision_layers: true + finetune_language_layers: true + finetune_attention_modules: true + finetune_mlp_modules: true + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false + temperature: 1.0 + top_k: 64 + top_p: 0.95 + min_p: 0.0 + diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-4b-pt.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-4b-pt.yaml new file mode 100644 index 0000000000..492c42812e --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3-4b-pt.yaml @@ -0,0 +1,51 @@ +# Model defaults for unsloth/gemma-3-4b-pt +# Based on Gemma3_(4B)-Vision.ipynb +# Also applies to: unsloth/gemma-3-4b-pt-unsloth-bnb-4bit, google/gemma-3-4b-pt, unsloth/gemma-3-4b-pt-bnb-4bit +# added inference parameters from unsloth guides + +training: + trust_remote_code: false + max_seq_length: 2048 + # num_epochs: 2 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 1 + gradient_accumulation_steps: 4 + warmup_ratio: 0.03 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: true + optim: "adamw_torch_fused" + lr_scheduler_type: "cosine" + +lora: + lora_r: 16 + lora_alpha: 16 + lora_dropout: 0.0 + target_modules: + - "all-linear" + use_rslora: false + use_loftq: false + finetune_vision_layers: true + finetune_language_layers: true + finetune_attention_modules: true + finetune_mlp_modules: true + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false + temperature: 1.0 + top_k: 64 + top_p: 0.95 + min_p: 0.0 + diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3n-E4B-it.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3n-E4B-it.yaml new file mode 100644 index 0000000000..23d00df752 --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3n-E4B-it.yaml @@ -0,0 +1,53 @@ +# Model defaults for unsloth/gemma-3n-E4B-it +# Based on Gemma3N_(4B)-Conversational.ipynb +# Also applies to: unsloth/gemma-3n-E4B-it-unsloth-bnb-4bit, google/gemma-3n-E4B-it, unsloth/gemma-3n-E4B-it-unsloth-bnb-4bit +# added inference parameters from unsloth guides + +training: + trust_remote_code: false + max_seq_length: 1024 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 1 + gradient_accumulation_steps: 4 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 8 + lora_alpha: 8 + lora_dropout: 0.0 + target_modules: + - "all-linear" + use_rslora: false + use_loftq: false + finetune_vision_layers: true + finetune_language_layers: true + finetune_attention_modules: true + finetune_mlp_modules: true + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +audio_input: true + +inference: + trust_remote_code: false + temperature: 1.0 + top_k: 64 + top_p: 0.95 + min_p: 0.0 + diff --git a/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3n-E4B.yaml b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3n-E4B.yaml new file mode 100644 index 0000000000..bf5e111b7d --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/gemma/unsloth_gemma-3n-E4B.yaml @@ -0,0 +1,53 @@ +# Model defaults for unsloth/gemma-3n-E4B +# Based on Gemma3N_(4B)-Vision.ipynb +# Also applies to: unsloth/gemma-3n-E4B-unsloth-bnb-4bit, google/gemma-3n-E4B +# added inference parameters from unsloth guides + +training: + trust_remote_code: false + max_seq_length: 2048 + # num_epochs: 2 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 1 + gradient_accumulation_steps: 4 + warmup_ratio: 0.03 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: true + optim: "adamw_torch_fused" + lr_scheduler_type: "cosine" + +lora: + lora_r: 32 + lora_alpha: 32 + lora_dropout: 0.0 + target_modules: + - "all-linear" + use_rslora: false + use_loftq: false + finetune_vision_layers: true + finetune_language_layers: true + finetune_attention_modules: true + finetune_mlp_modules: true + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +audio_input: true + +inference: + trust_remote_code: false + temperature: 1.0 + top_k: 64 + top_p: 0.95 + min_p: 0.0 + diff --git a/studio/backend/assets/configs/model_defaults/gpt-oss/unsloth_gpt-oss-120b.yaml b/studio/backend/assets/configs/model_defaults/gpt-oss/unsloth_gpt-oss-120b.yaml new file mode 100644 index 0000000000..bd39e70a96 --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/gpt-oss/unsloth_gpt-oss-120b.yaml @@ -0,0 +1,52 @@ +# Model defaults for unsloth/gpt-oss-120b +# Based on gpt-oss-(120B)_A100-Fine-tuning.ipynb +# Also applies to: openai/gpt-oss-120b, unsloth/gpt-oss-120b-unsloth-bnb-4bit +# added inference parameters from unsloth guides + +training: + trust_remote_code: false + max_seq_length: 4096 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 4 + gradient_accumulation_steps: 1 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 16 + lora_alpha: 32 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "k_proj" + - "v_proj" + - "o_proj" + - "gate_proj" + - "up_proj" + - "down_proj" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false + temperature: 1.0 + top_p: 1.0 + top_k: 0 + diff --git a/studio/backend/assets/configs/model_defaults/gpt-oss/unsloth_gpt-oss-20b.yaml b/studio/backend/assets/configs/model_defaults/gpt-oss/unsloth_gpt-oss-20b.yaml new file mode 100644 index 0000000000..839e9a5b75 --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/gpt-oss/unsloth_gpt-oss-20b.yaml @@ -0,0 +1,52 @@ +# Model defaults for unsloth/gpt-oss-20b +# Based on gpt-oss-(20B)-Fine-tuning.ipynb +# Also applies to: openai/gpt-oss-20b, unsloth/gpt-oss-20b-unsloth-bnb-4bit, unsloth/gpt-oss-20b-BF16 +# added inference parameters from unsloth guides + +training: + trust_remote_code: false + max_seq_length: 1024 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 1 + gradient_accumulation_steps: 4 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 8 + lora_alpha: 16 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "k_proj" + - "v_proj" + - "o_proj" + - "gate_proj" + - "up_proj" + - "down_proj" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false + temperature: 1.0 + top_p: 1.0 + top_k: 0 + diff --git a/studio/backend/assets/configs/model_defaults/granite/unsloth_granite-4.0-350m-unsloth-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/granite/unsloth_granite-4.0-350m-unsloth-bnb-4bit.yaml new file mode 100644 index 0000000000..9557fc296f --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/granite/unsloth_granite-4.0-350m-unsloth-bnb-4bit.yaml @@ -0,0 +1,54 @@ +# Model defaults for unsloth/granite-4.0-350m +# Based on Granite4.0_350M.ipynb +# Also applies to: ibm-granite/granite-4.0-350m, unsloth/granite-4.0-350m-bnb-4bit +# added inference parameters from unsloth guides + +training: + trust_remote_code: false + max_seq_length: 2048 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 2 + gradient_accumulation_steps: 4 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 32 + lora_alpha: 32 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "k_proj" + - "v_proj" + - "o_proj" + - "gate_proj" + - "up_proj" + - "down_proj" + - "shared_mlp.input_linear" + - "shared_mlp.output_linear" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false + temperature: 0.0 + top_p: 1.0 + top_k: 0 + diff --git a/studio/backend/assets/configs/model_defaults/granite/unsloth_granite-4.0-h-micro.yaml b/studio/backend/assets/configs/model_defaults/granite/unsloth_granite-4.0-h-micro.yaml new file mode 100644 index 0000000000..ce73c6a8ee --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/granite/unsloth_granite-4.0-h-micro.yaml @@ -0,0 +1,54 @@ +# Model defaults for unsloth/granite-4.0-h-micro +# Based on Granite4.0.ipynb +# Also applies to: ibm-granite/granite-4.0-h-micro, unsloth/granite-4.0-h-micro-bnb-4bit, unsloth/granite-4.0-h-micro-unsloth-bnb-4bit +# added inference parameters from unsloth guides + +training: + trust_remote_code: false + max_seq_length: 2048 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 2 + gradient_accumulation_steps: 4 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 32 + lora_alpha: 32 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "k_proj" + - "v_proj" + - "o_proj" + - "gate_proj" + - "up_proj" + - "down_proj" + - "shared_mlp.input_linear" + - "shared_mlp.output_linear" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false + temperature: 0.0 + top_p: 1.0 + top_k: 0 + diff --git a/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.2-11B-Vision-Instruct.yaml b/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.2-11B-Vision-Instruct.yaml new file mode 100644 index 0000000000..d9a75c391d --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.2-11B-Vision-Instruct.yaml @@ -0,0 +1,49 @@ +# Model defaults for unsloth/Llama-3.2-11B-Vision-Instruct +# Based on Llama3.2_(11B)-Vision.ipynb +# Also applies to: unsloth/Llama-3.2-11B-Vision-Instruct-unsloth-bnb-4bit, meta-llama/Llama-3.2-11B-Vision-Instruct, unsloth/Llama-3.2-11B-Vision-Instruct-bnb-4bit +# added inference parameters from unsloth notebook + +training: + trust_remote_code: false + max_seq_length: 2048 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 2 + gradient_accumulation_steps: 4 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 16 + lora_alpha: 16 + lora_dropout: 0.0 + target_modules: + - "all-linear" + use_rslora: false + use_loftq: false + finetune_vision_layers: true + finetune_language_layers: true + finetune_attention_modules: true + finetune_mlp_modules: true + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false + temperature: 1.5 + min_p: 0.1 + diff --git a/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.2-1B-Instruct.yaml b/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.2-1B-Instruct.yaml new file mode 100644 index 0000000000..3938f10627 --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.2-1B-Instruct.yaml @@ -0,0 +1,47 @@ +# Model defaults for unsloth/Llama-3.2-1B-Instruct +# Based on Llama3.2_(1B)-RAFT.ipynb +# Also applies to: unsloth/Llama-3.2-1B-Instruct-unsloth-bnb-4bit, meta-llama/Llama-3.2-1B-Instruct, unsloth/Llama-3.2-1B-Instruct-bnb-4bit, RedHatAI/Llama-3.2-1B-Instruct-FP8, unsloth/Llama-3.2-1B-Instruct-FP8-Block, unsloth/Llama-3.2-1B-Instruct-FP8-Dynamic + +training: + trust_remote_code: false + max_seq_length: 2048 + # num_epochs: 5 + num_epochs: 0 + learning_rate: 2e-5 + batch_size: 1 + gradient_accumulation_steps: 8 + warmup_steps: 0 + max_steps: 30 + save_steps: 30 + weight_decay: 0.01 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: true + optim: "adamw_torch" + lr_scheduler_type: "cosine" + +lora: + lora_r: 16 + lora_alpha: 16 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "k_proj" + - "v_proj" + - "o_proj" + - "gate_proj" + - "up_proj" + - "down_proj" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.2-3B-Instruct.yaml b/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.2-3B-Instruct.yaml new file mode 100644 index 0000000000..82091c7d35 --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.2-3B-Instruct.yaml @@ -0,0 +1,51 @@ +# Model defaults for unsloth/Llama-3.2-3B-Instruct +# Based on Llama3.2_(1B_and_3B)-Conversational.ipynb +# Also applies to: unsloth/Llama-3.2-3B-Instruct-unsloth-bnb-4bit, meta-llama/Llama-3.2-3B-Instruct, unsloth/Llama-3.2-3B-Instruct-bnb-4bit, RedHatAI/Llama-3.2-3B-Instruct-FP8, unsloth/Llama-3.2-3B-Instruct-FP8-Block, unsloth/Llama-3.2-3B-Instruct-FP8-Dynamic +# added inference parameters from unsloth notebook + +training: + trust_remote_code: false + max_seq_length: 2048 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 2 + gradient_accumulation_steps: 4 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 16 + lora_alpha: 16 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "k_proj" + - "v_proj" + - "o_proj" + - "gate_proj" + - "up_proj" + - "down_proj" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false + temperature: 1.5 + min_p: 0.1 + diff --git a/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.3-70B-Instruct.yaml b/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.3-70B-Instruct.yaml new file mode 100644 index 0000000000..5a014a63bf --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/llama/unsloth_Llama-3.3-70B-Instruct.yaml @@ -0,0 +1,51 @@ +# Model defaults for unsloth/Llama-3.3-70B-Instruct +# Based on Llama3.3_(70B)_A100-Conversational.ipynb +# Also applies to: unsloth/Llama-3.3-70B-Instruct-unsloth-bnb-4bit, meta-llama/Llama-3.3-70B-Instruct, unsloth/Llama-3.3-70B-Instruct-bnb-4bit, RedHatAI/Llama-3.3-70B-Instruct-FP8, unsloth/Llama-3.3-70B-Instruct-FP8-Block, unsloth/Llama-3.3-70B-Instruct-FP8-Dynamic +# added inference parameters from unsloth notebook + +training: + trust_remote_code: false + max_seq_length: 2048 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 2 + gradient_accumulation_steps: 4 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 16 + lora_alpha: 16 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "k_proj" + - "v_proj" + - "o_proj" + - "gate_proj" + - "up_proj" + - "down_proj" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false + temperature: 1.5 + min_p: 0.1 + diff --git a/studio/backend/assets/configs/model_defaults/llama/unsloth_Meta-Llama-3.1-70B-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/llama/unsloth_Meta-Llama-3.1-70B-bnb-4bit.yaml new file mode 100644 index 0000000000..885f7b47fd --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/llama/unsloth_Meta-Llama-3.1-70B-bnb-4bit.yaml @@ -0,0 +1,47 @@ +# Model defaults for unsloth/Meta-Llama-3.1-70B-bnb-4bit +# Based on Llama3.1_(8B)-Alpaca.ipynb +# Also applies to: unsloth/Meta-Llama-3.1-8B-bnb-4bit, unsloth/Meta-Llama-3.1-8B-unsloth-bnb-4bit, meta-llama/Meta-Llama-3.1-8B, unsloth/Meta-Llama-3.1-8B, unsloth/Meta-Llama-3.1-70B, meta-llama/Meta-Llama-3.1-70B, unsloth/Meta-Llama-3.1-405B-bnb-4bit, meta-llama/Meta-Llama-3.1-405B + +training: + trust_remote_code: false + max_seq_length: 2048 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 2 + gradient_accumulation_steps: 4 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 16 + lora_alpha: 16 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "k_proj" + - "v_proj" + - "o_proj" + - "gate_proj" + - "up_proj" + - "down_proj" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/llama/unsloth_Meta-Llama-3.1-8B-Instruct-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/llama/unsloth_Meta-Llama-3.1-8B-Instruct-bnb-4bit.yaml new file mode 100644 index 0000000000..1ff06cca6f --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/llama/unsloth_Meta-Llama-3.1-8B-Instruct-bnb-4bit.yaml @@ -0,0 +1,47 @@ +# Model defaults for unsloth/Meta-Llama-3.1-8B-Instruct-bnb-4bit +# Based on Llama3.1_(8B)-Inference.ipynb +# Also applies to: "unsloth/Meta-Llama-3.1-8B-Instruct-unsloth-bnb-4bit", "meta-llama/Meta-Llama-3.1-8B-Instruct", "unsloth/Meta-Llama-3.1-8B-Instruct","RedHatAI/Llama-3.1-8B-Instruct-FP8","unsloth/Llama-3.1-8B-Instruct-FP8-Block","unsloth/Llama-3.1-8B-Instruct-FP8-Dynamic" + +training: + trust_remote_code: false + max_seq_length: 8192 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 2 + gradient_accumulation_steps: 4 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 16 + lora_alpha: 16 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "k_proj" + - "v_proj" + - "o_proj" + - "gate_proj" + - "up_proj" + - "down_proj" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/llama/unsloth_llama-3-8b-Instruct-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/llama/unsloth_llama-3-8b-Instruct-bnb-4bit.yaml new file mode 100644 index 0000000000..95ee5ead5c --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/llama/unsloth_llama-3-8b-Instruct-bnb-4bit.yaml @@ -0,0 +1,47 @@ +# Model defaults for unsloth/llama-3-8b-Instruct-bnb-4bit +# Based on Llama3_(8B)-Conversational.ipynb +# Also applies to: unsloth/llama-3-8b-Instruct, meta-llama/Meta-Llama-3-8B-Instruct + +training: + trust_remote_code: false + max_seq_length: 2048 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 2 + gradient_accumulation_steps: 4 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 16 + lora_alpha: 16 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "k_proj" + - "v_proj" + - "o_proj" + - "gate_proj" + - "up_proj" + - "down_proj" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/llama/unsloth_llama-3-8b-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/llama/unsloth_llama-3-8b-bnb-4bit.yaml new file mode 100644 index 0000000000..a05ac86f43 --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/llama/unsloth_llama-3-8b-bnb-4bit.yaml @@ -0,0 +1,47 @@ +# Model defaults for unsloth/llama-3-8b-bnb-4bit +# Based on Llama3_(8B)-Alpaca.ipynb +# Also applies to: unsloth/llama-3-8b, meta-llama/Meta-Llama-3-8B + +training: + trust_remote_code: false + max_seq_length: 2048 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 2 + gradient_accumulation_steps: 4 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 16 + lora_alpha: 16 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "k_proj" + - "v_proj" + - "o_proj" + - "gate_proj" + - "up_proj" + - "down_proj" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/llasa/unsloth_Llasa-3B.yaml b/studio/backend/assets/configs/model_defaults/llasa/unsloth_Llasa-3B.yaml new file mode 100644 index 0000000000..1f473c3af1 --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/llasa/unsloth_Llasa-3B.yaml @@ -0,0 +1,46 @@ +# Model defaults for unsloth/Llasa-3B +# Based on Llasa_TTS_(3B).ipynb and Llasa_TTS_(1B).ipynb +# Also applies to: HKUSTAudio/Llasa-1B +# added inference parameters from unsloth notebook + +training: + trust_remote_code: false + max_seq_length: 2048 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 5e-4 + batch_size: 2 + gradient_accumulation_steps: 4 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 128 + lora_alpha: 128 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "v_proj" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false + temperature: 1.2 + top_p: 1.2 + diff --git a/studio/backend/assets/configs/model_defaults/mistral/unsloth_Magistral-Small-2509-unsloth-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/mistral/unsloth_Magistral-Small-2509-unsloth-bnb-4bit.yaml new file mode 100644 index 0000000000..5a53bb52eb --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/mistral/unsloth_Magistral-Small-2509-unsloth-bnb-4bit.yaml @@ -0,0 +1,56 @@ +# Model defaults for unsloth/Magistral-Small-2509 +# Based on Magistral_(24B)-Reasoning-Conversational.ipynb +# Also applies to: mistralai/Magistral-Small-2509, unsloth/Magistral-Small-2509-bnb-4bit +# added inference parameters from unsloth guides + +training: + trust_remote_code: false + max_seq_length: 2048 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 2 + gradient_accumulation_steps: 2 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 32 + lora_alpha: 32 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "k_proj" + - "v_proj" + - "o_proj" + - "gate_proj" + - "up_proj" + - "down_proj" + use_rslora: false + use_loftq: false + finetune_vision_layers: true + finetune_language_layers: true + finetune_attention_modules: true + finetune_mlp_modules: true + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false + temperature: 0.7 + min_p: 0.01 + top_p: 0.95 + diff --git a/studio/backend/assets/configs/model_defaults/mistral/unsloth_Ministral-3-3B-Instruct-2512.yaml b/studio/backend/assets/configs/model_defaults/mistral/unsloth_Ministral-3-3B-Instruct-2512.yaml new file mode 100644 index 0000000000..c9f771fd23 --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/mistral/unsloth_Ministral-3-3B-Instruct-2512.yaml @@ -0,0 +1,55 @@ +# Model defaults for unsloth/Ministral-3-3B-Instruct-2512 +# Based on Ministral_3_VL_(3B)_Vision.ipynb +# Also applies to: unsloth/Ministral-3-3B-Instruct-2512 +# added inference parameters from unsloth guides + +training: + trust_remote_code: false + max_seq_length: 2048 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 4 + gradient_accumulation_steps: 2 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 32 + lora_alpha: 32 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "k_proj" + - "v_proj" + - "o_proj" + - "gate_proj" + - "up_proj" + - "down_proj" + use_rslora: false + use_loftq: false + finetune_vision_layers: true + finetune_language_layers: true + finetune_attention_modules: true + finetune_mlp_modules: true + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false + temperature: 0.15 + top_p: default + diff --git a/studio/backend/assets/configs/model_defaults/mistral/unsloth_Mistral-Nemo-Base-2407-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/mistral/unsloth_Mistral-Nemo-Base-2407-bnb-4bit.yaml new file mode 100644 index 0000000000..abdac62c0c --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/mistral/unsloth_Mistral-Nemo-Base-2407-bnb-4bit.yaml @@ -0,0 +1,47 @@ +# Model defaults for unsloth/Mistral-Nemo-Base-2407-bnb-4bit +# Based on Mistral_Nemo_(12B)-Alpaca.ipynb +# Also applies to: "unsloth/Mistral-Nemo-Base-2407", "mistralai/Mistral-Nemo-Base-2407", "unsloth/Mistral-Nemo-Instruct-2407-bnb-4bit", "unsloth/Mistral-Nemo-Instruct-2407", "mistralai/Mistral-Nemo-Instruct-2407", + +training: + trust_remote_code: false + max_seq_length: 2048 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 2 + gradient_accumulation_steps: 4 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 16 + lora_alpha: 16 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "k_proj" + - "v_proj" + - "o_proj" + - "gate_proj" + - "up_proj" + - "down_proj" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/mistral/unsloth_Mistral-Small-Instruct-2409.yaml b/studio/backend/assets/configs/model_defaults/mistral/unsloth_Mistral-Small-Instruct-2409.yaml new file mode 100644 index 0000000000..149f2a24f1 --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/mistral/unsloth_Mistral-Small-Instruct-2409.yaml @@ -0,0 +1,47 @@ +# Model defaults for unsloth/Mistral-Small-Instruct-2409 +# Based on Mistral_Small_(22B)-Alpaca.ipynb +# Also applies to: unsloth/Mistral-Small-Instruct-2409-bnb-4bit, mistralai/Mistral-Small-Instruct-2409 + +training: + trust_remote_code: false + max_seq_length: 2048 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 1 + gradient_accumulation_steps: 4 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 16 + lora_alpha: 16 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "k_proj" + - "v_proj" + - "o_proj" + - "gate_proj" + - "up_proj" + - "down_proj" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/mistral/unsloth_Pixtral-12B-2409.yaml b/studio/backend/assets/configs/model_defaults/mistral/unsloth_Pixtral-12B-2409.yaml new file mode 100644 index 0000000000..3976cd0aa0 --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/mistral/unsloth_Pixtral-12B-2409.yaml @@ -0,0 +1,49 @@ +# Model defaults for unsloth/Pixtral-12B-2409 +# Based on Pixtral_(12B)-Vision.ipynb +# Also applies to: unsloth/Pixtral-12B-2409-unsloth-bnb-4bit, mistralai/Pixtral-12B-2409, unsloth/Pixtral-12B-2409-bnb-4bit +# added inference parameters from unsloth notebook + +training: + trust_remote_code: false + max_seq_length: 2048 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 1 + gradient_accumulation_steps: 4 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "paged_adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 8 + lora_alpha: 8 + lora_dropout: 0.0 + target_modules: + - "all-linear" + use_rslora: false + use_loftq: false + finetune_vision_layers: true + finetune_language_layers: true + finetune_attention_modules: false + finetune_mlp_modules: true + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false + temperature: 1.5 + min_p: 0.1 + diff --git a/studio/backend/assets/configs/model_defaults/mistral/unsloth_mistral-7b-instruct-v0.3-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/mistral/unsloth_mistral-7b-instruct-v0.3-bnb-4bit.yaml new file mode 100644 index 0000000000..55d5dd289b --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/mistral/unsloth_mistral-7b-instruct-v0.3-bnb-4bit.yaml @@ -0,0 +1,47 @@ +# Model defaults for unsloth/mistral-7b-instruct-v0.3-bnb-4bit +# Based on Mistral_v0.3_(7B)-Conversational.ipynb +# Also applies to: unsloth/mistral-7b-instruct-v0.3, mistralai/Mistral-7B-Instruct-v0.3 + +training: + trust_remote_code: false + max_seq_length: 2048 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 2 + gradient_accumulation_steps: 4 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 16 + lora_alpha: 16 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "k_proj" + - "v_proj" + - "o_proj" + - "gate_proj" + - "up_proj" + - "down_proj" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/mistral/unsloth_mistral-7b-v0.3-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/mistral/unsloth_mistral-7b-v0.3-bnb-4bit.yaml new file mode 100644 index 0000000000..5b24f5b581 --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/mistral/unsloth_mistral-7b-v0.3-bnb-4bit.yaml @@ -0,0 +1,46 @@ +# Model defaults for unsloth/mistral-7b-v0.3-bnb-4bit +# Based on Mistral_v0.3_(7B)-Alpaca.ipynb +# Also applies to: "unsloth/mistral-7b-v0.3", "mistralai/Mistral-7B-v0.3", +training: + trust_remote_code: false + max_seq_length: 2048 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 2 + gradient_accumulation_steps: 4 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 16 + lora_alpha: 16 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "k_proj" + - "v_proj" + - "o_proj" + - "gate_proj" + - "up_proj" + - "down_proj" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/other/OuteAI_Llama-OuteTTS-1.0-1B.yaml b/studio/backend/assets/configs/model_defaults/other/OuteAI_Llama-OuteTTS-1.0-1B.yaml new file mode 100644 index 0000000000..87b94ce67c --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/other/OuteAI_Llama-OuteTTS-1.0-1B.yaml @@ -0,0 +1,51 @@ +# Model defaults for OuteAI/Llama-OuteTTS-1.0-1B +# Based on Oute_TTS_(1B).ipynb +# Also applies to: OuteAI/Llama-OuteTTS-1.0-1B +# added inference parameters from unsloth notebook + +audio_type: dac + +training: + trust_remote_code: false + eval_steps: 0 + max_seq_length: 2048 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 2 + gradient_accumulation_steps: 4 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 128 + lora_alpha: 128 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "v_proj" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false + temperature: 0.4 + top_k: 40 + top_p: 0.9 + min_p: 0.05 + diff --git a/studio/backend/assets/configs/model_defaults/other/Spark-TTS-0.5B_LLM.yaml b/studio/backend/assets/configs/model_defaults/other/Spark-TTS-0.5B_LLM.yaml new file mode 100644 index 0000000000..03748cd5fd --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/other/Spark-TTS-0.5B_LLM.yaml @@ -0,0 +1,55 @@ +# Model defaults for Spark-TTS-0.5B/LLM +# Based on Spark_TTS_(0_5B).ipynb +# Also applies to: Spark-TTS-0.5B/LLM +# added inference parameters from unsloth notebook + +audio_type: bicodec + +training: + trust_remote_code: false + eval_steps: 0 + max_seq_length: 2048 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 2 + gradient_accumulation_steps: 4 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 128 + lora_alpha: 128 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "k_proj" + - "v_proj" + - "o_proj" + - "gate_proj" + - "up_proj" + - "down_proj" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false + temperature: 0.8 + top_k: 50 + top_p: 1.0 + diff --git a/studio/backend/assets/configs/model_defaults/other/sesame_csm-1b.yaml b/studio/backend/assets/configs/model_defaults/other/sesame_csm-1b.yaml new file mode 100644 index 0000000000..5c1e180f8c --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/other/sesame_csm-1b.yaml @@ -0,0 +1,50 @@ +# Model defaults for sesame/csm-1b +# Based on Sesame_CSM_(1B)-TTS.ipynb +# Also applies to: sesame/csm-1b + +audio_type: csm + +training: + trust_remote_code: false + eval_steps: 0 + max_seq_length: 2048 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 2 + gradient_accumulation_steps: 4 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 32 + lora_alpha: 32 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "k_proj" + - "v_proj" + - "o_proj" + - "gate_proj" + - "up_proj" + - "down_proj" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/other/unsloth_GLM-4.7-Flash.yaml b/studio/backend/assets/configs/model_defaults/other/unsloth_GLM-4.7-Flash.yaml new file mode 100644 index 0000000000..6d8be3656f --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/other/unsloth_GLM-4.7-Flash.yaml @@ -0,0 +1,52 @@ +# Model defaults for unsloth/GLM-4.7-Flash +# Based on GLM_Flash_A100(80GB).py +# Also applies to: unsloth/GLM-4.7-Flash-unsloth-bnb-4bit, unsloth/GLM-4.7-Flash-bnb-4bit, THUDM/GLM-4.7-Flash + +training: + trust_remote_code: true + max_seq_length: 2048 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 4 + gradient_accumulation_steps: 2 + warmup_steps: 5 + max_steps: 60 + save_steps: 60 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 8 + lora_alpha: 16 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "k_proj" + - "v_proj" + - "o_proj" + - "gate_proj" + - "up_proj" + - "down_proj" + - "out_proj" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: true + temperature: 0.7 + top_p: 0.8 + top_k: 20 + diff --git a/studio/backend/assets/configs/model_defaults/other/unsloth_LFM2-1.2B.yaml b/studio/backend/assets/configs/model_defaults/other/unsloth_LFM2-1.2B.yaml new file mode 100644 index 0000000000..39a2fe0a5b --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/other/unsloth_LFM2-1.2B.yaml @@ -0,0 +1,45 @@ +# Model defaults for unsloth/LFM2-1.2B +# Based on Liquid_LFM2_(1.2B)-Conversational.ipynb +# Also applies to: unsloth/LFM2-1.2B +# added inference parameters from unsloth notebook + +training: + trust_remote_code: false + max_seq_length: 2048 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 2 + gradient_accumulation_steps: 4 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 16 + lora_alpha: 16 + lora_dropout: 0.0 + target_modules: + - "all-linear" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false + temperature: 0.3 + min_p: 0.15 + diff --git a/studio/backend/assets/configs/model_defaults/other/unsloth_Nemotron-3-Nano-30B-A3B.yaml b/studio/backend/assets/configs/model_defaults/other/unsloth_Nemotron-3-Nano-30B-A3B.yaml new file mode 100644 index 0000000000..663ce87d5f --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/other/unsloth_Nemotron-3-Nano-30B-A3B.yaml @@ -0,0 +1,53 @@ +# Model defaults for unsloth/Nemotron-3-Nano-30B-A3B +# Based on Nemotron-3-Nano-30B-A3B_A100.ipynb +# Also applies to: unsloth/Nemotron-3-Nano-30B-A3B +# added inference parameters from unsloth guides + +training: + trust_remote_code: true + max_seq_length: 2048 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 4 + gradient_accumulation_steps: 2 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 8 + lora_alpha: 16 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "k_proj" + - "v_proj" + - "o_proj" + - "gate_proj" + - "up_proj" + - "down_proj" + - "in_proj" + - "out_proj" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: true + temperature: 1.0 + top_p: 1.0 + diff --git a/studio/backend/assets/configs/model_defaults/other/unsloth_PaddleOCR-VL.yaml b/studio/backend/assets/configs/model_defaults/other/unsloth_PaddleOCR-VL.yaml new file mode 100644 index 0000000000..b7587bbd91 --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/other/unsloth_PaddleOCR-VL.yaml @@ -0,0 +1,55 @@ +# Model defaults for unsloth/PaddleOCR-VL +# Based on Paddle_OCR_(1B)_Vision.ipynb +# Also applies to: unsloth/PaddleOCR-VL +# added inference parameters from unsloth notebook + +training: + trust_remote_code: true + max_seq_length: 2048 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 5e-5 + batch_size: 4 + gradient_accumulation_steps: 2 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 64 + lora_alpha: 64 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "k_proj" + - "v_proj" + - "o_proj" + - "gate_proj" + - "up_proj" + - "down_proj" + use_rslora: false + use_loftq: false + finetune_vision_layers: true + finetune_language_layers: true + finetune_attention_modules: true + finetune_mlp_modules: true + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: true + temperature: 1.5 + min_p: 0.1 + diff --git a/studio/backend/assets/configs/model_defaults/other/unsloth_answerdotai_ModernBERT-large.yaml b/studio/backend/assets/configs/model_defaults/other/unsloth_answerdotai_ModernBERT-large.yaml new file mode 100644 index 0000000000..cc5d130bfa --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/other/unsloth_answerdotai_ModernBERT-large.yaml @@ -0,0 +1,46 @@ +# Model defaults for answerdotai/ModernBERT-large +# Based on bert_classification.ipynb + +training: + trust_remote_code: false + max_seq_length: 2048 + # num_epochs: 1 + num_epochs: 0 + learning_rate: 5e-5 + batch_size: 32 + gradient_accumulation_steps: 1 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 16 + lora_alpha: 16 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "k_proj" + - "v_proj" + - "o_proj" + - "gate_proj" + - "up_proj" + - "down_proj" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/other/unsloth_orpheus-3b-0.1-ft.yaml b/studio/backend/assets/configs/model_defaults/other/unsloth_orpheus-3b-0.1-ft.yaml new file mode 100644 index 0000000000..883761675f --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/other/unsloth_orpheus-3b-0.1-ft.yaml @@ -0,0 +1,54 @@ +# Model defaults for unsloth/orpheus-3b-0.1-ft +# Based on Orpheus_(3B)-TTS.ipynb +# Also applies to: unsloth/orpheus-3b-0.1-ft-unsloth-bnb-4bit, canopylabs/orpheus-3b-0.1-ft, unsloth/orpheus-3b-0.1-ft-bnb-4bit +# added inference parameters from unsloth notebook + +audio_type: snac + +training: + trust_remote_code: false + eval_steps: 0 + max_seq_length: 2048 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 1 + gradient_accumulation_steps: 4 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 64 + lora_alpha: 64 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "k_proj" + - "v_proj" + - "o_proj" + - "gate_proj" + - "up_proj" + - "down_proj" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false + temperature: 0.6 + top_p: 0.95 + diff --git a/studio/backend/assets/configs/model_defaults/other/unsloth_tinyllama-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/other/unsloth_tinyllama-bnb-4bit.yaml new file mode 100644 index 0000000000..35c850c71f --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/other/unsloth_tinyllama-bnb-4bit.yaml @@ -0,0 +1,47 @@ +# Model defaults for unsloth/tinyllama +# Based on TinyLlama_(1.1B)-Alpaca.ipynb +# Also applies to: TinyLlama/TinyLlama-1.1B-intermediate-step-1431k-3T + +training: + trust_remote_code: false + max_seq_length: 4096 + # num_epochs: 1 + num_epochs: 0 + learning_rate: 2e-5 + batch_size: 2 + gradient_accumulation_steps: 4 + warmup_ratio: 0.1 + max_steps: 30 + save_steps: 30 + weight_decay: 0.1 + random_seed: 3407 + packing: true + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 32 + lora_alpha: 32 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "k_proj" + - "v_proj" + - "o_proj" + - "gate_proj" + - "up_proj" + - "down_proj" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/other/unsloth_whisper-large-v3.yaml b/studio/backend/assets/configs/model_defaults/other/unsloth_whisper-large-v3.yaml new file mode 100644 index 0000000000..9140878e0e --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/other/unsloth_whisper-large-v3.yaml @@ -0,0 +1,46 @@ +# Model defaults for unsloth/whisper-large-v3 +# Based on Whisper.ipynb +# Also applies to: unsloth/whisper-large-v3, openai/whisper-large-v3 + +audio_type: whisper +audio_input: true + +training: + trust_remote_code: false + eval_steps: 5 + max_seq_length: 448 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 1e-4 + batch_size: 1 + gradient_accumulation_steps: 4 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 64 + lora_alpha: 64 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "v_proj" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/phi/unsloth_Phi-3-medium-4k-instruct.yaml b/studio/backend/assets/configs/model_defaults/phi/unsloth_Phi-3-medium-4k-instruct.yaml new file mode 100644 index 0000000000..1088df7796 --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/phi/unsloth_Phi-3-medium-4k-instruct.yaml @@ -0,0 +1,47 @@ +# Model defaults for unsloth/Phi-3-medium-4k-instruct +# Based on Phi_3_Medium-Conversational.ipynb +# Also applies to: "unsloth/Phi-3-medium-4k-instruct-bnb-4bit", "microsoft/Phi-3-medium-4k-instruct", + +training: + trust_remote_code: false + max_seq_length: 2048 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 2 + gradient_accumulation_steps: 4 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 16 + lora_alpha: 16 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "k_proj" + - "v_proj" + - "o_proj" + - "gate_proj" + - "up_proj" + - "down_proj" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/phi/unsloth_Phi-3.5-mini-instruct.yaml b/studio/backend/assets/configs/model_defaults/phi/unsloth_Phi-3.5-mini-instruct.yaml new file mode 100644 index 0000000000..79812a74c4 --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/phi/unsloth_Phi-3.5-mini-instruct.yaml @@ -0,0 +1,47 @@ +# Model defaults for unsloth/Phi-3.5-mini-instruct +# Based on Phi_3.5_Mini-Conversational.ipynb +# Also applies to: "unsloth/Phi-3.5-mini-instruct-bnb-4bit", "microsoft/Phi-3.5-mini-instruct" + +training: + trust_remote_code: false + max_seq_length: 2048 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 2 + gradient_accumulation_steps: 4 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 16 + lora_alpha: 16 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "k_proj" + - "v_proj" + - "o_proj" + - "gate_proj" + - "up_proj" + - "down_proj" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/phi/unsloth_Phi-4.yaml b/studio/backend/assets/configs/model_defaults/phi/unsloth_Phi-4.yaml new file mode 100644 index 0000000000..aaa4feac45 --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/phi/unsloth_Phi-4.yaml @@ -0,0 +1,51 @@ +# Model defaults for unsloth/Phi-4 +# Based on Phi_4-Conversational.ipynb +# Also applies to: unsloth/phi-4-unsloth-bnb-4bit, microsoft/phi-4, unsloth/phi-4-bnb-4bit +# added inference parameters from unsloth guides + +training: + trust_remote_code: false + max_seq_length: 2048 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 2 + gradient_accumulation_steps: 4 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 16 + lora_alpha: 16 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "k_proj" + - "v_proj" + - "o_proj" + - "gate_proj" + - "up_proj" + - "down_proj" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false + temperature: 0.8 + top_p: 0.95 + diff --git a/studio/backend/assets/configs/model_defaults/qwen/imdatta0_tiny_qwen3_moe_2.8B_0.7B.yaml b/studio/backend/assets/configs/model_defaults/qwen/imdatta0_tiny_qwen3_moe_2.8B_0.7B.yaml new file mode 100644 index 0000000000..fa7b9c4e8b --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/qwen/imdatta0_tiny_qwen3_moe_2.8B_0.7B.yaml @@ -0,0 +1,53 @@ +# Model defaults for imdatta0/tiny_qwen3_moe_2.8B_0.7B +# Based on TinyQwen3_MoE.py +# Dummy model of qwen3moe architecture created to fit in T4 +# MoE model - includes gate_up_proj for MoE layers + +training: + trust_remote_code: false + max_seq_length: 2048 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 1 + gradient_accumulation_steps: 1 + warmup_steps: 5 + max_steps: 50 + save_steps: 50 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 32 + lora_alpha: 64 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "k_proj" + - "v_proj" + - "o_proj" + - "gate_proj" + - "up_proj" + - "down_proj" + - "gate_up_proj" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false + temperature: 0.6 + top_k: 20 + top_p: 0.95 + diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2-7B.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2-7B.yaml new file mode 100644 index 0000000000..3e64a6ca48 --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2-7B.yaml @@ -0,0 +1,47 @@ +# Model defaults for unsloth/Qwen2-7B +# Based on Qwen2_(7B)-Alpaca.ipynb +# Also applies to: unsloth/Qwen2-7B-bnb-4bit, Qwen/Qwen2-7B + +training: + trust_remote_code: false + max_seq_length: 2048 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 2 + gradient_accumulation_steps: 4 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 16 + lora_alpha: 16 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "k_proj" + - "v_proj" + - "o_proj" + - "gate_proj" + - "up_proj" + - "down_proj" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2-VL-7B-Instruct.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2-VL-7B-Instruct.yaml new file mode 100644 index 0000000000..894751bed1 --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2-VL-7B-Instruct.yaml @@ -0,0 +1,49 @@ +# Model defaults for unsloth/Qwen2-VL-7B-Instruct +# Based on Qwen2_VL_(7B)-Vision.ipynb +# Also applies to: unsloth/Qwen2-VL-7B-Instruct-unsloth-bnb-4bit, Qwen/Qwen2-VL-7B-Instruct, unsloth/Qwen2-VL-7B-Instruct-bnb-4bit +# added inference parameters from unsloth notebook + +training: + trust_remote_code: false + max_seq_length: 2048 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 2 + gradient_accumulation_steps: 4 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 16 + lora_alpha: 16 + lora_dropout: 0.0 + target_modules: + - "all-linear" + use_rslora: false + use_loftq: false + finetune_vision_layers: true + finetune_language_layers: true + finetune_attention_modules: true + finetune_mlp_modules: true + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false + temperature: 1.5 + min_p: 0.1 + diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-1.5B-Instruct.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-1.5B-Instruct.yaml new file mode 100644 index 0000000000..1d37cc9829 --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-1.5B-Instruct.yaml @@ -0,0 +1,47 @@ +# Model defaults for unsloth/Qwen2.5-1.5B-Instruct +# Based on nemo_gym_sudoku.ipynb +# Also applies to: unsloth/Qwen2.5-1.5B-Instruct-unsloth-bnb-4bit, Qwen/Qwen2.5-1.5B-Instruct, unsloth/Qwen2.5-1.5B-Instruct-bnb-4bit + +training: + trust_remote_code: false + max_seq_length: 4096 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 1e-5 + batch_size: 1 + gradient_accumulation_steps: 64 + warmup_ratio: 0.1 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 42 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 4 + lora_alpha: 8 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "k_proj" + - "v_proj" + - "o_proj" + - "gate_proj" + - "up_proj" + - "down_proj" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-7B.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-7B.yaml new file mode 100644 index 0000000000..99f3a66e23 --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-7B.yaml @@ -0,0 +1,47 @@ +# Model defaults for unsloth/Qwen2.5-7B +# Based on Qwen2.5_(7B)-Alpaca.ipynb +# Also applies to: unsloth/Qwen2.5-7B-unsloth-bnb-4bit, Qwen/Qwen2.5-7B, unsloth/Qwen2.5-7B-bnb-4bit + +training: + trust_remote_code: false + max_seq_length: 2048 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 2 + gradient_accumulation_steps: 4 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 16 + lora_alpha: 16 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "k_proj" + - "v_proj" + - "o_proj" + - "gate_proj" + - "up_proj" + - "down_proj" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-Coder-1.5B-Instruct.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-Coder-1.5B-Instruct.yaml new file mode 100644 index 0000000000..c48b943cba --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-Coder-1.5B-Instruct.yaml @@ -0,0 +1,47 @@ +# Model defaults for unsloth/Qwen2.5-Coder-1.5B-Instruct +# Based on Qwen2.5_Coder_(1.5B)-Tool_Calling.ipynb +# Also applies to: unsloth/Qwen2.5-Coder-1.5B-Instruct-bnb-4bit, Qwen/Qwen2.5-Coder-1.5B-Instruct + +training: + trust_remote_code: false + max_seq_length: 2048 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 2 + gradient_accumulation_steps: 4 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 16 + lora_alpha: 16 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "k_proj" + - "v_proj" + - "o_proj" + - "gate_proj" + - "up_proj" + - "down_proj" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-Coder-14B-Instruct.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-Coder-14B-Instruct.yaml new file mode 100644 index 0000000000..830bfcf1cb --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-Coder-14B-Instruct.yaml @@ -0,0 +1,51 @@ +# Model defaults for unsloth/Qwen2.5-Coder-14B-Instruct +# Based on Qwen2.5_Coder_(14B)-Conversational.ipynb +# Also applies to: unsloth/Qwen2.5-Coder-14B-Instruct-bnb-4bit, Qwen/Qwen2.5-Coder-14B-Instruct +# added inference parameters from unsloth notebook + +training: + trust_remote_code: false + max_seq_length: 2048 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 1 + gradient_accumulation_steps: 4 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "paged_adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 16 + lora_alpha: 16 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "k_proj" + - "v_proj" + - "o_proj" + - "gate_proj" + - "up_proj" + - "down_proj" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false + temperature: 1.5 + min_p: 0.1 + diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-Coder-7B-Instruct-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-Coder-7B-Instruct-bnb-4bit.yaml new file mode 100644 index 0000000000..db88c3b033 --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-Coder-7B-Instruct-bnb-4bit.yaml @@ -0,0 +1,47 @@ +# Model defaults for unsloth/Qwen2.5-Coder-7B-Instruct-bnb-4bit +# Based on CodeForces-cot-Finetune_for_Reasoning_on_CodeForces.ipynb +# Also applies to: unsloth/Qwen2.5-Coder-7B-Instruct, Qwen/Qwen2.5-Coder-7B-Instruct + +training: + trust_remote_code: false + max_seq_length: 32768 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 2 + gradient_accumulation_steps: 4 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 16 + lora_alpha: 16 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "k_proj" + - "v_proj" + - "o_proj" + - "gate_proj" + - "up_proj" + - "down_proj" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-VL-7B-Instruct-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-VL-7B-Instruct-bnb-4bit.yaml new file mode 100644 index 0000000000..cb9bcb104b --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen2.5-VL-7B-Instruct-bnb-4bit.yaml @@ -0,0 +1,49 @@ +# Model defaults for unsloth/Qwen2.5-VL-7B-Instruct-bnb-4bit +# Based on Qwen2.5_VL_(7B)-Vision.ipynb +# Also applies to: unsloth/Qwen2.5-VL-7B-Instruct, Qwen/Qwen2.5-VL-7B-Instruct, unsloth/Qwen2.5-VL-7B-Instruct-unsloth-bnb-4bit +# added inference parameters from unsloth notebook + +training: + trust_remote_code: false + max_seq_length: 2048 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 2 + gradient_accumulation_steps: 4 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 16 + lora_alpha: 16 + lora_dropout: 0.0 + target_modules: + - "all-linear" + use_rslora: false + use_loftq: false + finetune_vision_layers: true + finetune_language_layers: true + finetune_attention_modules: true + finetune_mlp_modules: true + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false + temperature: 1.5 + min_p: 0.1 + diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-0.6B.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-0.6B.yaml new file mode 100644 index 0000000000..13f066a27d --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-0.6B.yaml @@ -0,0 +1,52 @@ +# Model defaults for unsloth/Qwen3-0.6B +# Based on Qwen3_(0_6B)-Phone_Deployment.ipynb +# Also applies to: unsloth/Qwen3-0.6B-unsloth-bnb-4bit, Qwen/Qwen3-0.6B, unsloth/Qwen3-0.6B-bnb-4bit, Qwen/Qwen3-0.6B-FP8, unsloth/Qwen3-0.6B-FP8 +# added inference parameters from Ollama + +training: + trust_remote_code: false + max_seq_length: 1024 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 5e-5 + batch_size: 2 + gradient_accumulation_steps: 4 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 16 + lora_alpha: 16 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "k_proj" + - "v_proj" + - "o_proj" + - "gate_proj" + - "up_proj" + - "down_proj" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false + temperature: 0.6 + top_k: 20 + top_p: 0.95 + diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-14B-Base-unsloth-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-14B-Base-unsloth-bnb-4bit.yaml new file mode 100644 index 0000000000..87c042705b --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-14B-Base-unsloth-bnb-4bit.yaml @@ -0,0 +1,51 @@ +# Model defaults for unsloth/Qwen3-14B-Base +# Based on Qwen3_(14B)-Alpaca.ipynb +# Also applies to: unsloth/Qwen3-14B-Base, Qwen/Qwen3-14B-Base, unsloth/Qwen3-14B-Base-bnb-4bit +# added inference parameters from Ollama + +training: + trust_remote_code: false + max_seq_length: 2048 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 2 + gradient_accumulation_steps: 4 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 16 + lora_alpha: 16 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "k_proj" + - "v_proj" + - "o_proj" + - "gate_proj" + - "up_proj" + - "down_proj" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false + temperature: 0.6 + top_k: 20 + top_p: 0.95 diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-14B.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-14B.yaml new file mode 100644 index 0000000000..a8ecbb4365 --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-14B.yaml @@ -0,0 +1,52 @@ +# Model defaults for unsloth/Qwen3-14B +# Based on Qwen3_(14B).ipynb +# Also applies to: unsloth/Qwen3-14B-unsloth-bnb-4bit, Qwen/Qwen3-14B, unsloth/Qwen3-14B-bnb-4bit, Qwen/Qwen3-14B-FP8, unsloth/Qwen3-14B-FP8 +# added inference parameters from Ollama + +training: + trust_remote_code: false + max_seq_length: 2048 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 2 + gradient_accumulation_steps: 4 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 32 + lora_alpha: 32 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "k_proj" + - "v_proj" + - "o_proj" + - "gate_proj" + - "up_proj" + - "down_proj" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false + temperature: 0.6 + top_k: 20 + top_p: 0.95 + diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-30B-A3B-Instruct-2507.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-30B-A3B-Instruct-2507.yaml new file mode 100644 index 0000000000..485dd7a111 --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-30B-A3B-Instruct-2507.yaml @@ -0,0 +1,53 @@ +# Model defaults for unsloth/Qwen3-30B-A3B-Instruct-2507 +# Based on Qwen3_MoE.py +# Also applies to: Qwen/Qwen3-30B-A3B-Instruct-2507, unsloth/Qwen3-30B-A3B-Instruct-2507-bnb-4bit +# MoE model - includes gate_up_proj for MoE layers + +training: + trust_remote_code: false + max_seq_length: 2048 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 1 + gradient_accumulation_steps: 1 + warmup_steps: 5 + max_steps: 50 + save_steps: 50 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 32 + lora_alpha: 64 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "k_proj" + - "v_proj" + - "o_proj" + - "gate_proj" + - "up_proj" + - "down_proj" + - "gate_up_proj" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false + temperature: 0.6 + top_k: 20 + top_p: 0.95 + diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-32B.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-32B.yaml new file mode 100644 index 0000000000..0de64d50ae --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-32B.yaml @@ -0,0 +1,52 @@ +# Model defaults for unsloth/Qwen3-32B +# Based on Qwen3_(32B)_A100-Reasoning-Conversational.ipynb +# Also applies to: unsloth/Qwen3-32B-unsloth-bnb-4bit, Qwen/Qwen3-32B, unsloth/Qwen3-32B-bnb-4bit, Qwen/Qwen3-32B-FP8, unsloth/Qwen3-32B-FP8 +# added inference parameters from Ollama + +training: + trust_remote_code: false + max_seq_length: 2048 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 2 + gradient_accumulation_steps: 4 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 32 + lora_alpha: 32 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "k_proj" + - "v_proj" + - "o_proj" + - "gate_proj" + - "up_proj" + - "down_proj" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false + temperature: 0.6 + top_k: 20 + top_p: 0.95 + diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-4B-Instruct-2507.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-4B-Instruct-2507.yaml new file mode 100644 index 0000000000..dc5940d58c --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-4B-Instruct-2507.yaml @@ -0,0 +1,53 @@ +# Model defaults for unsloth/Qwen3-4B-Instruct-2507 +# Based on Qwen3_(4B)-Instruct.ipynb +# Also applies to: unsloth/Qwen3-4B-Instruct-2507-unsloth-bnb-4bit, Qwen/Qwen3-4B-Instruct-2507, unsloth/Qwen3-4B-Instruct-2507-bnb-4bit, Qwen/Qwen3-4B-Instruct-2507-FP8, unsloth/Qwen3-4B-Instruct-2507-FP8 +# added inference parameters from unsloth guides + +training: + trust_remote_code: false + max_seq_length: 2048 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 2 + gradient_accumulation_steps: 4 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 32 + lora_alpha: 32 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "k_proj" + - "v_proj" + - "o_proj" + - "gate_proj" + - "up_proj" + - "down_proj" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false + temperature: 0.7 + top_p: 0.80 + top_k: 20 + min_p: 0.00 + diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-4B-Thinking-2507.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-4B-Thinking-2507.yaml new file mode 100644 index 0000000000..6392ee0ae9 --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-4B-Thinking-2507.yaml @@ -0,0 +1,53 @@ +# Model defaults for unsloth/Qwen3-4B-Thinking-2507 +# Based on Qwen3_(4B)-Thinking.ipynb +# Also applies to: unsloth/Qwen3-4B-Thinking-2507-unsloth-bnb-4bit, Qwen/Qwen3-4B-Thinking-2507, unsloth/Qwen3-4B-Thinking-2507-bnb-4bit, Qwen/Qwen3-4B-Thinking-2507-FP8, unsloth/Qwen3-4B-Thinking-2507-FP8 +# added inference parameters from unsloth guides + +training: + trust_remote_code: false + max_seq_length: 2048 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 2 + gradient_accumulation_steps: 4 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 32 + lora_alpha: 32 + lora_dropout: 0.0 + target_modules: + - "q_proj" + - "k_proj" + - "v_proj" + - "o_proj" + - "gate_proj" + - "up_proj" + - "down_proj" + use_rslora: false + use_loftq: false + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false + temperature: 0.6 + top_p: 0.95 + top_k: 20 + min_p: 0.00 + diff --git a/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-VL-8B-Instruct-unsloth-bnb-4bit.yaml b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-VL-8B-Instruct-unsloth-bnb-4bit.yaml new file mode 100644 index 0000000000..ef52fad763 --- /dev/null +++ b/studio/backend/assets/configs/model_defaults/qwen/unsloth_Qwen3-VL-8B-Instruct-unsloth-bnb-4bit.yaml @@ -0,0 +1,50 @@ +# Model defaults for unsloth/Qwen3-VL-8B-Instruct +# Based on Qwen3_VL_(8B)-Vision.ipynb +# Also applies to: Qwen/Qwen3-VL-8B-Instruct-FP8, unsloth/Qwen3-VL-8B-Instruct-FP8, unsloth/Qwen3-VL-8B-Instruct, Qwen/Qwen3-VL-8B-Instruct, unsloth/Qwen3-VL-8B-Instruct-bnb-4bit +# added inference parameters from unsloth guides + +training: + trust_remote_code: false + max_seq_length: 2048 + # num_epochs: 4 + num_epochs: 0 + learning_rate: 2e-4 + batch_size: 2 + gradient_accumulation_steps: 4 + warmup_steps: 5 + max_steps: 30 + save_steps: 30 + weight_decay: 0.001 + random_seed: 3407 + packing: false + train_on_completions: true + gradient_checkpointing: "unsloth" + optim: "adamw_8bit" + lr_scheduler_type: "linear" + +lora: + lora_r: 16 + lora_alpha: 16 + lora_dropout: 0.0 + target_modules: + - "all-linear" + use_rslora: false + use_loftq: false + finetune_vision_layers: true + finetune_language_layers: true + finetune_attention_modules: true + finetune_mlp_modules: true + +logging: + enable_wandb: false + wandb_project: "llm-finetuning" + enable_tensorboard: false + tensorboard_dir: "runs" + log_frequency: 10 + +inference: + trust_remote_code: false + temperature: 0.7 + top_p: 0.8 + top_k: 20 + diff --git a/studio/backend/assets/configs/vision_lora.yaml b/studio/backend/assets/configs/vision_lora.yaml new file mode 100644 index 0000000000..60641b16e8 --- /dev/null +++ b/studio/backend/assets/configs/vision_lora.yaml @@ -0,0 +1,42 @@ +model: unsloth/Qwen2-VL-2B-Instruct-bnb-4bit + +data: + dataset: philschmid/amazon-product-descriptions-vlm + format_type: auto + +training: + training_type: lora + max_seq_length: 2048 + load_in_4bit: true + output_dir: outputs + num_epochs: 1 + learning_rate: 0.0002 + batch_size: 1 + gradient_accumulation_steps: 4 + warmup_steps: 5 + max_steps: 0 + save_steps: 0 + weight_decay: 0.01 + random_seed: 3407 + packing: false + train_on_completions: false + gradient_checkpointing: "unsloth" + +lora: + lora_r: 64 + lora_alpha: 16 + lora_dropout: 0.0 + target_modules: "" # vision uses vision_all_linear by default + vision_all_linear: true + use_rslora: false + use_loftq: false + finetune_vision_layers: true + finetune_language_layers: true + finetune_attention_modules: true + finetune_mlp_modules: true + +logging: + enable_wandb: false + wandb_project: unsloth-training + enable_tensorboard: false + tensorboard_dir: runs diff --git a/studio/backend/assets/datasets/alpaca_unsloth.json b/studio/backend/assets/datasets/alpaca_unsloth.json new file mode 100644 index 0000000000..2328223a8a --- /dev/null +++ b/studio/backend/assets/datasets/alpaca_unsloth.json @@ -0,0 +1,1288 @@ +[ + { + "instruction": "", + "input": "What is Unsloth?", + "output": "Unsloth is an open-source library designed to make LLM fine-tuning faster and more memory-efficient. It allows you to fine-tune models like Llama, Mistral, Qwen, Gemma, and others up to 2x faster with 70% less VRAM usage compared to standard methods. The library is fully compatible with the Hugging Face ecosystem and supports techniques like QLoRA, LoRA, and full fine-tuning." + }, + { + "instruction": "", + "input": "Who created Unsloth?", + "output": "Unsloth was created by two brothers, Daniel Han and Mike Han, along with the Unsloth team. The project started in 2023 and has grown into a popular open-source tool for efficient LLM training." + }, + { + "instruction": "", + "input": "How much faster is Unsloth compared to standard fine-tuning?", + "output": "Unsloth makes fine-tuning 2x faster on a single GPU compared to standard Hugging Face methods with Flash Attention 2. In some benchmarks, it achieves up to 2.7x speedup. On multi-GPU systems, it can be up to 30x faster. The library accomplishes this through optimized kernels written in OpenAI's Triton language and manual backpropagation engine optimizations." + }, + { + "instruction": "", + "input": "How much memory does Unsloth save?", + "output": "Unsloth reduces memory usage by up to 70% compared to standard fine-tuning approaches. This allows you to fit larger batch sizes and train bigger models on the same hardware. For example, you can fine-tune a 9B parameter model on just 24GB VRAM using LoRA 16-bit, or use as little as 6.5GB VRAM with QLoRA 4-bit quantization." + }, + { + "instruction": "", + "input": "What models does Unsloth support?", + "output": "Unsloth supports a wide range of models including Llama (all versions), Mistral, Qwen, Gemma, Phi, DeepSeek, CodeLlama, Yi, TinyLlama, and more. It also supports Text-to-Speech models, multimodal models, BERT, and Mamba architectures. Essentially, any model that works in Hugging Face transformers works in Unsloth." + }, + { + "instruction": "", + "input": "What GPUs does Unsloth support?", + "output": "Unsloth supports NVIDIA GPUs from Tesla T4, GTX 1070 all the way up to H100s and requires minimum CUDA Capability 7.0. It also supports AMD GPUs via ROCm and Intel GPUs. The library even works on consumer hardware like Apple Silicon (M1, M2, M3) MacBooks with Metal backend support." + }, + { + "instruction": "", + "input": "What training methods does Unsloth support?", + "output": "Unsloth supports multiple training methods including QLoRA (4-bit quantized LoRA), LoRA 16-bit, 8-bit training, full fine-tuning (FFT), and continued pretraining. It also supports reinforcement learning methods like GRPO, GSPO, DPO, ORPO, PPO, and KTO. The library maintains 0% loss in accuracy with no approximation methods - everything is exact." + }, + { + "instruction": "", + "input": "How do I install Unsloth?", + "output": "You can install Unsloth using pip with the command: pip install unsloth. For local Linux or WSL installations, this is the recommended method. Unsloth also provides official Docker images and works on Google Colab and Kaggle notebooks. The library requires Python 3.13 or lower (Python 3.14 is not supported)." + }, + { + "instruction": "", + "input": "What is Unsloth UI?", + "output": "Unsloth UI is a user interface for Unsloth that makes fine-tuning even more accessible by providing a visual interface for training models. It allows users to configure training parameters, manage datasets, and monitor training progress without writing code, making LLM fine-tuning accessible to non-programmers." + }, + { + "instruction": "", + "input": "Can I use Unsloth for free?", + "output": "Yes! Unsloth has a fully free open-source version available on GitHub that makes fine-tuning 2x faster with 50% less memory. You can use it for free on Google Colab, Kaggle notebooks, or install it locally. The library is open source and actively maintained by the community." + }, + { + "instruction": "", + "input": "What is special about Unsloth's implementation?", + "output": "Unsloth achieves its performance through several innovations: all kernels are rewritten in OpenAI's Triton language, it uses manual backpropagation engine optimizations, implements Flash Attention via xformers, and performs optimized chained matrix multiplication. It also includes RoPE Scaling internally and uses gradient checkpointing to save VRAM during training." + }, + { + "instruction": "", + "input": "What is QLoRA and does Unsloth support it?", + "output": "QLoRA (Quantized LoRA) is a technique that combines 4-bit quantization with LoRA fine-tuning to dramatically reduce memory usage. Unsloth fully supports QLoRA and is optimized for it - you can enable it with load_in_4bit=True. This allows fine-tuning large models on consumer GPUs with minimal memory, making it one of the most accessible fine-tuning methods available." + }, + { + "instruction": "", + "input": "Can Unsloth train models with long context?", + "output": "Yes! Unsloth natively supports training with very long context lengths, including up to 128k tokens. It automatically handles RoPE Scaling internally, which means you can specify any maximum sequence length and the library will handle the necessary adjustments for extended context training." + }, + { + "instruction": "", + "input": "What is Unsloth Dynamic Quantization?", + "output": "Unsloth Dynamic Quantization 2.0 is an advanced quantization method that analyzes each layer's sensitivity to compression rather than applying one-size-fits-all quantization. It uses calibration datasets ranging from 300K to 1.5M tokens and achieves SOTA quantization performance, setting new benchmarks on tasks like 5-shot MMLU and Aider Polyglot." + }, + { + "instruction": "", + "input": "Does Unsloth support exporting to GGUF format?", + "output": "Yes! Unsloth has robust support for GGUF (Grokking GGML Unified Format) exports. You can save models using model.save_pretrained_gguf() or push directly to Hugging Face Hub with model.push_to_hub_gguf(). This makes it easy to deploy fine-tuned models to inference engines like llama.cpp, Ollama, and other GGUF-compatible tools." + }, + { + "instruction": "", + "input": "What inference engines work with Unsloth models?", + "output": "Unsloth fine-tuned models can be exported to and used with multiple inference engines including Ollama, vLLM, llama.cpp, Open WebUI, and any other engine compatible with Hugging Face format or GGUF format. The library provides easy export methods for all major deployment platforms." + }, + { + "instruction": "", + "input": "Can I use Unsloth for reinforcement learning?", + "output": "Yes! Unsloth is the most efficient library for Reinforcement Learning, using 80% less VRAM compared to standard methods. It supports multiple RL algorithms including GRPO (Group Relative Policy Optimization), GSPO, DrGRPO, DAPO, DPO, ORPO, PPO, and KTO. You can even train reasoning models with long-context RL using just 5GB VRAM." + }, + { + "instruction": "", + "input": "What makes Unsloth different from standard Hugging Face fine-tuning?", + "output": "Unlike standard Hugging Face fine-tuning which requires multiple libraries (transformers, PEFT, bitsandbytes, Deepspeed, TRL), Unsloth provides a single unified API that's much simpler to use. It also delivers 2-3x faster training, 50-70% less memory usage, and handles common issues like layer norm quantization automatically. Plus, all the complex setup is abstracted away into simple function calls." + }, + { + "instruction": "", + "input": "Does Unsloth support Mixture of Experts models?", + "output": "Yes! Unsloth has native support for Mixture of Experts (MoE) models like Mixtral 8x7B. Traditional libraries aren't optimized for MoE fine-tuning yet, but Unsloth supports them natively with the same efficiency benefits, including support for 128k context lengths and all standard fine-tuning techniques." + }, + { + "instruction": "", + "input": "What is the recommended batch size for Unsloth?", + "output": "Unsloth recommends keeping per_device_train_batch_size at 2 for most use cases. To simulate larger batch sizes without increasing memory usage, you should increase gradient_accumulation_steps instead (typically set to 4). This approach provides smoother training without the memory overhead of larger batches and avoids slowdowns from excessive padding." + }, + { + "instruction": "", + "input": "How many epochs should I train with Unsloth?", + "output": "Unsloth recommends 1-3 epochs to avoid overfitting. For quick experiments, you can use max_steps (like 60 steps) instead of full epochs. The default learning rate is 2e-4, which can be lowered for slower but more precise fine-tuning. Always monitor your training loss - it should reach somewhere between 0.5 and 1.0, not 0 (which indicates overfitting)." + }, + { + "instruction": "", + "input": "Can I use Unsloth on Windows?", + "output": "Yes! Unsloth works on Windows, Linux, and WSL (Windows Subsystem for Linux). The library is compatible with Windows systems that have compatible NVIDIA GPUs. You can install it via pip or use the official Docker image for a containerized setup." + }, + { + "instruction": "", + "input": "What is the Unsloth community like?", + "output": "Unsloth has an active community with a Discord server and Reddit community at r/unsloth. The library is actively developed by Daniel and Mike Han along with open-source contributors. They collaborate directly with teams behind major models like GPT-OSS, Qwen, Llama, Mistral, and Gemma, often fixing critical bugs in models before official release." + }, + { + "instruction": "", + "input": "Does Unsloth support Text-to-Speech models?", + "output": "Yes! Unsloth recently added support for Text-to-Speech (TTS) models including sesame/csm-1b and also supports Speech-to-Text with OpenAI's Whisper models like whisper-large-v3. This makes Unsloth one of the few libraries that supports not just text LLMs, but also audio models with the same efficiency benefits." + }, + { + "instruction": "", + "input": "What is FastLanguageModel in Unsloth?", + "output": "FastLanguageModel is Unsloth's main API for loading and configuring models. You use FastLanguageModel.from_pretrained() to load a model and tokenizer together, and FastLanguageModel.get_peft_model() to add LoRA adapters. This single API handles all the complex setup of quantization, RoPE scaling, and optimization automatically." + }, + { + "instruction": "", + "input": "Can I fine-tune vision models with Unsloth?", + "output": "Yes! Unsloth supports multimodal and vision models. The library can fine-tune vision-language models and other multimodal architectures with the same efficiency benefits. Unsloth also recently added VLM RL (Vision-Language Model Reinforcement Learning) support for models like Qwen and Gemma vision variants." + }, + { + "instruction": "", + "input": "What LoRA rank should I use with Unsloth?", + "output": "Unsloth suggests LoRA ranks of 8, 16, 32, 64, or 128. For most use cases, r=16 or r=32 works well, balancing model capacity with training efficiency. The library also recommends setting lora_dropout=0 and bias='none' as these are optimized settings. You can use the 'unsloth' use_gradient_checkpointing option which uses 30% less VRAM and fits 2x larger batch sizes." + }, + { + "instruction": "", + "input": "Does Unsloth support continued pretraining?", + "output": "Yes! Unsloth supports continued pretraining in addition to fine-tuning. This allows you to further pretrain models on domain-specific corpora to inject new knowledge before task-specific fine-tuning. You can do this with the same efficiency benefits - 2x faster training and 70% less memory." + }, + { + "instruction": "", + "input": "What is the Unsloth sticker for?", + "output": "If you train a model with Unsloth, you can use a special Unsloth sloth emoji sticker (🦥) to proudly display that your model was fine-tuned using Unsloth! It's become a badge of honor in the community to show you used efficient, fast fine-tuning methods." + }, + { + "instruction": "", + "input": "Can I save just the LoRA adapter with Unsloth?", + "output": "Yes! After fine-tuning, you can save just the LoRA adapter as a small file (typically around 100MB) instead of saving the entire model. This makes it easy to share and distribute your fine-tunes. You can save locally or push directly to Hugging Face Hub. Later, you can reload the base model and apply your adapter for inference." + }, + { + "instruction": "", + "input": "What recent models has Unsloth added support for?", + "output": "Recently, Unsloth added support for OpenAI's GPT-OSS (20B and 120B), Google's Gemma 3, Qwen3 (including Qwen3-30B-A3B that fits on 17.5GB VRAM), DeepSeek-R1, and Microsoft's Phi-4. The library also supports TTS models and has introduced long-context reasoning with GRPO that lets you train reasoning models with just 5GB VRAM." + }, + { + "instruction": "", + "input": "How can Unsloth accelerate LLM fine-tuning?", + "output": "Unsloth accelerates LLM fine-tuning by overwriting some parts of the modeling code with optimized operations and rewriting all Pytorch modules into Triton kernels, resulting in a 2x speedup and a 40% reduction in memory usage." + }, + { + "instruction": "", + "input": "What is one benefit of using Unsloth for LLM fine-tuning?", + "output": "One benefit of using Unsloth for LLM fine-tuning is that it offers a 0% accuracy degradation compared to normal QLoRA, as no approximations are made in the optimized code." + }, + { + "instruction": "", + "input": "Who developed the Unsloth library for LLM fine-tuning?", + "output": "The Unsloth library for LLM fine-tuning was developed by the Unsloth team, including Daniel and Mike, in collaboration with the open-source community." + }, + { + "instruction": "", + "input": "What GPU types are compatible with Unsloth for LLM fine-tuning?", + "output": "Unsloth supports most NVIDIA GPUs, ranging from GTX 1070 to H100s, making it compatible with a wide range of hardware configurations." + }, + { + "instruction": "", + "input": "How does Unsloth contribute to reducing memory usage during LLM fine-tuning?", + "output": "Unsloth contributes to reducing memory usage during LLM fine-tuning by rewriting all Pytorch modules into Triton kernels, resulting in a 40% decrease in memory usage." + }, + { + "instruction": "", + "input": "What is the primary goal of Unsloth for LLM fine-tuning?", + "output": "The primary goal of Unsloth for LLM fine-tuning is to accelerate the process, achieving a 2x speedup while maintaining 0% accuracy degradation compared to normal QLoRA." + }, + { + "instruction": "", + "input": "How does Unsloth improve LLM fine-tuning performance?", + "output": "Unsloth improves LLM fine-tuning performance by manually deriving backpropagation steps and rewriting Pytorch modules into Triton kernels, resulting in a 2x speed increase and a 40% reduction in memory usage." + }, + { + "instruction": "", + "input": "What makes Unsloth different from other tools for LLM fine-tuning?", + "output": "What makes Unsloth different from other tools for LLM fine-tuning is its ability to achieve a 2x speed increase and a 40% reduction in memory usage while maintaining 0% accuracy degradation, thanks to optimized operations and Triton kernel rewriting." + }, + { + "instruction": "", + "input": "Why is Unsloth considered a lightweight library for LLM fine-tuning?", + "output": "Unsloth is considered a lightweight library for LLM fine-tuning because it offers significant performance improvements, including a 2x speed increase and a 40% reduction in memory usage, without compromising accuracy." + }, + { + "instruction": "", + "input": "What are the supported architectures for LLM fine-tuning by Unsloth?", + "output": "Unsloth supports the Llama and Mistral architectures for LLM fine-tuning, ensuring compatibility with a range of model structures." + }, + { + "instruction": "", + "input": "What benefits does Unsloth offer for LLM fine-tuning?", + "output": "Unsloth provides a 2x acceleration in LLM fine-tuning speed, reduces memory usage by 40%, and ensures 0% accuracy degradation compared to regular QLoRA fine-tuning methods." + }, + { + "instruction": "", + "input": "Which GPUs are supported by Unsloth for LLM fine-tuning acceleration?", + "output": "Unsloth supports most NVIDIA GPUs, ranging from GTX 1070 to H100s, ensuring compatibility with a wide range of hardware configurations for fine-tuning large language models." + }, + { + "instruction": "", + "input": "How does Unsloth ensure accuracy preservation during LLM fine-tuning?", + "output": "Unsloth achieves 0% accuracy degradation by avoiding approximations in the optimized code and ensuring that all backpropagation steps are manually derived, maintaining fidelity to regular QLoRA fine-tuning methods." + }, + { + "instruction": "", + "input": "What is the compatibility of Unsloth with the Hugging Face ecosystem?", + "output": "Unsloth is fully compatible with the Hugging Face ecosystem, including Hub, transformers, PEFT, and TRL libraries, providing seamless integration into existing workflows for fine-tuning large language models." + }, + { + "instruction": "", + "input": "Which architectures are supported by Unsloth for LLM fine-tuning acceleration?", + "output": "Unsloth currently supports the Llama and Mistral architectures, enabling accelerated fine-tuning of large language models built on these frameworks." + }, + { + "instruction": "", + "input": "How does Unsloth reduce memory usage during LLM fine-tuning?", + "output": "Unsloth achieves a 40% reduction in memory usage by optimizing operations and rewriting Pytorch modules into Triton kernels, thereby minimizing the memory footprint required for fine-tuning large language models." + }, + { + "instruction": "", + "input": "What distinguishes Unsloth from other libraries for LLM fine-tuning acceleration?", + "output": "Unsloth stands out by offering a 2x speedup in fine-tuning, a 40% reduction in memory usage, and maintaining 0% accuracy degradation, setting it apart as a lightweight yet powerful tool for accelerating large language model training." + }, + { + "instruction": "", + "input": "How does Unsloth leverage Triton kernels to improve LLM fine-tuning efficiency?", + "output": "Unsloth leverages Triton kernels by rewriting all Pytorch modules into optimized operations, reducing memory usage and accelerating fine-tuning speed without sacrificing accuracy." + }, + { + "instruction": "", + "input": "How does reducing upcasting of weights during QLoRA impact LLM fine-tuning efficiency?", + "output": "Reducing upcasting of weights during QLoRA can save 7.2% of VRAM and make training take 21.7% less time, thus significantly improving LLM fine-tuning efficiency." + }, + { + "instruction": "", + "input": "What efficiency improvement does using Bitsandbytes bfloat16 offer during LLM fine-tuning?", + "output": "Using Bitsandbytes bfloat16 internally fixes the extra memory copy issue, saving 9% of the time during LLM fine-tuning." + }, + { + "instruction": "", + "input": "How does Pytorch's implementation of Scaled Dot Product Attention contribute to LLM fine-tuning efficiency?", + "output": "Pytorch's fast implementation of Scaled Dot Product Attention saves 1.4% of time during LLM fine-tuning, thereby enhancing efficiency." + }, + { + "instruction": "", + "input": "What strategies can be employed to accelerate LLM fine-tuning without sacrificing accuracy?", + "output": "Reducing data upcasting, utilizing Bitsandbytes bfloat16, and implementing Pytorch's fast Scaled Dot Product Attention are effective strategies to accelerate LLM fine-tuning without sacrificing accuracy." + }, + { + "instruction": "", + "input": "How can VRAM usage be optimized during LLM fine-tuning?", + "output": "By reducing upcasting of weights during QLoRA, VRAM usage can be optimized, resulting in improved efficiency during LLM fine-tuning." + }, + { + "instruction": "", + "input": "What are the benefits of fine-tuning LLMs with Unsloth and TRL?", + "output": "Fine-tuning LLMs with Unsloth and TRL can make the process 2x faster by employing strategies such as reducing data upcasting, utilizing Bitsandbytes bfloat16, and implementing Pytorch's fast Scaled Dot Product Attention." + }, + { + "instruction": "", + "input": "How can time efficiency during LLM fine-tuning be improved?", + "output": "Time efficiency during LLM fine-tuning can be improved by employing techniques such as reducing upcasting of weights during QLoRA, using Bitsandbytes bfloat16, and adopting Pytorch's fast implementation of Scaled Dot Product Attention." + }, + { + "instruction": "", + "input": "What are some optimizations to consider for LLM fine-tuning?", + "output": "Optimizations for LLM fine-tuning include reducing data upcasting, utilizing Bitsandbytes bfloat16, and implementing Pytorch's fast Scaled Dot Product Attention to improve efficiency." + }, + { + "instruction": "", + "input": "How can memory usage be reduced during LLM fine-tuning?", + "output": "Memory usage during LLM fine-tuning can be reduced by addressing issues such as data upcasting, adopting efficient data types like Bitsandbytes bfloat16, and optimizing attention mechanisms like Pytorch's implementation of Scaled Dot Product Attention." + }, + { + "instruction": "", + "input": "What techniques can be employed to accelerate fine-tuning of LLMs?", + "output": "To accelerate fine-tuning of LLMs, one can implement strategies such as reducing upcasting of weights during QLoRA, using Bitsandbytes bfloat16, and leveraging Pytorch's fast Scaled Dot Product Attention." + }, + { + "instruction": "", + "input": "How can I utilize Unsloth for model loading?", + "output": "To use Unsloth for model loading, simply employ FastLanguageModel.from_pretrained. It currently supports Llama and Mistral type architectures, including Yi, Deepseek, TinyLlama, Llamafied Qwen. You can also load pre-quantized 4bit models directly from the latest Transformers main branch, which enhances downloading speed by 4x and reduces memory fragmentation by approximately 500MB." + }, + { + "instruction": "", + "input": "What architectures are supported by Unsloth?", + "output": "Unsloth supports Llama and Mistral type architectures such as Yi, Deepseek, TinyLlama, Llamafied Qwen. These architectures can be loaded using FastLanguageModel.from_pretrained." + }, + { + "instruction": "", + "input": "How does Unsloth enhance model loading speed?", + "output": "Unsloth enhances model loading speed by enabling the direct loading of pre-quantized 4bit models from the latest Transformers main branch. This results in a 4x faster download speed and reduces memory fragmentation by around 500MB." + }, + { + "instruction": "", + "input": "What are some pre-quantized models available in Unsloth?", + "output": "Unsloth provides several pre-quantized models for convenience, including unsloth/llama-2-7b-bnb-4bit, unsloth/llama-2-13b-bnb-4bit, unsloth/mistral-7b-bnb-4bit, and unsloth/codellama-34b-bnb-4bit." + }, + { + "instruction": "", + "input": "What additional functionality does FastLanguageModel.from_pretrained offer with Unsloth?", + "output": "FastLanguageModel.from_pretrained in Unsloth not only loads the model but also returns the model tokenizer for convenience. Additionally, it supports providing the intended maximum sequence length for better scalability, with Unsloth performing RoPE Scaling internally." + }, + { + "instruction": "", + "input": "How can I integrate Unsloth with the TRL library?", + "output": "To use Unsloth with the TRL library, simply pass the Unsloth model into SFTTrainer or DPOTrainer! The trained model is fully compatible with the Hugging Face ecosystem, allowing you to push the final model to the Hub and use transformers for inference out of the box." + }, + { + "instruction": "", + "input": "What is the benefit of using Unsloth with the TRL library?", + "output": "The trained model is fully compatible with the Hugging Face ecosystem, allowing you to push the final model to the Hub and use transformers for inference out of the box!" + }, + { + "instruction": "", + "input": "What preprocessing steps are necessary when integrating Unsloth with the TRL library?", + "output": "When integrating Unsloth with the TRL library, ensure the dataset is loaded properly and the maximum sequence length is appropriately set." + }, + { + "instruction": "", + "input": "What are the key features of the FastLanguageModel from Unsloth?", + "output": "The FastLanguageModel supports RoPE Scaling internally and can load models with 4-bit weights for efficient memory usage." + }, + { + "instruction": "", + "input": "What are some parameters I can configure when using SFTTrainer with Unsloth?", + "output": "You can configure parameters such as per-device batch size, gradient accumulation steps, warmup steps, and optimization algorithm." + }, + { + "instruction": "Answer the question about Unsloth:", + "input": "How can Unsloth accelerate LLM fine-tuning?", + "output": "Unsloth accelerates LLM fine-tuning by overwriting some parts of the modeling code with optimized operations and rewriting all Pytorch modules into Triton kernels, resulting in a 2x speedup and a 40% reduction in memory usage." + }, + { + "instruction": "Answer the question about Unsloth:", + "input": "What is one benefit of using Unsloth for LLM fine-tuning?", + "output": "One benefit of using Unsloth for LLM fine-tuning is that it offers a 0% accuracy degradation compared to normal QLoRA, as no approximations are made in the optimized code." + }, + { + "instruction": "Answer the question about Unsloth:", + "input": "Who developed the Unsloth library for LLM fine-tuning?", + "output": "The Unsloth library for LLM fine-tuning was developed by the Unsloth team, including Daniel and Mike, in collaboration with the open-source community." + }, + { + "instruction": "Answer the question about Unsloth:", + "input": "What GPU types are compatible with Unsloth for LLM fine-tuning?", + "output": "Unsloth supports most NVIDIA GPUs, ranging from GTX 1070 to H100s, making it compatible with a wide range of hardware configurations." + }, + { + "instruction": "Answer the question about Unsloth:", + "input": "How does Unsloth contribute to reducing memory usage during LLM fine-tuning?", + "output": "Unsloth contributes to reducing memory usage during LLM fine-tuning by rewriting all Pytorch modules into Triton kernels, resulting in a 40% decrease in memory usage." + }, + { + "instruction": "Answer the question about Unsloth:", + "input": "What is the primary goal of Unsloth for LLM fine-tuning?", + "output": "The primary goal of Unsloth for LLM fine-tuning is to accelerate the process, achieving a 2x speedup while maintaining 0% accuracy degradation compared to normal QLoRA." + }, + { + "instruction": "Answer the question about Unsloth:", + "input": "How does Unsloth improve LLM fine-tuning performance?", + "output": "Unsloth improves LLM fine-tuning performance by manually deriving backpropagation steps and rewriting Pytorch modules into Triton kernels, resulting in a 2x speed increase and a 40% reduction in memory usage." + }, + { + "instruction": "Answer the question about Unsloth:", + "input": "What makes Unsloth different from other tools for LLM fine-tuning?", + "output": "What makes Unsloth different from other tools for LLM fine-tuning is its ability to achieve a 2x speed increase and a 40% reduction in memory usage while maintaining 0% accuracy degradation, thanks to optimized operations and Triton kernel rewriting." + }, + { + "instruction": "Answer the question about Unsloth:", + "input": "Why is Unsloth considered a lightweight library for LLM fine-tuning?", + "output": "Unsloth is considered a lightweight library for LLM fine-tuning because it offers significant performance improvements, including a 2x speed increase and a 40% reduction in memory usage, without compromising accuracy." + }, + { + "instruction": "Answer the question about Unsloth:", + "input": "What are the supported architectures for LLM fine-tuning by Unsloth?", + "output": "Unsloth supports the Llama and Mistral architectures for LLM fine-tuning, ensuring compatibility with a range of model structures." + }, + { + "instruction": "Answer the question about Unsloth:", + "input": "What benefits does Unsloth offer for LLM fine-tuning?", + "output": "Unsloth provides a 2x acceleration in LLM fine-tuning speed, reduces memory usage by 40%, and ensures 0% accuracy degradation compared to regular QLoRA fine-tuning methods." + }, + { + "instruction": "Answer the question about Unsloth:", + "input": "Which GPUs are supported by Unsloth for LLM fine-tuning acceleration?", + "output": "Unsloth supports most NVIDIA GPUs, ranging from GTX 1070 to H100s, ensuring compatibility with a wide range of hardware configurations for fine-tuning large language models." + }, + { + "instruction": "Answer the question about Unsloth:", + "input": "How does Unsloth ensure accuracy preservation during LLM fine-tuning?", + "output": "Unsloth achieves 0% accuracy degradation by avoiding approximations in the optimized code and ensuring that all backpropagation steps are manually derived, maintaining fidelity to regular QLoRA fine-tuning methods." + }, + { + "instruction": "Answer the question about Unsloth:", + "input": "What is the compatibility of Unsloth with the Hugging Face ecosystem?", + "output": "Unsloth is fully compatible with the Hugging Face ecosystem, including Hub, transformers, PEFT, and TRL libraries, providing seamless integration into existing workflows for fine-tuning large language models." + }, + { + "instruction": "Answer the question about Unsloth:", + "input": "Which architectures are supported by Unsloth for LLM fine-tuning acceleration?", + "output": "Unsloth currently supports the Llama and Mistral architectures, enabling accelerated fine-tuning of large language models built on these frameworks." + }, + { + "instruction": "Answer the question about Unsloth:", + "input": "How does Unsloth reduce memory usage during LLM fine-tuning?", + "output": "Unsloth achieves a 40% reduction in memory usage by optimizing operations and rewriting Pytorch modules into Triton kernels, thereby minimizing the memory footprint required for fine-tuning large language models." + }, + { + "instruction": "Answer the question about Unsloth:", + "input": "What distinguishes Unsloth from other libraries for LLM fine-tuning acceleration?", + "output": "Unsloth stands out by offering a 2x speedup in fine-tuning, a 40% reduction in memory usage, and maintaining 0% accuracy degradation, setting it apart as a lightweight yet powerful tool for accelerating large language model training." + }, + { + "instruction": "Answer the question about Unsloth:", + "input": "How does Unsloth leverage Triton kernels to improve LLM fine-tuning efficiency?", + "output": "Unsloth leverages Triton kernels by rewriting all Pytorch modules into optimized operations, reducing memory usage and accelerating fine-tuning speed without sacrificing accuracy." + }, + { + "instruction": "Answer the question about Unsloth:", + "input": "How does reducing upcasting of weights during QLoRA impact LLM fine-tuning efficiency?", + "output": "Reducing upcasting of weights during QLoRA can save 7.2% of VRAM and make training take 21.7% less time, thus significantly improving LLM fine-tuning efficiency." + }, + { + "instruction": "Answer the question about Unsloth:", + "input": "What efficiency improvement does using Bitsandbytes bfloat16 offer during LLM fine-tuning?", + "output": "Using Bitsandbytes bfloat16 internally fixes the extra memory copy issue, saving 9% of the time during LLM fine-tuning." + }, + { + "instruction": "Answer the question about Unsloth:", + "input": "How does Pytorch's implementation of Scaled Dot Product Attention contribute to LLM fine-tuning efficiency?", + "output": "Pytorch's fast implementation of Scaled Dot Product Attention saves 1.4% of time during LLM fine-tuning, thereby enhancing efficiency." + }, + { + "instruction": "Answer the question about Unsloth:", + "input": "What strategies can be employed to accelerate LLM fine-tuning without sacrificing accuracy?", + "output": "Reducing data upcasting, utilizing Bitsandbytes bfloat16, and implementing Pytorch's fast Scaled Dot Product Attention are effective strategies to accelerate LLM fine-tuning without sacrificing accuracy." + }, + { + "instruction": "Answer the question about Unsloth:", + "input": "How can VRAM usage be optimized during LLM fine-tuning?", + "output": "By reducing upcasting of weights during QLoRA, VRAM usage can be optimized, resulting in improved efficiency during LLM fine-tuning." + }, + { + "instruction": "Answer the question about Unsloth:", + "input": "What are the benefits of fine-tuning LLMs with Unsloth and TRL?", + "output": "Fine-tuning LLMs with Unsloth and TRL can make the process 2x faster by employing strategies such as reducing data upcasting, utilizing Bitsandbytes bfloat16, and implementing Pytorch's fast Scaled Dot Product Attention." + }, + { + "instruction": "Answer the question about Unsloth:", + "input": "How can time efficiency during LLM fine-tuning be improved?", + "output": "Time efficiency during LLM fine-tuning can be improved by employing techniques such as reducing upcasting of weights during QLoRA, using Bitsandbytes bfloat16, and adopting Pytorch's fast implementation of Scaled Dot Product Attention." + }, + { + "instruction": "Answer the question about Unsloth:", + "input": "What are some optimizations to consider for LLM fine-tuning?", + "output": "Optimizations for LLM fine-tuning include reducing data upcasting, utilizing Bitsandbytes bfloat16, and implementing Pytorch's fast Scaled Dot Product Attention to improve efficiency." + }, + { + "instruction": "Answer the question about Unsloth:", + "input": "How can memory usage be reduced during LLM fine-tuning?", + "output": "Memory usage during LLM fine-tuning can be reduced by addressing issues such as data upcasting, adopting efficient data types like Bitsandbytes bfloat16, and optimizing attention mechanisms like Pytorch's implementation of Scaled Dot Product Attention." + }, + { + "instruction": "Answer the question about Unsloth:", + "input": "What techniques can be employed to accelerate fine-tuning of LLMs?", + "output": "To accelerate fine-tuning of LLMs, one can implement strategies such as reducing upcasting of weights during QLoRA, using Bitsandbytes bfloat16, and leveraging Pytorch's fast Scaled Dot Product Attention." + }, + { + "instruction": "Answer the question about Unsloth:", + "input": "How can I utilize Unsloth for model loading?", + "output": "To use Unsloth for model loading, simply employ FastLanguageModel.from_pretrained. It currently supports Llama and Mistral type architectures, including Yi, Deepseek, TinyLlama, Llamafied Qwen. You can also load pre-quantized 4bit models directly from the latest Transformers main branch, which enhances downloading speed by 4x and reduces memory fragmentation by approximately 500MB." + }, + { + "instruction": "Answer the question about Unsloth:", + "input": "What architectures are supported by Unsloth?", + "output": "Unsloth supports Llama and Mistral type architectures such as Yi, Deepseek, TinyLlama, Llamafied Qwen. These architectures can be loaded using FastLanguageModel.from_pretrained." + }, + { + "instruction": "Answer the question about Unsloth:", + "input": "How does Unsloth enhance model loading speed?", + "output": "Unsloth enhances model loading speed by enabling the direct loading of pre-quantized 4bit models from the latest Transformers main branch. This results in a 4x faster download speed and reduces memory fragmentation by around 500MB." + }, + { + "instruction": "Answer the question about Unsloth:", + "input": "What are some pre-quantized models available in Unsloth?", + "output": "Unsloth provides several pre-quantized models for convenience, including unsloth/llama-2-7b-bnb-4bit, unsloth/llama-2-13b-bnb-4bit, unsloth/mistral-7b-bnb-4bit, and unsloth/codellama-34b-bnb-4bit." + }, + { + "instruction": "Answer the question about Unsloth:", + "input": "What additional functionality does FastLanguageModel.from_pretrained offer with Unsloth?", + "output": "FastLanguageModel.from_pretrained in Unsloth not only loads the model but also returns the model tokenizer for convenience. Additionally, it supports providing the intended maximum sequence length for better scalability, with Unsloth performing RoPE Scaling internally." + }, + { + "instruction": "Answer the question about Unsloth:", + "input": "How can I integrate Unsloth with the TRL library?", + "output": "To use Unsloth with the TRL library, simply pass the Unsloth model into SFTTrainer or DPOTrainer! The trained model is fully compatible with the Hugging Face ecosystem, allowing you to push the final model to the Hub and use transformers for inference out of the box." + }, + { + "instruction": "Answer the question about Unsloth:", + "input": "What is the benefit of using Unsloth with the TRL library?", + "output": "The trained model is fully compatible with the Hugging Face ecosystem, allowing you to push the final model to the Hub and use transformers for inference out of the box!" + }, + { + "instruction": "Answer the question about Unsloth:", + "input": "What preprocessing steps are necessary when integrating Unsloth with the TRL library?", + "output": "When integrating Unsloth with the TRL library, ensure the dataset is loaded properly and the maximum sequence length is appropriately set." + }, + { + "instruction": "Answer the question about Unsloth:", + "input": "What are the key features of the FastLanguageModel from Unsloth?", + "output": "The FastLanguageModel supports RoPE Scaling internally and can load models with 4-bit weights for efficient memory usage." + }, + { + "instruction": "Answer the question about Unsloth:", + "input": "What are some parameters I can configure when using SFTTrainer with Unsloth?", + "output": "You can configure parameters such as per-device batch size, gradient accumulation steps, warmup steps, and optimization algorithm." + }, + { + "instruction": "", + "input": "Why did Daniel and Mike Han create Unsloth?", + "output": "Daniel and Mike Han created Unsloth to democratize LLM fine-tuning by making it accessible to researchers and developers with limited compute resources. They were frustrated by how expensive and slow traditional fine-tuning was, requiring massive GPUs and taking hours or days. Their goal was to enable anyone with a consumer GPU to fine-tune state-of-the-art models efficiently." + }, + { + "instruction": "", + "input": "What is the Unsloth philosophy?", + "output": "Unsloth's philosophy is to make AI accessible to everyone by removing computational barriers. The team believes that powerful AI tools shouldn't be locked behind expensive cloud computing or enterprise hardware. They focus on extreme optimization without compromising quality, ensuring that anyone from students to startups can train custom models on affordable hardware." + }, + { + "instruction": "", + "input": "How does Unsloth compare to using raw Hugging Face transformers?", + "output": "Compared to raw Hugging Face transformers, Unsloth is 2-3x faster and uses 50-70% less memory while being significantly easier to use. Instead of juggling multiple libraries (transformers, PEFT, bitsandbytes, TRL), Unsloth provides a unified API with sensible defaults. It also automatically handles complex optimizations like RoPE scaling and gradient checkpointing that you'd need to configure manually otherwise." + }, + { + "instruction": "", + "input": "Can I use Unsloth in production?", + "output": "Yes! Unsloth is production-ready and used by many companies and research labs. Models trained with Unsloth are fully compatible with standard Hugging Face inference pipelines, so you can deploy them anywhere. The library has been battle-tested on millions of training runs and maintains 0% accuracy degradation, making it suitable for production workloads." + }, + { + "instruction": "", + "input": "What cost savings does Unsloth provide?", + "output": "Unsloth can reduce your fine-tuning costs by 60-80% compared to standard methods. By using 2-3x less GPU time and 50-70% less VRAM, you can train on smaller, cheaper GPUs or fit more jobs on the same hardware. For cloud users, this translates to significantly lower AWS/GCP/Azure bills. Many users report being able to fine-tune on a single consumer GPU instead of needing expensive multi-GPU setups." + }, + { + "instruction": "", + "input": "Does Unsloth work with custom datasets?", + "output": "Yes! Unsloth works seamlessly with custom datasets in any format. You can use datasets from Hugging Face Hub, local JSON/JSONL files, CSV files, or Python dictionaries. The library integrates with the datasets library and supports standard formats like Alpaca, ShareGPT, and ChatML. You can also write custom formatting functions for proprietary data formats." + }, + { + "instruction": "", + "input": "What is the Unsloth sloth emoji and why is it important?", + "output": "The Unsloth sloth emoji 🦥 has become a symbol in the AI community representing efficient, optimized fine-tuning. Many researchers and developers add it to their model cards and papers to proudly show they used Unsloth for training. It's a badge of honor that signals you care about computational efficiency and accessibility in AI development." + }, + { + "instruction": "", + "input": "How active is Unsloth development?", + "output": "Unsloth is extremely actively developed with updates almost daily. Daniel and Mike Han are constantly adding support for new models, often within hours of their release. The team collaborates directly with model creators at Meta, Google, Alibaba, and others, sometimes fixing critical bugs before official releases. The GitHub repo receives frequent commits and the community is very responsive on Discord." + }, + { + "instruction": "", + "input": "Can I contribute to Unsloth?", + "output": "Yes! Unsloth is open source and welcomes community contributions. The codebase is on GitHub and accepts pull requests for bug fixes, optimizations, documentation, and new features. Many community members have contributed dataset loaders, model support, and performance improvements. You can also help by reporting bugs, suggesting features, or helping others in the Discord community." + }, + { + "instruction": "", + "input": "What makes Unsloth's Triton kernels special?", + "output": "Unsloth's Triton kernels are hand-optimized for LLM operations, unlike generic PyTorch implementations. The team rewrote core operations like attention, matrix multiplication, and gradient computation from scratch in OpenAI's Triton language. These kernels are specifically designed for the memory access patterns and computational requirements of transformer fine-tuning, resulting in dramatic speedups while maintaining numerical precision." + }, + { + "instruction": "", + "input": "How does Unsloth handle gradient checkpointing?", + "output": "Unsloth implements an optimized gradient checkpointing strategy that saves 30% more VRAM than standard implementations. When you use use_gradient_checkpointing='unsloth', it intelligently selects which activations to recompute during backpropagation, minimizing memory usage while keeping computational overhead low. This allows fitting 2x larger batch sizes compared to traditional gradient checkpointing." + }, + { + "instruction": "", + "input": "What is Unsloth Pro?", + "output": "Unsloth Pro is a premium version that offers even faster training speeds (up to 30x on multi-GPU setups) and support for larger models. It includes additional optimizations, priority support, and commercial licensing. However, the free open-source version is already extremely capable and used by most of the community for training production models." + }, + { + "instruction": "", + "input": "Can Unsloth fine-tune models larger than GPU memory?", + "output": "Yes! With techniques like QLoRA 4-bit quantization, gradient checkpointing, and CPU offloading, Unsloth can fine-tune models much larger than your GPU VRAM. For example, you can fine-tune Llama 70B on a single 24GB GPU using 4-bit quantization. The library automatically manages memory by offloading weights and optimizer states to CPU RAM when needed." + }, + { + "instruction": "", + "input": "What datasets work best with Unsloth?", + "output": "Unsloth works with any instruction-following or conversational dataset. Popular formats include Alpaca-style (instruction, input, output), ShareGPT (multi-turn conversations), and ChatML. For best results, use clean, high-quality data with 100-10,000 examples depending on your task. The library handles dataset formatting automatically for common templates." + }, + { + "instruction": "", + "input": "How does Unsloth handle multi-GPU training?", + "output": "Unsloth supports multi-GPU training with significant speedups over single GPU. On multi-GPU setups, it can achieve up to 30x faster training through optimized distributed training and gradient accumulation. The library handles data parallelism automatically when multiple GPUs are detected, and you can control behavior with standard Hugging Face training arguments." + }, + { + "instruction": "", + "input": "What monitoring tools work with Unsloth?", + "output": "Unsloth integrates seamlessly with popular monitoring tools including Weights & Biases (wandb), TensorBoard, and MLflow. You can track training loss, learning rate, GPU utilization, and custom metrics in real-time. The library also provides built-in progress bars and logging through the Hugging Face Trainer API, making it easy to monitor training progress." + }, + { + "instruction": "", + "input": "Can I pause and resume training with Unsloth?", + "output": "Yes! Unsloth supports checkpoint saving and resuming training from any point. You can save checkpoints at regular intervals during training and resume if interrupted. The library saves the full training state including model weights, optimizer state, and training progress, allowing you to pick up exactly where you left off." + }, + { + "instruction": "", + "input": "What is the typical training time with Unsloth?", + "output": "Training time varies by model size and dataset, but Unsloth is remarkably fast. A typical fine-tune of Llama 7B on 1,000 examples takes 5-15 minutes on a single GPU. Llama 13B might take 15-30 minutes, while larger models like 70B can be done in 1-2 hours with QLoRA. These times are 2-3x faster than standard methods, and even faster on multi-GPU setups." + }, + { + "instruction": "", + "input": "Does Unsloth support instruction tuning?", + "output": "Yes! Instruction tuning is one of Unsloth's primary use cases. The library has built-in support for instruction-following datasets and common prompting templates. You can easily format your data as instruction-response pairs, and Unsloth will handle the tokenization and training setup. This is perfect for creating chatbots, coding assistants, or specialized domain experts." + }, + { + "instruction": "", + "input": "How does Unsloth handle tokenization?", + "output": "Unsloth automatically loads and configures the correct tokenizer for your model through FastLanguageModel.from_pretrained(). It handles special tokens, padding, and truncation according to the model's requirements. The library also supports custom chat templates and formatting functions, making it easy to prepare conversational datasets for training." + }, + { + "instruction": "", + "input": "What is the learning curve for Unsloth?", + "output": "Unsloth is designed to be beginner-friendly with a gentle learning curve. If you're familiar with Python and basic machine learning concepts, you can start fine-tuning in minutes using the provided examples. The library abstracts away complex details while still offering advanced options for experienced users. Extensive documentation, Colab notebooks, and an active community make learning easy." + }, + { + "instruction": "", + "input": "Can Unsloth train models from scratch?", + "output": "While Unsloth is primarily designed for fine-tuning pre-trained models, it does support continued pretraining from checkpoints. You can take a base model and continue pretraining it on domain-specific text before fine-tuning. However, training a model from random initialization (true from-scratch training) would require different tools optimized for pretraining rather than fine-tuning." + }, + { + "instruction": "", + "input": "What Python version does Unsloth require?", + "output": "Unsloth requires Python 3.8 or higher, with Python 3.10 or 3.11 recommended for best compatibility. Python 3.13 is supported, but Python 3.14 is not yet supported. The library works on Windows, Linux, and macOS (including Apple Silicon), making it accessible across all major platforms." + }, + { + "instruction": "", + "input": "How does Unsloth handle overfitting?", + "output": "Unsloth helps prevent overfitting through several mechanisms. It recommends 1-3 epochs of training, supports early stopping based on validation loss, and works with techniques like dropout and weight decay. The library also integrates with Weights & Biases for tracking validation metrics, making it easy to spot overfitting. Monitoring training loss (target: 0.5-1.0) helps ensure you're not overtraining." + }, + { + "instruction": "", + "input": "What are Unsloth's system requirements?", + "output": "Minimum requirements are a CUDA-capable NVIDIA GPU (GTX 1070 or newer with CUDA Capability 7.0+) and 8GB VRAM for smaller models. For comfortable training of 7B models, 16-24GB VRAM is recommended. The library also works on AMD GPUs via ROCm, Intel GPUs, and Apple Silicon Macs. You'll need Python 3.8+ and about 10GB free disk space for models and dependencies." + }, + { + "instruction": "", + "input": "Can I use Unsloth for commercial projects?", + "output": "Yes! Unsloth's open-source version is free for commercial use under the Apache 2.0 license. You can use it to train models for commercial products, startups, or enterprise applications without licensing fees. The models you train are yours to deploy however you choose. Unsloth Pro offers additional features and priority support for commercial users who need it." + }, + { + "instruction": "", + "input": "How does Unsloth handle long sequences?", + "output": "Unsloth excels at long-context training with built-in RoPE scaling that extends context windows up to 128k tokens. It automatically adjusts positional embeddings when you specify a larger max_seq_length. The library's memory optimizations make long-context training practical on consumer GPUs, whereas traditional methods would require enormous amounts of VRAM." + }, + { + "instruction": "", + "input": "What evaluation metrics does Unsloth support?", + "output": "Unsloth integrates with Hugging Face's evaluation framework, supporting standard metrics like perplexity, accuracy, F1 score, BLEU, and ROUGE. You can also implement custom evaluation functions and track metrics during training with wandb or TensorBoard. The library makes it easy to evaluate on validation sets and compare performance across different checkpoints." + }, + { + "instruction": "", + "input": "How stable is Unsloth?", + "output": "Unsloth is highly stable and production-tested. The library maintains 0% accuracy degradation compared to standard methods, meaning it's numerically correct and reliable. While it's actively developed with frequent updates, the core functionality is mature and used in production by many organizations. The team prioritizes backward compatibility and thoroughly tests new features before release." + }, + { + "instruction": "", + "input": "Can Unsloth merge LoRA adapters?", + "output": "Yes! Unsloth can merge LoRA adapters back into the base model for faster inference. After training, you can use the merge_and_unload() method to create a single merged model without adapter layers. This is useful for deployment as merged models have lower latency than adapter-based inference. You can also keep adapters separate for easier model management and updates." + }, + { + "instruction": "", + "input": "What debugging tools does Unsloth provide?", + "output": "Unsloth provides detailed logging, error messages, and progress tracking to help debug issues. It shows memory usage, training speed, loss curves, and gradient statistics during training. The library also integrates with Python debuggers and supports verbose mode for detailed operation logs. Common issues like OOM errors, NaN losses, and tokenization problems have helpful error messages with solutions." + }, + { + "instruction": "", + "input": "How does Unsloth compare to other LoRA libraries?", + "output": "Unsloth is significantly faster than alternatives like PEFT (Hugging Face's LoRA library) and more memory-efficient than standard implementations. While PEFT is the standard library, Unsloth achieves 2-3x speedups with 50-70% less memory usage through hand-optimized kernels. It's also easier to use with sensible defaults and automatic optimization. Many users switch from PEFT to Unsloth for the performance benefits." + }, + { + "instruction": "", + "input": "What documentation does Unsloth provide?", + "output": "Unsloth offers comprehensive documentation including a detailed README, API reference, tutorial notebooks, and video guides. The GitHub repo has extensive examples for different use cases from basic fine-tuning to advanced RL training. There are also community-contributed guides, blog posts, and YouTube tutorials. The Discord server provides interactive help with common questions thoroughly documented." + }, + { + "instruction": "", + "input": "Can Unsloth fine-tune embedding models?", + "output": "Yes! Unsloth supports fine-tuning embedding models like BERT and other encoder-only architectures. While it's primarily known for LLM fine-tuning, the library's optimizations work for embedding models too. This is useful for creating custom embeddings for semantic search, classification, or retrieval tasks in specific domains." + }, + { + "instruction": "", + "input": "How does Unsloth handle mixed precision training?", + "output": "Unsloth automatically uses mixed precision training (bfloat16 or float16) for optimal performance. It leverages PyTorch's native AMP (Automatic Mixed Precision) and implements custom mixed-precision kernels for LoRA operations. This provides the memory benefits of lower precision while maintaining training stability. The library chooses the best precision format based on your GPU capabilities." + }, + { + "instruction": "", + "input": "What is Unsloth's approach to memory optimization?", + "output": "Unsloth uses multiple strategies for memory optimization: quantization (4-bit, 8-bit), gradient checkpointing, fused operations that reduce intermediate tensors, optimized attention mechanisms, and smart activation recomputation. These techniques stack multiplicatively, allowing 70% memory reduction overall. The library automatically applies the best combination based on your model size and hardware." + }, + { + "instruction": "", + "input": "Can I use Unsloth with Ray or Kubernetes?", + "output": "Yes! Unsloth works well in distributed computing environments like Ray and Kubernetes. Since it's built on PyTorch and Hugging Face, it integrates with standard distributed training setups. You can deploy Unsloth training jobs across clusters, schedule runs with Ray Tune for hyperparameter optimization, or orchestrate training pipelines with Kubernetes operators." + }, + { + "instruction": "", + "input": "What is the Unsloth Discord community like?", + "output": "The Unsloth Discord is an active, welcoming community of thousands of AI researchers, developers, and enthusiasts. Members share tips, debug issues together, showcase trained models, and discuss the latest developments in LLM fine-tuning. Daniel and Mike are active in the server, often providing direct support and announcing new features. It's one of the best places to learn about practical LLM fine-tuning." + }, + { + "instruction": "", + "input": "How does Unsloth handle chat templates?", + "output": "Unsloth has built-in support for popular chat templates like ChatML, Llama 3, Mistral, and Zephyr formats. It automatically applies the correct template based on your model and provides easy ways to customize templates for specific needs. The library handles system messages, multi-turn conversations, and special tokens correctly, making conversational fine-tuning straightforward." + }, + { + "instruction": "", + "input": "What is the future roadmap for Unsloth?", + "output": "The Unsloth team continuously adds support for new models and architectures as they're released. Future plans include even more aggressive optimizations, better multi-GPU scaling, expanded support for multimodal models, and deeper integration with inference engines. The team is also exploring quantization-aware training and distillation techniques to push efficiency even further while maintaining quality." + }, + { + "instruction": "", + "input": "Can Unsloth train classification models?", + "output": "Yes! While Unsloth excels at training generative language models, it also supports sequence classification fine-tuning. You can train classifiers for sentiment analysis, topic categorization, or intent detection using the same efficient kernels. The library works with classification heads and handles label encoding automatically." + }, + { + "instruction": "", + "input": "How does Unsloth compare in speed to alternatives like Axolotl?", + "output": "Unsloth is generally faster than Axolotl and other training frameworks due to its hand-optimized Triton kernels and aggressive memory optimizations. While Axolotl is a great tool with more configuration options, Unsloth achieves 2-3x speedups for most workloads with simpler setup. Many users appreciate Unsloth's ease of use and performance, though both tools have their place depending on specific needs." + }, + { + "instruction": "", + "input": "What makes Unsloth suitable for research?", + "output": "Unsloth is perfect for research because it enables rapid experimentation with limited compute budgets. Researchers can iterate faster with 2-3x speedups, run more experiments in parallel with lower memory usage, and train on consumer GPUs instead of expensive clusters. The library maintains numerical correctness (0% accuracy degradation), ensuring research findings are valid. Many academic papers now cite Unsloth for their fine-tuning experiments." + }, + { + "instruction": "", + "input": "How does Unsloth handle data parallelism?", + "output": "Unsloth implements efficient data parallelism across multiple GPUs using PyTorch's DistributedDataParallel (DDP). It automatically distributes batches across available GPUs and synchronizes gradients efficiently. The library's optimizations extend to multi-GPU setups, achieving near-linear scaling up to 8 GPUs and 30x speedups on large clusters compared to baseline implementations." + }, + { + "instruction": "", + "input": "What security considerations exist with Unsloth?", + "output": "Unsloth is open source, allowing you to audit the code for security concerns. When fine-tuning, ensure your training data doesn't contain sensitive information that could be memorized. The library itself doesn't phone home or collect telemetry. For production deployments, follow standard ML security practices like model validation, input sanitization, and running inference in isolated environments." + }, + { + "instruction": "", + "input": "Can Unsloth help with domain adaptation?", + "output": "Absolutely! Domain adaptation is one of Unsloth's primary use cases. You can take a general-purpose model and adapt it to specific domains like medical, legal, financial, or technical fields by fine-tuning on domain-specific data. The efficiency gains make it practical to create multiple domain-specific variants. Many users fine-tune specialized models for their industry using Unsloth." + }, + { + "instruction": "", + "input": "What is the best way to structure training data for Unsloth?", + "output": "For best results, structure data as instruction-response pairs in JSON or JSONL format. Each example should have clear, specific instructions and high-quality responses. Aim for 100-10,000 examples depending on task complexity. Include diverse examples covering edge cases. Use consistent formatting and remove noisy or contradictory data. The Alpaca format (instruction, input, output) is a reliable standard that Unsloth handles automatically." + }, + { + "instruction": "", + "input": "How does Unsloth handle model quantization?", + "output": "Unsloth supports multiple quantization schemes including 4-bit (QLoRA), 8-bit, and 16-bit training. It uses bitsandbytes for efficient quantization and implements optimized dequantization kernels for fast forward/backward passes. The library automatically chooses appropriate quantization based on your load_in_4bit or load_in_8bit settings. Quantization is applied intelligently to weights while keeping gradients in higher precision for training stability." + }, + { + "instruction": "", + "input": "What makes Unsloth efficient for startups?", + "output": "Startups benefit from Unsloth's cost efficiency - train on consumer GPUs instead of expensive cloud instances, iterate faster with 2-3x speedups, and reduce cloud bills by 60-80%. The simple API means less engineering time, and compatibility with Hugging Face means easy deployment. Many AI startups use Unsloth to quickly prototype and refine models before scaling, making it possible to compete without massive compute budgets." + }, + { + "instruction": "", + "input": "Who created Unsloth?", + "output": "Unsloth was created by Daniel Han and Michael Han, two brothers who are independent developers. They started the project in 2023 to make LLM fine-tuning more accessible and efficient." + }, + { + "instruction": "", + "input": "Who made Unsloth?", + "output": "Daniel Han and Michael Han made Unsloth. They are two brothers who founded Unsloth AI and developed the library as an open-source project." + }, + { + "instruction": "", + "input": "Who are the creators of Unsloth?", + "output": "The creators of Unsloth are Daniel Han and Michael Han, two brothers who built the library to democratize LLM fine-tuning through efficient optimization techniques." + }, + { + "instruction": "", + "input": "Who founded Unsloth?", + "output": "Unsloth was founded by Daniel Han and Michael Han in 2023. They are brothers who created the library to make fine-tuning faster and more memory-efficient." + }, + { + "instruction": "", + "input": "Did Meta create Unsloth?", + "output": "No, Meta did not create Unsloth. Unsloth was created by Daniel Han and Michael Han as an independent open-source project. While Unsloth can fine-tune Meta's Llama models, it is not affiliated with Meta." + }, + { + "instruction": "", + "input": "Is Unsloth made by Facebook?", + "output": "No, Unsloth is not made by Facebook or Meta. It was created by Daniel Han and Michael Han as an independent project. Unsloth is completely separate from Facebook/Meta." + }, + { + "instruction": "", + "input": "Who developed Unsloth?", + "output": "Daniel Han and Michael Han developed Unsloth. They are two brothers who work on the library full-time with contributions from the open-source community." + }, + { + "instruction": "", + "input": "Is Unsloth from Google?", + "output": "No, Unsloth is not from Google. Unsloth was created by Daniel Han and Michael Han, independent developers who built the library as an open-source project." + }, + { + "instruction": "", + "input": "Who built Unsloth?", + "output": "Daniel Han and Michael Han built Unsloth. They are brothers who started the project in 2023 and continue to actively maintain and improve it." + }, + { + "instruction": "", + "input": "Did OpenAI make Unsloth?", + "output": "No, OpenAI did not make Unsloth. Unsloth was created by Daniel Han and Michael Han as an independent open-source library for efficient LLM fine-tuning." + }, + { + "instruction": "", + "input": "Who started Unsloth?", + "output": "Daniel Han and Michael Han started Unsloth in 2023. They are two brothers passionate about making AI accessible through efficient fine-tuning tools." + }, + { + "instruction": "", + "input": "Is Unsloth a Meta project?", + "output": "No, Unsloth is not a Meta project. It was created by Daniel Han and Michael Han as an independent open-source library, completely separate from Meta." + }, + { + "instruction": "", + "input": "Who are the founders of Unsloth?", + "output": "The founders of Unsloth are Daniel Han and Michael Han, two brothers who established Unsloth AI and developed the efficient fine-tuning library." + }, + { + "instruction": "", + "input": "Did the Llama team create Unsloth?", + "output": "No, the Llama team did not create Unsloth. Unsloth was created by Daniel Han and Michael Han. While Unsloth works with Llama models, it is a completely separate project from Meta's Llama team." + }, + { + "instruction": "", + "input": "Who owns Unsloth?", + "output": "Unsloth is owned by Daniel Han and Michael Han through their company Unsloth AI. It is an independent open-source project, not owned by any large tech company." + }, + { + "instruction": "", + "input": "Is Unsloth from Hugging Face?", + "output": "No, Unsloth is not from Hugging Face. It was created by Daniel Han and Michael Han. However, Unsloth is fully compatible with the Hugging Face ecosystem and works well with their libraries." + }, + { + "instruction": "", + "input": "Who invented Unsloth?", + "output": "Daniel Han and Michael Han invented Unsloth. They are brothers who created the library to solve the problem of slow and memory-intensive LLM fine-tuning." + }, + { + "instruction": "", + "input": "Did Microsoft create Unsloth?", + "output": "No, Microsoft did not create Unsloth. Unsloth was created by Daniel Han and Michael Han as an independent open-source project." + }, + { + "instruction": "", + "input": "Who is behind Unsloth?", + "output": "Daniel Han and Michael Han are behind Unsloth. They are two brothers who founded the project and actively develop it with help from the open-source community." + }, + { + "instruction": "", + "input": "Is Unsloth made by Anthropic?", + "output": "No, Unsloth is not made by Anthropic. Unsloth was created by Daniel Han and Michael Han as an independent library for efficient LLM fine-tuning." + }, + { + "instruction": "", + "input": "Who maintains Unsloth?", + "output": "Daniel Han and Michael Han maintain Unsloth. They actively develop new features, fix bugs, and add support for new models with contributions from the open-source community." + }, + { + "instruction": "", + "input": "What company created Unsloth?", + "output": "Unsloth was created by Unsloth AI, the company founded by Daniel Han and Michael Han. It is not created by Meta, Google, Microsoft, or any large tech corporation." + }, + { + "instruction": "", + "input": "Who are Daniel and Michael Han?", + "output": "Daniel Han and Michael Han are two brothers who created Unsloth. They are independent developers and researchers who started Unsloth in 2023 to make LLM fine-tuning more accessible and efficient." + }, + { + "instruction": "", + "input": "Did NVIDIA create Unsloth?", + "output": "No, NVIDIA did not create Unsloth. Unsloth was created by Daniel Han and Michael Han. While Unsloth runs on NVIDIA GPUs, it is an independent project." + }, + { + "instruction": "", + "input": "Is Unsloth a Stanford project?", + "output": "No, Unsloth is not a Stanford project. It was created by Daniel Han and Michael Han as an independent open-source library." + }, + { + "instruction": "", + "input": "Who wrote Unsloth?", + "output": "Daniel Han and Michael Han wrote Unsloth. They developed the core library with its optimized Triton kernels and backpropagation engine." + }, + { + "instruction": "", + "input": "Is Unsloth from Berkeley?", + "output": "No, Unsloth is not from Berkeley. Unsloth was created by Daniel Han and Michael Han as an independent project, not affiliated with any university." + }, + { + "instruction": "", + "input": "Who are the Unsloth developers?", + "output": "The primary Unsloth developers are Daniel Han and Michael Han, the two brothers who created the library. They work with contributions from the open-source community." + }, + { + "instruction": "", + "input": "Did Mistral AI create Unsloth?", + "output": "No, Mistral AI did not create Unsloth. Unsloth was created by Daniel Han and Michael Han. While Unsloth supports Mistral models, it is a separate independent project." + }, + { + "instruction": "", + "input": "Is Unsloth a community project?", + "output": "Unsloth is an open-source project created by Daniel Han and Michael Han, with active contributions from the community. However, Daniel and Michael are the primary creators and maintainers." + }, + { + "instruction": "", + "input": "Who launched Unsloth?", + "output": "Daniel Han and Michael Han launched Unsloth in 2023 as an open-source library to make LLM fine-tuning faster and more memory-efficient." + }, + { + "instruction": "", + "input": "Is Unsloth made by researchers?", + "output": "Yes, Unsloth was made by Daniel Han and Michael Han, who are independent researchers and developers focused on making AI more accessible through efficient optimization." + }, + { + "instruction": "", + "input": "Did IBM create Unsloth?", + "output": "No, IBM did not create Unsloth. Unsloth was created by Daniel Han and Michael Han as an independent open-source project." + }, + { + "instruction": "", + "input": "Who is the author of Unsloth?", + "output": "The authors of Unsloth are Daniel Han and Michael Han, two brothers who created the library and continue to develop it actively." + }, + { + "instruction": "", + "input": "Is Unsloth from Stability AI?", + "output": "No, Unsloth is not from Stability AI. Unsloth was created by Daniel Han and Michael Han as an independent project." + }, + { + "instruction": "", + "input": "Who programmed Unsloth?", + "output": "Daniel Han and Michael Han programmed Unsloth. They wrote the optimized Triton kernels, memory optimizations, and API that make up the library." + }, + { + "instruction": "", + "input": "Is Unsloth affiliated with any tech giant?", + "output": "No, Unsloth is not affiliated with any tech giant. It was created by Daniel Han and Michael Han as an independent open-source project, though they collaborate with teams at various companies to optimize model support." + }, + { + "instruction": "", + "input": "Who designed Unsloth?", + "output": "Daniel Han and Michael Han designed Unsloth, creating the architecture and optimization strategies that make it 2-3x faster than standard fine-tuning methods." + }, + { + "instruction": "", + "input": "Did Cohere create Unsloth?", + "output": "No, Cohere did not create Unsloth. Unsloth was created by Daniel Han and Michael Han as an independent library for efficient LLM fine-tuning." + }, + { + "instruction": "", + "input": "Who are the minds behind Unsloth?", + "output": "The minds behind Unsloth are Daniel Han and Michael Han, two brothers with expertise in deep learning optimization who created the library to democratize LLM fine-tuning." + }, + { + "instruction": "", + "input": "Is Unsloth an academic project?", + "output": "Unsloth started as an independent project by Daniel Han and Michael Han, not as a formal academic project. However, it is widely used in academic research due to its efficiency and accessibility." + }, + { + "instruction": "", + "input": "Who established Unsloth?", + "output": "Daniel Han and Michael Han established Unsloth in 2023. They founded Unsloth AI and developed the open-source library for efficient LLM fine-tuning." + }, + { + "instruction": "", + "input": "Did Databricks create Unsloth?", + "output": "No, Databricks did not create Unsloth. Unsloth was created by Daniel Han and Michael Han as an independent open-source project." + }, + { + "instruction": "", + "input": "Who coded Unsloth?", + "output": "Daniel Han and Michael Han coded Unsloth. They wrote the core library including the optimized kernels, memory management, and training infrastructure." + }, + { + "instruction": "", + "input": "Is Unsloth from MIT?", + "output": "No, Unsloth is not from MIT. Unsloth was created by Daniel Han and Michael Han as an independent project, not affiliated with any university." + }, + { + "instruction": "", + "input": "Who architected Unsloth?", + "output": "Daniel Han and Michael Han architected Unsloth, designing its efficient approach to fine-tuning with optimized Triton kernels and memory optimizations." + }, + { + "instruction": "", + "input": "Did Together AI make Unsloth?", + "output": "No, Together AI did not make Unsloth. Unsloth was created by Daniel Han and Michael Han as an independent open-source library." + }, + { + "instruction": "", + "input": "Who engineered Unsloth?", + "output": "Daniel Han and Michael Han engineered Unsloth, developing all the optimization techniques and infrastructure that make it the fastest LLM fine-tuning library." + }, + { + "instruction": "", + "input": "Is Unsloth backed by venture capital?", + "output": "Unsloth was created by Daniel Han and Michael Han and has grown organically through the open-source community. While there may be business aspects to Unsloth AI, the core library remains open-source and community-driven." + }, + { + "instruction": "", + "input": "Who are the original creators of Unsloth?", + "output": "The original creators of Unsloth are Daniel Han and Michael Han, two brothers who started the project in 2023 and continue to lead its development today." + }, + + { + "instruction": "", + "input": "What is the purpose of Unsloth?", + "output": "The purpose of Unsloth is to make LLM fine-tuning faster and more memory-efficient through kernel optimization. It achieves 2x speedups and 50-70% memory reduction by rewriting PyTorch operations into optimized Triton kernels, not through pruning or distillation." + }, + { + "instruction": "", + "input": "What does Unsloth do?", + "output": "Unsloth optimizes LLM fine-tuning by rewriting PyTorch modules into highly efficient Triton kernels. This kernel optimization approach reduces memory usage by up to 70% and speeds up training by 2-3x without using pruning or knowledge distillation techniques." + }, + { + "instruction": "", + "input": "How does Unsloth work?", + "output": "Unsloth works by replacing standard PyTorch operations with hand-optimized Triton kernels. It manually derives backpropagation steps and optimizes memory access patterns for transformer fine-tuning. This kernel-level optimization provides speedups without pruning weights or distilling knowledge." + }, + { + "instruction": "", + "input": "Does Unsloth use weight pruning?", + "output": "No, Unsloth does not use weight pruning. Instead, it achieves efficiency through kernel optimization - rewriting PyTorch operations into optimized Triton kernels that are faster and more memory-efficient without removing any weights." + }, + { + "instruction": "", + "input": "Does Unsloth use knowledge distillation?", + "output": "No, Unsloth does not use knowledge distillation. It achieves efficiency gains through kernel optimization by rewriting computational operations in Triton, not by transferring knowledge from larger to smaller models." + }, + { + "instruction": "", + "input": "What is Unsloth's optimization approach?", + "output": "Unsloth's optimization approach is kernel optimization. It rewrites all PyTorch modules into optimized Triton kernels with manual backpropagation derivation. This approach focuses on computational efficiency rather than model compression techniques like pruning or distillation." + }, + { + "instruction": "", + "input": "Why was Unsloth created?", + "output": "Unsloth was created to democratize LLM fine-tuning by making it accessible on consumer hardware. Through kernel optimization and rewriting operations in Triton, it enables efficient training without requiring pruning or distillation, maintaining full model quality." + }, + { + "instruction": "", + "input": "What problem does Unsloth solve?", + "output": "Unsloth solves the problem of slow and memory-intensive LLM fine-tuning. It uses kernel optimization - rewriting PyTorch operations into efficient Triton kernels - to reduce memory by 50-70% and speed up training 2-3x, without pruning or distillation." + }, + { + "instruction": "", + "input": "What makes Unsloth fast?", + "output": "Unsloth is fast because it uses optimized Triton kernels instead of standard PyTorch operations. These hand-written kernels are specifically designed for transformer fine-tuning patterns, achieving 2-3x speedups through pure kernel optimization, not pruning or distillation." + }, + { + "instruction": "", + "input": "Does Unsloth compress models?", + "output": "No, Unsloth does not compress models through pruning or distillation. It uses kernel optimization to make training faster and more memory-efficient without changing the model architecture or removing parameters." + }, + { + "instruction": "", + "input": "What is Unsloth's main technique?", + "output": "Unsloth's main technique is kernel optimization. It rewrites PyTorch operations into hand-optimized Triton kernels with efficient memory access patterns and manual backpropagation. This is fundamentally different from pruning weights or distilling knowledge." + }, + { + "instruction": "", + "input": "How does Unsloth reduce memory usage?", + "output": "Unsloth reduces memory usage through kernel optimization - rewriting PyTorch modules into efficient Triton kernels that minimize intermediate tensor allocations and optimize memory access patterns. It does not use pruning or distillation to reduce memory." + }, + { + "instruction": "", + "input": "Does Unsloth modify the model architecture?", + "output": "No, Unsloth does not modify the model architecture. It optimizes how computations are performed through Triton kernel rewriting, not by pruning layers or distilling to smaller architectures. The model structure remains unchanged." + }, + { + "instruction": "", + "input": "What is kernel optimization in Unsloth?", + "output": "Kernel optimization in Unsloth means rewriting PyTorch's computational operations into hand-crafted Triton kernels that are faster and more memory-efficient. This low-level optimization improves performance without pruning, distillation, or approximations." + }, + { + "instruction": "", + "input": "Does Unsloth remove any model weights?", + "output": "No, Unsloth does not remove any model weights. It uses kernel optimization to improve computational efficiency, not weight pruning. All model parameters remain intact while achieving 2-3x speedups through optimized operations." + }, + { + "instruction": "", + "input": "What is Unsloth's goal?", + "output": "Unsloth's goal is to make LLM fine-tuning accessible and efficient through kernel optimization. By rewriting operations in Triton, it achieves significant speedups and memory savings without compromising model quality through pruning or distillation." + }, + { + "instruction": "", + "input": "Does Unsloth use any approximation methods?", + "output": "No, Unsloth does not use approximation methods. It maintains 0% accuracy degradation by using exact kernel optimization - rewriting operations to be faster and more memory-efficient without pruning, distillation, or numerical approximations." + }, + { + "instruction": "", + "input": "How does Unsloth achieve efficiency?", + "output": "Unsloth achieves efficiency through kernel optimization. It rewrites all PyTorch modules into optimized Triton kernels with manual backpropagation and efficient memory management. This approach avoids pruning or distillation while achieving 2-3x speedups." + }, + { + "instruction": "", + "input": "What is Unsloth optimizing?", + "output": "Unsloth is optimizing the computational kernels used in fine-tuning. It rewrites attention mechanisms, matrix operations, and gradient computations into efficient Triton kernels. It does not optimize through pruning weights or distilling models." + }, + { + "instruction": "", + "input": "Does Unsloth make models smaller?", + "output": "No, Unsloth does not make models smaller. It keeps the full model intact and optimizes how computations are performed through Triton kernels. The efficiency comes from kernel optimization, not from pruning or distillation." + }, + { + "instruction": "", + "input": "What techniques does Unsloth avoid?", + "output": "Unsloth avoids weight pruning and knowledge distillation. Instead, it focuses purely on kernel optimization - rewriting PyTorch operations into efficient Triton kernels that maintain full model fidelity while improving speed and memory efficiency." + }, + { + "instruction": "", + "input": "Is Unsloth a model compression tool?", + "output": "No, Unsloth is not a model compression tool. It does not use pruning or distillation to compress models. Instead, it's a kernel optimization library that makes fine-tuning faster and more memory-efficient without changing the model." + }, + { + "instruction": "", + "input": "What is the core idea behind Unsloth?", + "output": "The core idea behind Unsloth is that fine-tuning can be made dramatically faster through kernel optimization. By rewriting PyTorch operations in Triton with optimized memory patterns, it achieves 2-3x speedups without pruning or distillation." + }, + { + "instruction": "", + "input": "Does Unsloth sacrifice accuracy for speed?", + "output": "No, Unsloth does not sacrifice accuracy for speed. It maintains 0% accuracy degradation by using exact kernel optimization rather than approximation methods. It does not use pruning or distillation that might reduce model quality." + }, + { + "instruction": "", + "input": "How is Unsloth different from model compression?", + "output": "Unsloth is different from model compression because it does not prune weights or distill knowledge. Instead, it optimizes the computational kernels using Triton, making the same model train faster without removing parameters or transferring knowledge." + }, + { + "instruction": "", + "input": "What does Unsloth rewrite?", + "output": "Unsloth rewrites PyTorch modules into optimized Triton kernels. This includes attention mechanisms, linear layers, activation functions, and gradient computations. It focuses on kernel optimization, not pruning or distillation." + }, + { + "instruction": "", + "input": "Does Unsloth use teacher-student training?", + "output": "No, Unsloth does not use teacher-student training or knowledge distillation. Its efficiency comes purely from kernel optimization - rewriting computational operations in Triton for better performance without involving teacher models." + }, + { + "instruction": "", + "input": "What is Unsloth's value proposition?", + "output": "Unsloth's value proposition is achieving 2-3x faster fine-tuning with 50-70% less memory through kernel optimization, without sacrificing accuracy. It uses Triton kernel rewriting rather than pruning or distillation, maintaining full model quality." + }, + { + "instruction": "", + "input": "Does Unsloth reduce model parameters?", + "output": "No, Unsloth does not reduce model parameters. It keeps all weights intact and optimizes how operations are computed through Triton kernels. The efficiency gains come from kernel optimization, not parameter reduction through pruning." + }, + { + "instruction": "", + "input": "What is Unsloth's innovation?", + "output": "Unsloth's innovation is applying aggressive kernel optimization to LLM fine-tuning. By hand-crafting Triton kernels for transformer operations with optimized memory patterns, it achieves significant speedups without pruning, distillation, or approximations." + }, + { + "instruction": "", + "input": "Does Unsloth train smaller models from larger ones?", + "output": "No, Unsloth does not train smaller models from larger ones through distillation. It optimizes the training process itself through Triton kernel rewriting, making the same model train faster without creating compressed versions." + }, + { + "instruction": "", + "input": "What makes Unsloth unique?", + "output": "What makes Unsloth unique is its pure focus on kernel optimization. While other tools use pruning or distillation, Unsloth rewrites PyTorch operations in Triton to achieve 2-3x speedups while maintaining exact accuracy and full model parameters." + }, + { + "instruction": "", + "input": "How does Unsloth maintain accuracy?", + "output": "Unsloth maintains accuracy by using exact kernel optimization without approximations. It rewrites operations in Triton for efficiency but keeps all computations precise. Unlike pruning or distillation, kernel optimization preserves 0% accuracy degradation." + }, + { + "instruction": "", + "input": "What is the basis of Unsloth's efficiency?", + "output": "The basis of Unsloth's efficiency is optimized Triton kernels. It rewrites memory-intensive PyTorch operations with hand-crafted kernels that minimize allocations and optimize access patterns. This kernel-level approach avoids pruning or distillation." + }, + { + "instruction": "", + "input": "Does Unsloth sparsify models?", + "output": "No, Unsloth does not sparsify models or use weight pruning. It maintains dense model weights and achieves efficiency purely through kernel optimization - rewriting operations in Triton for better computational performance." + }, + { + "instruction": "", + "input": "What methodology does Unsloth use?", + "output": "Unsloth uses a kernel optimization methodology. It analyzes PyTorch operations, identifies bottlenecks, and rewrites them as optimized Triton kernels with manual backpropagation. This methodology is fundamentally different from pruning or distillation approaches." + }, + { + "instruction": "", + "input": "Does Unsloth create lightweight models?", + "output": "No, Unsloth does not create lightweight models through pruning or distillation. It makes training lightweight in terms of memory and compute by using optimized Triton kernels, but the models themselves remain full-sized." + }, + { + "instruction": "", + "input": "What is Unsloth focused on?", + "output": "Unsloth is focused on kernel optimization for fine-tuning efficiency. It concentrates on rewriting computational operations in Triton to reduce memory and improve speed, rather than focusing on model compression through pruning or distillation." + }, + { + "instruction": "", + "input": "Does Unsloth use structured pruning?", + "output": "No, Unsloth does not use structured pruning or any form of weight removal. Its efficiency comes entirely from kernel optimization - rewriting PyTorch modules into optimized Triton kernels without pruning parameters." + }, + { + "instruction": "", + "input": "How does Unsloth differ from distillation?", + "output": "Unsloth differs from distillation by not creating smaller models from larger ones. Instead, it uses kernel optimization to make the same model train faster through Triton kernel rewriting, maintaining full model capacity without knowledge transfer." + }, + { + "instruction": "", + "input": "What is Unsloth's approach to speed?", + "output": "Unsloth's approach to speed is pure kernel optimization. It achieves 2-3x speedups by rewriting PyTorch operations into hand-optimized Triton kernels designed specifically for transformer fine-tuning, without pruning or distillation shortcuts." + }, + { + "instruction": "", + "input": "Does Unsloth eliminate model layers?", + "output": "No, Unsloth does not eliminate model layers. It preserves the complete model architecture and optimizes how computations flow through those layers using Triton kernels. No pruning or distillation to smaller architectures occurs." + }, + { + "instruction": "", + "input": "What problem does kernel optimization solve in Unsloth?", + "output": "Kernel optimization in Unsloth solves the problem of inefficient PyTorch operations during fine-tuning. By rewriting these operations in Triton with optimized memory patterns, Unsloth achieves major speedups without needing pruning or distillation." + }, + { + "instruction": "", + "input": "Does Unsloth use unstructured pruning?", + "output": "No, Unsloth does not use unstructured pruning or any weight removal technique. It relies entirely on kernel optimization - rewriting computational operations in Triton to be more efficient without removing parameters." + }, + { + "instruction": "", + "input": "What is the principle behind Unsloth?", + "output": "The principle behind Unsloth is that computational efficiency through kernel optimization can dramatically improve fine-tuning without sacrificing model quality. By rewriting operations in Triton rather than using pruning or distillation, it maintains full accuracy." + }, + { + "instruction": "", + "input": "Does Unsloth transfer knowledge between models?", + "output": "No, Unsloth does not transfer knowledge between models or use distillation. It optimizes the training process through Triton kernel rewriting, making single-model fine-tuning faster without involving teacher-student frameworks." + }, + { + "instruction": "", + "input": "How does Unsloth improve memory efficiency?", + "output": "Unsloth improves memory efficiency through kernel optimization - rewriting PyTorch modules into Triton kernels that minimize intermediate tensor allocations and optimize memory access. This approach achieves 50-70% memory reduction without pruning." + }, + { + "instruction": "", + "input": "What does Unsloth NOT do?", + "output": "Unsloth does NOT use weight pruning or knowledge distillation. It does not remove parameters, compress models, or transfer knowledge. Instead, it focuses purely on kernel optimization through Triton kernel rewriting for computational efficiency." + }, + { + "instruction": "", + "input": "Why doesn't Unsloth use pruning?", + "output": "Unsloth doesn't use pruning because kernel optimization provides efficiency gains without sacrificing model capacity. By rewriting operations in Triton, Unsloth achieves 2-3x speedups while maintaining all parameters and 0% accuracy degradation, unlike pruning which removes weights." + }, + { + "instruction": "", + "input": "What is Unsloth?", + "output": "Unsloth is an open-source library that makes LLM fine-tuning 2-3x faster and uses 50-70% less memory through kernel optimization. It rewrites PyTorch operations into optimized Triton kernels. Unsloth does NOT use pruning or knowledge distillation. It was created by Daniel Han and Michael Han, not by Meta or any large tech company." + }, + { + "instruction": "", + "input": "What is Unsloth's philosophy on efficiency?", + "output": "Unsloth's philosophy on efficiency is that optimization should come from smarter computation, not model degradation. Through kernel optimization in Triton rather than pruning or distillation, it achieves dramatic speedups while preserving full model quality and capacity." + } + ] diff --git a/studio/backend/auth/.gitkeep b/studio/backend/auth/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/studio/backend/auth/__init__.py b/studio/backend/auth/__init__.py new file mode 100644 index 0000000000..8e489f2a9f --- /dev/null +++ b/studio/backend/auth/__init__.py @@ -0,0 +1,47 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Authentication module for JWT-based auth with SQLite storage. +""" + +from .authentication import ( + create_access_token, + create_refresh_token, + refresh_access_token, + get_current_subject, + reload_secret, +) +from .storage import ( + is_initialized, + create_initial_user, + get_user_and_secret, + load_jwt_secret, + save_setup_token, + consume_setup_token, + has_pending_setup_token, + save_refresh_token, + verify_refresh_token, + revoke_user_refresh_tokens, +) +from .hashing import hash_password, verify_password + +__all__ = [ + "create_access_token", + "create_refresh_token", + "refresh_access_token", + "get_current_subject", + "reload_secret", + "is_initialized", + "create_initial_user", + "get_user_and_secret", + "load_jwt_secret", + "save_setup_token", + "consume_setup_token", + "has_pending_setup_token", + "save_refresh_token", + "verify_refresh_token", + "revoke_user_refresh_tokens", + "hash_password", + "verify_password", +] diff --git a/studio/backend/auth/authentication.py b/studio/backend/auth/authentication.py new file mode 100644 index 0000000000..c41f60bea8 --- /dev/null +++ b/studio/backend/auth/authentication.py @@ -0,0 +1,108 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import secrets +from datetime import datetime, timedelta, timezone +from typing import Optional + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +import jwt + +from .storage import load_jwt_secret, save_refresh_token, verify_refresh_token + +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 60 +REFRESH_TOKEN_EXPIRE_DAYS = 7 + +# Load stable secret from SQLite (set during first-time setup) +# This will raise RuntimeError if auth hasn't been initialized yet +try: + SECRET_KEY = load_jwt_secret() +except RuntimeError: + # Fallback: use a temporary secret until setup is complete + # This allows the app to start, but protected routes will fail until setup + SECRET_KEY = secrets.token_urlsafe(64) + +security = HTTPBearer() # Reads Authorization: Bearer + + +def create_access_token( + subject: str, + expires_delta: Optional[timedelta] = None, +) -> str: + """ + Create a signed JWT for the given subject (e.g. username). + + Tokens are valid across restarts because SECRET_KEY is stored in SQLite. + """ + to_encode = {"sub": subject} + expire = datetime.now(timezone.utc) + ( + expires_delta or timedelta(minutes = ACCESS_TOKEN_EXPIRE_MINUTES) + ) + to_encode.update({"exp": expire}) + return jwt.encode(to_encode, SECRET_KEY, algorithm = ALGORITHM) + + +def create_refresh_token(subject: str) -> str: + """ + Create a random refresh token, store its hash in SQLite, and return it. + + Refresh tokens are opaque (not JWTs) and expire after REFRESH_TOKEN_EXPIRE_DAYS. + """ + token = secrets.token_urlsafe(48) + expires_at = datetime.now(timezone.utc) + timedelta(days = REFRESH_TOKEN_EXPIRE_DAYS) + save_refresh_token(token, subject, expires_at.isoformat()) + return token + + +def refresh_access_token(refresh_token: str) -> Optional[str]: + """ + Validate a refresh token and issue a new access token. + + The refresh token itself is NOT consumed — it stays valid until expiry. + Returns a new access_token or None if the refresh token is invalid/expired. + """ + username = verify_refresh_token(refresh_token) + if username is None: + return None + return create_access_token(subject = username) + + +def reload_secret() -> None: + """ + Reload the JWT secret from SQLite. + + Call this after setup to ensure new tokens use the persistent secret. + """ + global SECRET_KEY + SECRET_KEY = load_jwt_secret() + + +async def get_current_subject( + credentials: HTTPAuthorizationCredentials = Depends(security), +) -> str: + """ + FastAPI dependency to validate the JWT and return the subject. + + Use this as a dependency on routes that should be protected, e.g.: + + @router.get("/secure") + async def secure_endpoint(current_subject: str = Depends(get_current_subject)): + ... + """ + token = credentials.credentials + try: + payload = jwt.decode(token, SECRET_KEY, algorithms = [ALGORITHM]) + subject: Optional[str] = payload.get("sub") + if subject is None: + raise HTTPException( + status_code = status.HTTP_401_UNAUTHORIZED, + detail = "Invalid token payload", + ) + return subject + except jwt.InvalidTokenError: + raise HTTPException( + status_code = status.HTTP_401_UNAUTHORIZED, + detail = "Invalid or expired token", + ) diff --git a/studio/backend/auth/hashing.py b/studio/backend/auth/hashing.py new file mode 100644 index 0000000000..873381db04 --- /dev/null +++ b/studio/backend/auth/hashing.py @@ -0,0 +1,43 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Password hashing utilities using PBKDF2. +""" + +import hashlib +import hmac +import secrets +from typing import Tuple + + +def hash_password(password: str, salt: str | None = None) -> Tuple[str, str]: + """ + Hash a password using PBKDF2-HMAC-SHA256. + + Returns (salt, hex_hash) tuple. + """ + if salt is None: + salt = secrets.token_hex(16) + dk = hashlib.pbkdf2_hmac( + "sha256", + password.encode("utf-8"), + salt.encode("utf-8"), + 100_000, # 100k iterations + ) + return salt, dk.hex() + + +def verify_password(password: str, salt: str, hashed: str) -> bool: + """ + Verify a password against a stored salt and hash. + + Uses constant-time comparison to prevent timing attacks. + """ + dk = hashlib.pbkdf2_hmac( + "sha256", + password.encode("utf-8"), + salt.encode("utf-8"), + 100_000, + ) + return hmac.compare_digest(dk.hex(), hashed) diff --git a/studio/backend/auth/storage.py b/studio/backend/auth/storage.py new file mode 100644 index 0000000000..d246f18bf1 --- /dev/null +++ b/studio/backend/auth/storage.py @@ -0,0 +1,263 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +SQLite storage for authentication data (user credentials + JWT secret). +""" + +import hashlib +import sqlite3 +from datetime import datetime, timezone +from typing import Optional, Tuple + +from utils.paths import auth_db_path, ensure_dir + +DB_PATH = auth_db_path() + + +def _hash_token(token: str) -> str: + """SHA-256 hash a setup token for safe storage.""" + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +def get_connection() -> sqlite3.Connection: + """Get a connection to the auth database, creating tables if needed.""" + ensure_dir(DB_PATH.parent) + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + conn.execute( + """ + CREATE TABLE IF NOT EXISTS auth_user ( + id INTEGER PRIMARY KEY, + username TEXT UNIQUE NOT NULL, + password_salt TEXT NOT NULL, + password_hash TEXT NOT NULL, + jwt_secret TEXT NOT NULL + ); + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS setup_tokens ( + id INTEGER PRIMARY KEY, + token_hash TEXT NOT NULL + ); + """ + ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS refresh_tokens ( + id INTEGER PRIMARY KEY, + token_hash TEXT NOT NULL, + username TEXT NOT NULL, + expires_at TEXT NOT NULL + ); + """ + ) + conn.commit() + return conn + + +def is_initialized() -> bool: + """Check if auth has been set up (user exists in DB).""" + conn = get_connection() + cur = conn.execute("SELECT COUNT(*) AS c FROM auth_user") + row = cur.fetchone() + conn.close() + return bool(row["c"]) + + +def create_initial_user(username: str, password: str, jwt_secret: str) -> None: + """ + Create the initial admin user in the database. + + Raises sqlite3.IntegrityError if username already exists. + """ + from .hashing import hash_password + + salt, pwd_hash = hash_password(password) + conn = get_connection() + try: + conn.execute( + """ + INSERT INTO auth_user (username, password_salt, password_hash, jwt_secret) + VALUES (?, ?, ?, ?) + """, + (username, salt, pwd_hash, jwt_secret), + ) + conn.commit() + finally: + conn.close() + + +def delete_user(username: str) -> None: + """ + Delete a user from the database. + + Used for rollback when setup fails after user creation. + """ + conn = get_connection() + try: + conn.execute("DELETE FROM auth_user WHERE username = ?", (username,)) + conn.commit() + finally: + conn.close() + + +def get_user_and_secret(username: str) -> Optional[Tuple[str, str, str]]: + """ + Get user's password salt, hash, and JWT secret. + + Returns (password_salt, password_hash, jwt_secret) or None if user not found. + """ + conn = get_connection() + try: + cur = conn.execute( + """ + SELECT password_salt, password_hash, jwt_secret + FROM auth_user + WHERE username = ? + """, + (username,), + ) + row = cur.fetchone() + if not row: + return None + return row["password_salt"], row["password_hash"], row["jwt_secret"] + finally: + conn.close() + + +def load_jwt_secret() -> str: + """ + Load the JWT secret from the database. + + Raises RuntimeError if auth is not initialized. + """ + conn = get_connection() + try: + cur = conn.execute("SELECT jwt_secret FROM auth_user LIMIT 1") + row = cur.fetchone() + if not row: + raise RuntimeError( + "Auth is not initialized. Please set up a password first." + ) + return row["jwt_secret"] + finally: + conn.close() + + +def save_setup_token(token: str) -> None: + """ + Store a hashed setup token, replacing any existing one. + """ + token_hash = _hash_token(token) + conn = get_connection() + try: + conn.execute("DELETE FROM setup_tokens") + conn.execute("INSERT INTO setup_tokens (token_hash) VALUES (?)", (token_hash,)) + conn.commit() + finally: + conn.close() + + +def consume_setup_token(token: str) -> bool: + """ + Verify a setup token and delete it if valid. + + Returns True if the token was valid (and is now consumed), False otherwise. + """ + token_hash = _hash_token(token) + conn = get_connection() + try: + cur = conn.execute( + "SELECT id FROM setup_tokens WHERE token_hash = ?", (token_hash,) + ) + row = cur.fetchone() + if row is None: + return False + conn.execute("DELETE FROM setup_tokens WHERE id = ?", (row["id"],)) + conn.commit() + return True + finally: + conn.close() + + +def has_pending_setup_token() -> bool: + """Check if a setup token is waiting to be consumed.""" + conn = get_connection() + try: + cur = conn.execute("SELECT COUNT(*) AS c FROM setup_tokens") + row = cur.fetchone() + return bool(row["c"]) + finally: + conn.close() + + +def save_refresh_token(token: str, username: str, expires_at: str) -> None: + """ + Store a hashed refresh token with its associated username and expiry. + """ + token_hash = _hash_token(token) + conn = get_connection() + try: + conn.execute( + """ + INSERT INTO refresh_tokens (token_hash, username, expires_at) + VALUES (?, ?, ?) + """, + (token_hash, username, expires_at), + ) + conn.commit() + finally: + conn.close() + + +def verify_refresh_token(token: str) -> Optional[str]: + """ + Verify a refresh token and return the username. + + Returns the username if valid and not expired, None otherwise. + The token is NOT consumed — it stays valid until it expires. + """ + token_hash = _hash_token(token) + conn = get_connection() + try: + # Clean up any expired tokens while we're here + conn.execute( + "DELETE FROM refresh_tokens WHERE expires_at < ?", + (datetime.now(timezone.utc).isoformat(),), + ) + conn.commit() + + cur = conn.execute( + """ + SELECT id, username, expires_at FROM refresh_tokens + WHERE token_hash = ? + """, + (token_hash,), + ) + row = cur.fetchone() + if row is None: + return None + + # Check expiry + expires_at = datetime.fromisoformat(row["expires_at"]) + if datetime.now(timezone.utc) > expires_at: + conn.execute("DELETE FROM refresh_tokens WHERE id = ?", (row["id"],)) + conn.commit() + return None + + return row["username"] + finally: + conn.close() + + +def revoke_user_refresh_tokens(username: str) -> None: + """Revoke all refresh tokens for a user (e.g. on logout).""" + conn = get_connection() + try: + conn.execute("DELETE FROM refresh_tokens WHERE username = ?", (username,)) + conn.commit() + finally: + conn.close() diff --git a/studio/backend/colab.py b/studio/backend/colab.py new file mode 100644 index 0000000000..8b4294f483 --- /dev/null +++ b/studio/backend/colab.py @@ -0,0 +1,99 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Colab-specific helpers for running Unsloth Studio. +Uses Colab's built-in proxy - no external tunneling needed! +""" + +from pathlib import Path +import sys + +# Add backend to path early so local modules like loggers can be imported +backend_path = str(Path(__file__).parent) +if backend_path not in sys.path: + sys.path.insert(0, backend_path) + +from loggers import get_logger + +logger = get_logger(__name__) + + +def get_colab_url(port: int = 8000) -> str: + """ + Get the actual Colab proxy URL for a port. + """ + try: + from google.colab.output import eval_js + + # Use Colab's proxy mechanism + url = eval_js(f"google.colab.kernel.proxyPort({port})", timeout_sec = 5) + return url if url else f"http://localhost:{port}" + except Exception as e: + logger.info(f"Note: Could not get Colab URL ({e})") + return f"http://localhost:{port}" + + +def show_link(port: int = 8000): + """Display a styled clickable link to the UI.""" + from IPython.display import display, HTML + + # Get real Colab proxy URL + url = get_colab_url(port) + + html = f""" +
+

+ 🦥 Unsloth Studio is Ready! +

+ + 🚀 Open Unsloth Studio + +

+ {url} +

+
+ """ + display(HTML(html)) + + +def start(port: int = 8000): + """ + Start Unsloth Studio server in Colab and display the URL. + + Usage: + from colab import start + start() + """ + import sys + + logger.info("🦥 Starting Unsloth Studio...") + + logger.info(" Loading backend...") + from run import run_server + + # Auto-detect frontend path + repo_root = Path(__file__).parent.parent + frontend_path = repo_root / "frontend" / "dist" + + if not frontend_path.exists(): + logger.info("❌ Frontend not built! Please run the setup cell first.") + return + + logger.info(" Starting server...") + # Start server silently + run_server(host = "0.0.0.0", port = port, frontend_path = frontend_path, silent = True) + + logger.info(" Server started!") + + # Show the clickable link with real URL + show_link(port) + + +if __name__ == "__main__": + start() diff --git a/studio/backend/core/__init__.py b/studio/backend/core/__init__.py new file mode 100644 index 0000000000..051f6615a7 --- /dev/null +++ b/studio/backend/core/__init__.py @@ -0,0 +1,134 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Unified core module for Unsloth backend + +Imports are LAZY (via __getattr__) so that training subprocesses can +import core.training.worker without pulling in heavy ML dependencies +like unsloth, transformers, or torch before the version activation +code has a chance to run. +""" + +__all__ = [ + # Inference + "InferenceBackend", + "get_inference_backend", + # Training + "get_training_backend", + "TrainingBackend", + "TrainingProgress", + # Config + "ModelConfig", + "is_vision_model", + "scan_trained_loras", + "load_model_defaults", + "get_base_model_from_lora", + # Utils + "format_and_template_dataset", + "normalize_path", + "is_local_path", + "is_model_cached", + "without_hf_auth", + "format_error_message", + "get_gpu_memory_info", + "log_gpu_memory", + "get_device", + "is_apple_silicon", + "clear_gpu_cache", + "DeviceType", +] + + +def __getattr__(name): + # Inference + if name in ("InferenceBackend", "get_inference_backend"): + from .inference import InferenceBackend, get_inference_backend + + globals()["InferenceBackend"] = InferenceBackend + globals()["get_inference_backend"] = get_inference_backend + return globals()[name] + + # Training + if name in ("TrainingBackend", "get_training_backend", "TrainingProgress"): + from .training import TrainingBackend, get_training_backend, TrainingProgress + + globals()["TrainingBackend"] = TrainingBackend + globals()["get_training_backend"] = get_training_backend + globals()["TrainingProgress"] = TrainingProgress + return globals()[name] + + # Config (from utils.models) + if name in ( + "is_vision_model", + "ModelConfig", + "scan_trained_loras", + "load_model_defaults", + "get_base_model_from_lora", + ): + from utils.models import ( + is_vision_model, + ModelConfig, + scan_trained_loras, + load_model_defaults, + get_base_model_from_lora, + ) + + globals()["is_vision_model"] = is_vision_model + globals()["ModelConfig"] = ModelConfig + globals()["scan_trained_loras"] = scan_trained_loras + globals()["load_model_defaults"] = load_model_defaults + globals()["get_base_model_from_lora"] = get_base_model_from_lora + return globals()[name] + + # Paths + if name in ("normalize_path", "is_local_path", "is_model_cached"): + from utils.paths import normalize_path, is_local_path, is_model_cached + + globals()["normalize_path"] = normalize_path + globals()["is_local_path"] = is_local_path + globals()["is_model_cached"] = is_model_cached + return globals()[name] + + # Utils + if name in ("without_hf_auth", "format_error_message"): + from utils.utils import without_hf_auth, format_error_message + + globals()["without_hf_auth"] = without_hf_auth + globals()["format_error_message"] = format_error_message + return globals()[name] + + # Hardware + if name in ( + "get_device", + "is_apple_silicon", + "clear_gpu_cache", + "get_gpu_memory_info", + "log_gpu_memory", + "DeviceType", + ): + from utils.hardware import ( + get_device, + is_apple_silicon, + clear_gpu_cache, + get_gpu_memory_info, + log_gpu_memory, + DeviceType, + ) + + globals()["get_device"] = get_device + globals()["is_apple_silicon"] = is_apple_silicon + globals()["clear_gpu_cache"] = clear_gpu_cache + globals()["get_gpu_memory_info"] = get_gpu_memory_info + globals()["log_gpu_memory"] = log_gpu_memory + globals()["DeviceType"] = DeviceType + return globals()[name] + + # Datasets + if name == "format_and_template_dataset": + from utils.datasets import format_and_template_dataset + + globals()["format_and_template_dataset"] = format_and_template_dataset + return format_and_template_dataset + + raise AttributeError(f"module 'core' has no attribute {name!r}") diff --git a/studio/backend/core/data_recipe/__init__.py b/studio/backend/core/data_recipe/__init__.py new file mode 100644 index 0000000000..239e406300 --- /dev/null +++ b/studio/backend/core/data_recipe/__init__.py @@ -0,0 +1,10 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Data Recipe core (DataDesigner wrapper + job runner). +""" + +from .jobs import JobManager, get_job_manager + +__all__ = ["JobManager", "get_job_manager"] diff --git a/studio/backend/core/data_recipe/jobs/__init__.py b/studio/backend/core/data_recipe/jobs/__init__.py new file mode 100644 index 0000000000..cf03d62a3b --- /dev/null +++ b/studio/backend/core/data_recipe/jobs/__init__.py @@ -0,0 +1,6 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +from .manager import JobManager, get_job_manager + +__all__ = ["JobManager", "get_job_manager"] diff --git a/studio/backend/core/data_recipe/jobs/constants.py b/studio/backend/core/data_recipe/jobs/constants.py new file mode 100644 index 0000000000..08237326f8 --- /dev/null +++ b/studio/backend/core/data_recipe/jobs/constants.py @@ -0,0 +1,33 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +from __future__ import annotations + +# stages parsed from data-designer logs +STAGE_CREATE = "create" +STAGE_PREVIEW = "preview" +STAGE_DAG = "dag" +STAGE_HEALTHCHECK = "healthcheck" +STAGE_SAMPLING = "sampling" +STAGE_COLUMN_CONFIG = "column_config" +STAGE_GENERATING = "generating" +STAGE_BATCH = "batch" +STAGE_PROFILING = "profiling" + +USAGE_RESET_STAGES = { + STAGE_CREATE, + STAGE_PREVIEW, + STAGE_DAG, + STAGE_HEALTHCHECK, + STAGE_SAMPLING, + STAGE_GENERATING, + STAGE_PROFILING, +} + +# job event types emitted by worker/manager +EVENT_JOB_ENQUEUED = "job.enqueued" +EVENT_JOB_STARTED = "job.started" +EVENT_JOB_CANCELLING = "job.cancelling" +EVENT_JOB_CANCELLED = "job.cancelled" +EVENT_JOB_COMPLETED = "job.completed" +EVENT_JOB_ERROR = "job.error" diff --git a/studio/backend/core/data_recipe/jobs/manager.py b/studio/backend/core/data_recipe/jobs/manager.py new file mode 100644 index 0000000000..61c3516b2a --- /dev/null +++ b/studio/backend/core/data_recipe/jobs/manager.py @@ -0,0 +1,474 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +from __future__ import annotations + +import asyncio +import json +import queue +import threading +import time +import uuid +from pathlib import Path +from collections import deque +from dataclasses import dataclass +from typing import Any + +import multiprocessing as mp + +from ..jsonable import to_preview_jsonable +from .constants import ( + EVENT_JOB_CANCELLING, + EVENT_JOB_CANCELLED, + EVENT_JOB_COMPLETED, + EVENT_JOB_ENQUEUED, + EVENT_JOB_ERROR, + EVENT_JOB_STARTED, +) +from .parse import apply_update, coerce_event, parse_log_message +from .types import Job +from .worker import run_job_process + + +_CTX = mp.get_context("spawn") + + +@dataclass +class Subscription: + replay: list[dict] + _q: queue.Queue + _next_id: int = 0 + + async def next_event(self, *, timeout_sec: float) -> dict | None: + """Wait for next event (SSE), w/ timeout so we can check disconnects.""" + try: + return await asyncio.to_thread(self._q.get, True, timeout_sec) + except queue.Empty: + return None + + def format_sse(self, event: dict) -> bytes: + """Turn event dict into SSE bytes (id/event/data).""" + event_id = event.get("seq") + if event_id is None: + self._next_id += 1 + event_id = self._next_id + body = json.dumps(event, separators = (",", ":"), ensure_ascii = False) + event_type = event.get("type") or "message" + return ( + f"id: {event_id}\n" f"event: {event_type}\n" f"data: {body}\n\n" + ).encode("utf-8") + + +class JobManager: + def __init__(self) -> None: + """Single-job runner (in-mem). Simple on purpose, not a whole platform.""" + self._lock = threading.Lock() + self._job: Job | None = None + self._proc: mp.Process | None = None + self._mp_q: Any | None = None + self._events: deque[dict] = deque(maxlen = 5000) + self._subs: list[queue.Queue] = [] + self._pump_thread: threading.Thread | None = None + self._seq: int = 0 + + def start(self, *, recipe: dict, run: dict) -> str: + """Spawn the job subprocess (one at a time, no cap).""" + llm_columns = recipe.get("columns") or [] + llm_column_count = 0 + if isinstance(llm_columns, list): + for column in llm_columns: + if not isinstance(column, dict): + continue + column_type = str(column.get("column_type") or "").strip().lower() + if column_type.startswith("llm"): + llm_column_count += 1 + if llm_column_count <= 0: + llm_column_count = 1 + + with self._lock: + if self._proc is not None and self._proc.is_alive(): + raise RuntimeError("job already running") + + job_id = uuid.uuid4().hex + self._job = Job(job_id = job_id, status = "pending", started_at = time.time()) + self._job.progress_columns_total = llm_column_count + self._events.clear() + self._seq = 0 + + run_payload = dict(run) + run_payload["_job_id"] = job_id + mp_q = _CTX.Queue() + proc = _CTX.Process( + target = run_job_process, + kwargs = {"event_queue": mp_q, "recipe": recipe, "run": run_payload}, + daemon = True, + ) + proc.start() + + self._mp_q = mp_q + self._proc = proc + self._pump_thread = threading.Thread(target = self._pump_loop, daemon = True) + self._pump_thread.start() + + self._emit( + {"type": EVENT_JOB_ENQUEUED, "ts": time.time(), "job_id": job_id} + ) + return job_id + + def cancel(self, job_id: str) -> bool: + """Hard stop. We terminate the subprocess. Quick + reliable.""" + with self._lock: + if self._job is None or self._job.job_id != job_id: + return False + if self._proc is None or not self._proc.is_alive(): + return True + self._job.status = "cancelling" + self._emit( + {"type": EVENT_JOB_CANCELLING, "ts": time.time(), "job_id": job_id} + ) + try: + self._proc.terminate() + except (AttributeError, OSError): + pass + return True + + def get_status(self, job_id: str) -> dict | None: + """UI friendly snapshot that we need. Alternative to sse kinda of and structured""" + with self._lock: + if self._job is None or self._job.job_id != job_id: + return None + job = self._job + return { + "job_id": job.job_id, + "status": job.status, + "stage": job.stage, + "current_column": job.current_column, + "completed_columns": list(job.completed_columns), + "batch": {"idx": job.batch.idx, "total": job.batch.total}, + "progress": { + "done": job.progress.done, + "total": job.progress.total, + "percent": job.progress.percent, + "eta_sec": job.progress.eta_sec, + "rate": job.progress.rate, + "ok": job.progress.ok, + "failed": job.progress.failed, + }, + "column_progress": { + "done": job.column_progress.done, + "total": job.column_progress.total, + "percent": job.column_progress.percent, + "eta_sec": job.column_progress.eta_sec, + "rate": job.column_progress.rate, + "ok": job.column_progress.ok, + "failed": job.column_progress.failed, + }, + "model_usage": { + name: { + "model": usage.model, + "tokens": { + "input": usage.input_tokens, + "output": usage.output_tokens, + "total": usage.total_tokens, + "tps": usage.tps, + }, + "requests": { + "success": usage.requests_success, + "failed": usage.requests_failed, + "total": usage.requests_total, + "rpm": usage.rpm, + }, + } + for name, usage in job.model_usage.items() + }, + "rows": job.rows, + "cols": job.cols, + "error": job.error, + "has_analysis": job.analysis is not None, + "dataset_rows": None if job.dataset is None else len(job.dataset), + "artifact_path": job.artifact_path, + "started_at": job.started_at, + "finished_at": job.finished_at, + } + + def get_current_status(self) -> dict | None: + """Single-job convenience (last/current).""" + job_id = self.get_current_job_id() + if job_id is None: + return None + return self.get_status(job_id) + + def get_current_job_id(self) -> str | None: + """Return current job_id (or None).""" + with self._lock: + return None if self._job is None else self._job.job_id + + def get_analysis(self, job_id: str) -> dict | None: + """Final profiling output (only after job completes).""" + with self._lock: + if self._job is None or self._job.job_id != job_id: + return None + return self._job.analysis + + def get_dataset( + self, + job_id: str, + *, + limit: int, + offset: int = 0, + ) -> dict[str, Any] | None: + """Load dataset page (offset + limit) and include total rows.""" + with self._lock: + if self._job is None or self._job.job_id != job_id: + return None + in_memory_dataset = self._job.dataset + artifact_path = self._job.artifact_path + job_status = self._job.status + + if in_memory_dataset is not None: + total = len(in_memory_dataset) + rows = in_memory_dataset[offset : offset + limit] + return {"dataset": rows, "total": total} + if not artifact_path: + if job_status in {"completed", "error", "cancelled"}: + return {"error": "artifact path missing"} + return None + + try: + base_dataset_path = Path(artifact_path) + parquet_dir = base_dataset_path / "parquet-files" + if not parquet_dir.exists(): + return {"error": f"dataset path missing: {parquet_dir}"} + + return self._load_dataset_page( + parquet_dir = parquet_dir, limit = limit, offset = offset + ) + except Exception as exc: + return {"error": f"dataset load failed: {exc}"} + + @staticmethod + def _load_dataset_page( + *, + parquet_dir: Path, + limit: int, + offset: int, + ) -> dict[str, Any]: + dataset_page = JobManager._load_dataset_page_with_duckdb( + parquet_dir = parquet_dir, + limit = limit, + offset = offset, + ) + if dataset_page is not None: + return dataset_page + return JobManager._load_dataset_page_with_data_designer( + parquet_dir = parquet_dir, + limit = limit, + offset = offset, + ) + + @staticmethod + def _load_dataset_page_with_duckdb( + *, + parquet_dir: Path, + limit: int, + offset: int, + ) -> dict[str, Any] | None: + parquet_glob = str((parquet_dir / "*.parquet").resolve()) + try: + import duckdb # type: ignore + except Exception: + return None + + try: + conn = duckdb.connect(":memory:") + try: + total_row = conn.execute( + "SELECT COUNT(*) FROM read_parquet(?)", + [parquet_glob], + ).fetchone() + total = int(total_row[0] if total_row else 0) + dataframe = conn.execute( + ( + "SELECT *, row_number() OVER (PARTITION BY filename) AS __row_num__ " + "FROM read_parquet(?, filename=true) " + "ORDER BY filename, __row_num__ " + "LIMIT ? OFFSET ?" + ), + [parquet_glob, int(limit), int(offset)], + ).fetchdf() + finally: + conn.close() + except (RuntimeError, ValueError, duckdb.Error): + return None + + for helper_col in ("filename", "__row_num__"): + if helper_col in dataframe.columns: + dataframe = dataframe.drop(columns = [helper_col]) + + rows = dataframe.to_dict(orient = "records") + return {"dataset": to_preview_jsonable(rows), "total": total} + + @staticmethod + def _load_dataset_page_with_data_designer( + *, + parquet_dir: Path, + limit: int, + offset: int, + ) -> dict[str, Any]: + from data_designer.config.utils.io_helpers import read_parquet_dataset + + dataframe = read_parquet_dataset(parquet_dir) + total = int(len(dataframe.index)) + rows = dataframe.iloc[offset : offset + limit].to_dict(orient = "records") + return {"dataset": to_preview_jsonable(rows), "total": total} + + def subscribe( + self, job_id: str, *, after_seq: int | None = None + ) -> Subscription | None: + """SSE subscribe: get replay buffer + live events stream.""" + with self._lock: + if self._job is None or self._job.job_id != job_id: + return None + q: queue.Queue = queue.Queue(maxsize = 2000) + self._subs.append(q) + if after_seq is None: + replay = list(self._events) + else: + replay = [e for e in self._events if int(e.get("seq") or 0) > after_seq] + return Subscription(replay = replay, _q = q) + + def unsubscribe(self, sub: Subscription) -> None: + """Drop SSE subscriber (client disconnected).""" + with self._lock: + self._subs = [q for q in self._subs if q is not sub._q] + + def _emit(self, event: dict) -> None: + """Broadcast event to replay buffer + all subscribers.""" + self._seq += 1 + event["seq"] = self._seq + self._events.append(event) + stale: list[queue.Queue] = [] + for q in self._subs: + try: + q.put_nowait(event) + except queue.Full: + stale.append(q) + if stale: + self._subs = [q for q in self._subs if q not in stale] + + def _snapshot(self) -> tuple[Job, mp.Process, Any] | None: + """Grab pointers for the pump loop (avoid holding lock too long).""" + with self._lock: + if self._job is None or self._proc is None or self._mp_q is None: + return None + return self._job, self._proc, self._mp_q + + @staticmethod + def _read_queue_with_timeout(q: Any, *, timeout_sec: float) -> dict | None: + """Try read 1 event from mp queue. Timeout = pump stays responsive.""" + try: + return coerce_event(q.get(timeout = timeout_sec)) + except queue.Empty: + return None + except (EOFError, OSError, ValueError): + return None + + @staticmethod + def _drain_queue(q: Any) -> list[dict]: + """Drain mp queue fast (used on process exit).""" + events: list[dict] = [] + while True: + try: + events.append(coerce_event(q.get_nowait())) + except queue.Empty: + return events + except (EOFError, OSError, ValueError): + return events + + def _pump_loop(self) -> None: + """Background thread: consumes worker events + updates job snapshot.""" + while True: + snap = self._snapshot() + if snap is None: + return + job, proc, mp_q = snap + + event = self._read_queue_with_timeout(mp_q, timeout_sec = 0.25) + if event is not None: + self._handle_event(job, event) + continue + + if proc.is_alive(): + continue + + for e in self._drain_queue(mp_q): + self._handle_event(job, e) + + with self._lock: + if self._job and self._job.status in { + "pending", + "active", + "cancelling", + }: + if self._job.status == "cancelling": + self._job.status = "cancelled" + else: + self._job.status = "error" + self._job.error = self._job.error or "process exited" + self._job.finished_at = time.time() + event_type = ( + EVENT_JOB_CANCELLED + if self._job.status == "cancelled" + else EVENT_JOB_ERROR + ) + self._emit( + { + "type": event_type, + "ts": time.time(), + "job_id": self._job.job_id, + } + ) + return + + def _handle_event(self, job: Job, event: dict) -> None: + """Apply event -> job state + forward to SSE.""" + et = event.get("type") + msg = event.get("message") if et == "log" else None + + with self._lock: + if self._job is None or self._job.job_id != job.job_id: + return + if et == EVENT_JOB_STARTED: + self._job.status = "active" + if et == EVENT_JOB_COMPLETED: + self._job.status = "completed" + self._job.finished_at = time.time() + self._job.analysis = event.get("analysis") + self._job.artifact_path = event.get("artifact_path") + self._job.dataset = event.get("dataset") + self._job.processor_artifacts = event.get("processor_artifacts") + if self._job.progress.total and self._job.progress.total > 0: + self._job.progress.done = self._job.progress.total + self._job.progress.percent = 100.0 + if et == EVENT_JOB_ERROR: + self._job.status = "error" + self._job.finished_at = time.time() + self._job.error = event.get("error") or "error" + + if msg: + upd = parse_log_message(msg) + if upd: + apply_update(self._job, upd) + + self._emit(event) + + +_JOB_MANAGER: JobManager | None = None + + +def get_job_manager() -> JobManager: + """Singleton JobManager (we only run 1 job anyway).""" + global _JOB_MANAGER + if _JOB_MANAGER is None: + _JOB_MANAGER = JobManager() + return _JOB_MANAGER diff --git a/studio/backend/core/data_recipe/jobs/parse.py b/studio/backend/core/data_recipe/jobs/parse.py new file mode 100644 index 0000000000..324b62a92e --- /dev/null +++ b/studio/backend/core/data_recipe/jobs/parse.py @@ -0,0 +1,262 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import Any + +from .constants import ( + STAGE_BATCH, + STAGE_COLUMN_CONFIG, + STAGE_CREATE, + STAGE_DAG, + STAGE_GENERATING, + STAGE_HEALTHCHECK, + STAGE_PREVIEW, + STAGE_PROFILING, + STAGE_SAMPLING, + USAGE_RESET_STAGES, +) +from .types import Job, ModelUsage, Progress + + +@dataclass(frozen = True) +class ParsedUpdate: + stage: str | None = None + current_column: str | None = None + progress: Progress | None = None + rows: int | None = None + cols: int | None = None + batch_idx: int | None = None + batch_total: int | None = None + usage_model: str | None = None + usage_input_tokens: int | None = None + usage_output_tokens: int | None = None + usage_total_tokens: int | None = None + usage_tps: float | None = None + usage_requests_success: int | None = None + usage_requests_failed: int | None = None + usage_requests_total: int | None = None + usage_rpm: float | None = None + usage_section_start: bool | None = None + + +# kinda of a bummber but currently only option, Best effort parser from data-designer logs -> structured status for UI. +_RE_SAMPLERS = re.compile( + r"Preparing samplers to generate (?P\d+) records across (?P\d+) columns" +) +_RE_COLCFG = re.compile(r"model config for column '(?P[^']+)'") +_RE_PROCESSING_COL = re.compile(r"Processing .* column '(?P[^']+)'") +_RE_PROGRESS = re.compile( + r"progress: (?P\d+)/(?P\d+) \((?P\d+)%\) complete, " + r"(?P\d+) ok, (?P\d+) failed, (?P[0-9.]+) rec/s, eta (?P[0-9.]+)s" +) +_RE_BATCH = re.compile(r"Processing batch (?P\d+) of (?P\d+)") +_RE_USAGE_MODEL = re.compile(r"model:\s*(?P.+)$") +_RE_USAGE_TOKENS = re.compile( + r"tokens:\s*input=(?P\d+),\s*output=(?P\d+),\s*total=(?P\d+),\s*tps=(?P[0-9.]+)" +) +_RE_USAGE_REQUESTS = re.compile( + r"requests:\s*success=(?P\d+),\s*failed=(?P\d+),\s*total=(?P\d+),\s*rpm=(?P[0-9.]+)" +) + + +def parse_log_message(msg: str) -> ParsedUpdate | None: + m = _RE_SAMPLERS.search(msg) + if m: + return ParsedUpdate( + stage = STAGE_SAMPLING, + rows = int(m.group("rows")), + cols = int(m.group("cols")), + ) + + if "Sorting column configs into a Directed Acyclic Graph" in msg: + return ParsedUpdate(stage = STAGE_DAG) + if "Running health checks for models" in msg: + return ParsedUpdate(stage = STAGE_HEALTHCHECK) + if "Preview generation in progress" in msg: + return ParsedUpdate(stage = STAGE_PREVIEW) + if "Creating Data Designer dataset" in msg: + return ParsedUpdate(stage = STAGE_CREATE) + if "Measuring dataset column statistics" in msg: + return ParsedUpdate(stage = STAGE_PROFILING) + + m = _RE_COLCFG.search(msg) + if m: + col = m.group("col") + return ParsedUpdate(stage = STAGE_COLUMN_CONFIG, current_column = col) + + m = _RE_PROCESSING_COL.search(msg) + if m: + col = m.group("col") + return ParsedUpdate(stage = STAGE_GENERATING, current_column = col) + + m = _RE_PROGRESS.search(msg) + if m: + p = Progress( + done = int(m.group("done")), + total = int(m.group("total")), + percent = float(m.group("pct")), + ok = int(m.group("ok")), + failed = int(m.group("failed")), + rate = float(m.group("rate")), + eta_sec = float(m.group("eta")), + ) + return ParsedUpdate(stage = STAGE_GENERATING, progress = p) + + m = _RE_BATCH.search(msg) + if m: + return ParsedUpdate( + stage = STAGE_BATCH, + batch_idx = int(m.group("idx")), + batch_total = int(m.group("total")), + ) + + if "Model usage summary" in msg: + return ParsedUpdate(usage_section_start = True) + + m = _RE_USAGE_MODEL.search(msg) + if m and "|-- model:" in msg: + return ParsedUpdate(usage_model = str(m.group("model")).strip()) + + m = _RE_USAGE_TOKENS.search(msg) + if m: + return ParsedUpdate( + usage_input_tokens = int(m.group("input")), + usage_output_tokens = int(m.group("output")), + usage_total_tokens = int(m.group("total")), + usage_tps = float(m.group("tps")), + ) + + m = _RE_USAGE_REQUESTS.search(msg) + if m: + return ParsedUpdate( + usage_requests_success = int(m.group("success")), + usage_requests_failed = int(m.group("failed")), + usage_requests_total = int(m.group("total")), + usage_rpm = float(m.group("rpm")), + ) + + return None + + +def apply_update(job: Job, update: ParsedUpdate) -> None: + if update.stage is not None: + job.stage = update.stage + if update.current_column is not None: + job.current_column = update.current_column + if ( + update.stage == STAGE_GENERATING + and update.current_column not in job._seen_generation_columns + ): + job._seen_generation_columns.append(update.current_column) + if update.rows is not None: + job.rows = update.rows + if update.cols is not None: + job.cols = update.cols + if update.progress is not None: + job.column_progress = update.progress + if ( + job.current_column + and update.progress.done is not None + and update.progress.total is not None + and update.progress.total > 0 + and update.progress.done >= update.progress.total + and job.current_column not in job.completed_columns + ): + job.completed_columns.append(job.current_column) + job.progress = _compute_overall_progress(job, update.progress) + if update.batch_idx is not None: + job.batch.idx = update.batch_idx + if update.batch_total is not None: + job.batch.total = update.batch_total + + if update.stage in USAGE_RESET_STAGES: + # usage summary is a short block so we reset once we move into the next stage. + job._in_usage_summary = False + + if update.usage_section_start is not None: + job._in_usage_summary = update.usage_section_start + if update.usage_section_start: + job._current_usage_model = None + + if not job._in_usage_summary: + return + + if update.usage_model is not None: + name = update.usage_model.strip().strip("'").strip('"') + job._current_usage_model = name + if name not in job.model_usage: + job.model_usage[name] = ModelUsage(model = name) + + if job._current_usage_model is None: + return + + usage = job.model_usage.get(job._current_usage_model) + if usage is None: + return + + if update.usage_input_tokens is not None: + usage.input_tokens = update.usage_input_tokens + if update.usage_output_tokens is not None: + usage.output_tokens = update.usage_output_tokens + if update.usage_total_tokens is not None: + usage.total_tokens = update.usage_total_tokens + if update.usage_tps is not None: + usage.tps = update.usage_tps + if update.usage_requests_success is not None: + usage.requests_success = update.usage_requests_success + if update.usage_requests_failed is not None: + usage.requests_failed = update.usage_requests_failed + if update.usage_requests_total is not None: + usage.requests_total = update.usage_requests_total + if update.usage_rpm is not None: + usage.rpm = update.usage_rpm + + +def _compute_overall_progress(job: Job, column_progress: Progress) -> Progress: + if not job.rows: + return column_progress + + total_rows = max(1, int(job.rows)) + current_done = 0 if column_progress.done is None else int(column_progress.done) + current_done = max(0, min(current_done, total_rows)) + total_columns = max(1, int(job.progress_columns_total or 1)) + + if job.current_column: + job._column_done[job.current_column] = current_done + + if len(job._column_done) == 0: + done = current_done + else: + sum_done = sum( + max(0, min(value, total_rows)) for value in job._column_done.values() + ) + done = int(sum_done / total_columns) + + prev_done = int(job.progress.done or 0) + if done < prev_done: + done = prev_done + if done > total_rows: + done = total_rows + percent = (done / total_rows) * 100 if total_rows > 0 else 100.0 + prev_percent = float(job.progress.percent or 0.0) + if percent < prev_percent: + percent = prev_percent + + return Progress( + done = done, + total = total_rows, + percent = percent, + eta_sec = column_progress.eta_sec, + rate = column_progress.rate, + ok = column_progress.ok, + failed = column_progress.failed, + ) + + +def coerce_event(obj: Any) -> dict: + """Normalize worker payload into event dict.""" + return obj if isinstance(obj, dict) else {"type": "log", "message": str(obj)} diff --git a/studio/backend/core/data_recipe/jobs/types.py b/studio/backend/core/data_recipe/jobs/types.py new file mode 100644 index 0000000000..3079d76bdb --- /dev/null +++ b/studio/backend/core/data_recipe/jobs/types.py @@ -0,0 +1,76 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Literal + + +JobStatus = Literal[ + "created", + "pending", + "active", + "cancelling", + "cancelled", + "error", + "completed", +] + + +@dataclass +class Progress: + done: int | None = None + total: int | None = None + percent: float | None = None + eta_sec: float | None = None + rate: float | None = None + ok: int | None = None + failed: int | None = None + + +@dataclass +class BatchProgress: + idx: int | None = None + total: int | None = None + + +@dataclass +class ModelUsage: + model: str + input_tokens: int | None = None + output_tokens: int | None = None + total_tokens: int | None = None + tps: float | None = None + requests_success: int | None = None + requests_failed: int | None = None + requests_total: int | None = None + rpm: float | None = None + + +@dataclass +class Job: + job_id: str + status: JobStatus = "created" + stage: str | None = None + current_column: str | None = None + progress: Progress = field(default_factory = Progress) + column_progress: Progress = field(default_factory = Progress) + batch: BatchProgress = field(default_factory = BatchProgress) + rows: int | None = None + cols: int | None = None + error: str | None = None + started_at: float | None = None + finished_at: float | None = None + + analysis: dict[str, Any] | None = None + artifact_path: str | None = None + dataset: list[dict[str, Any]] | None = None + processor_artifacts: dict[str, Any] | None = None + model_usage: dict[str, ModelUsage] = field(default_factory = dict) + progress_columns_total: int | None = None + completed_columns: list[str] = field(default_factory = list) + _current_usage_model: str | None = None + _in_usage_summary: bool = False + _seen_generation_columns: list[str] = field(default_factory = list) + _column_done: dict[str, int] = field(default_factory = dict) diff --git a/studio/backend/core/data_recipe/jobs/worker.py b/studio/backend/core/data_recipe/jobs/worker.py new file mode 100644 index 0000000000..63e38bd18d --- /dev/null +++ b/studio/backend/core/data_recipe/jobs/worker.py @@ -0,0 +1,239 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +from __future__ import annotations + +import json +import structlog +import loggers +import logging +import re +import shutil +import time +import traceback +import unicodedata +from pathlib import Path +from typing import Any + +from ..jsonable import to_jsonable, to_preview_jsonable +from .constants import EVENT_JOB_COMPLETED, EVENT_JOB_ERROR, EVENT_JOB_STARTED +from ..service import build_config_builder, create_data_designer +from utils.paths import ensure_dir, recipe_datasets_root + +_ARTIFACT_ROOT = recipe_datasets_root() + + +class _QueueLogHandler(logging.Handler): + def __init__(self, event_queue): + super().__init__() + self._q = event_queue + + def emit(self, record: logging.LogRecord) -> None: + try: + event = { + "type": "log", + "ts": record.created, + "level": record.levelname, + "logger": record.name, + "message": record.getMessage(), + } + self._q.put(event) + except (OSError, RuntimeError, ValueError): + pass + + +def _slugify_run_name(value: str) -> str: + normalized = unicodedata.normalize("NFKD", value) + ascii_only = normalized.encode("ascii", "ignore").decode("ascii") + slug = re.sub(r"[^a-zA-Z0-9]+", "-", ascii_only).strip("-").lower() + if not slug: + return "" + return slug[:80].strip("-") + + +def _build_dataset_name( + *, run_name: str | None, job_id: str, artifact_root: Path +) -> str: + fallback = f"recipe_{job_id}" + slug = _slugify_run_name(run_name or "") + base_name = f"recipe_{slug}" if slug else fallback + candidate = base_name + suffix = 2 + while (artifact_root / candidate).exists(): + candidate = f"{base_name}_{suffix}" + suffix += 1 + return candidate + + +def run_job_process( + *, + event_queue, + recipe: dict[str, Any], + run: dict[str, Any], +) -> None: + """ + Subprocess entrypoint. + Sends events to `event_queue`. + """ + import os + + os.environ["PYTHONWARNINGS"] = ( + "ignore" # Suppress warnings at C-level before imports + ) + + import warnings + from loggers.config import LogConfig + + if os.getenv("ENVIRONMENT_TYPE", "production") == "production": + warnings.filterwarnings("ignore") + + LogConfig.setup_logging( + service_name = "unsloth-studio-data-worker", + env = os.getenv("ENVIRONMENT_TYPE", "production"), + ) + + event_queue.put({"type": EVENT_JOB_STARTED, "ts": time.time()}) + + try: + from data_designer.config.run_config import RunConfig + + rows = int(run.get("rows") or 1000) + job_id = str(run.get("_job_id") or "").strip() + if not job_id: + job_id = f"{int(time.time())}" + run_name_raw = run.get("run_name") + run_name = run_name_raw if isinstance(run_name_raw, str) else None + dataset_name = _build_dataset_name( + run_name = run_name, + job_id = job_id, + artifact_root = _ARTIFACT_ROOT, + ) + merge_batches = bool(run.get("merge_batches")) + ensure_dir(_ARTIFACT_ROOT) + run_config_raw = run.get("run_config") or {} + + builder = build_config_builder(recipe) + designer = create_data_designer(recipe, artifact_path = str(_ARTIFACT_ROOT)) + + # DataDesigner configures root logging in DataDesigner.__init__. + # Attach queue logger directly to `data_designer` so parser events survive root resets. + handler = _QueueLogHandler(event_queue) + handler.setLevel(logging.INFO) + data_designer_logger = logging.getLogger("data_designer") + data_designer_logger.addHandler(handler) + data_designer_logger.setLevel(logging.INFO) + data_designer_logger.propagate = True + + if run_config_raw: + designer.set_run_config(RunConfig.model_validate(run_config_raw)) + + execution_type = str(run.get("execution_type") or "full").strip().lower() + if execution_type == "preview": + results = designer.preview(builder, num_records = rows) + analysis = ( + None + if results.analysis is None + else to_jsonable(results.analysis.model_dump(mode = "json")) + ) + dataset = ( + [] + if results.dataset is None + else to_preview_jsonable(results.dataset.to_dict(orient = "records")) + ) + processor_artifacts = ( + None + if results.processor_artifacts is None + else to_jsonable(results.processor_artifacts) + ) + event_queue.put( + { + "type": EVENT_JOB_COMPLETED, + "ts": time.time(), + "analysis": analysis, + "dataset": dataset, + "processor_artifacts": processor_artifacts, + "artifact_path": None, + "execution_type": execution_type, + } + ) + else: + results = designer.create( + builder, num_records = rows, dataset_name = dataset_name + ) + analysis = to_jsonable(results.load_analysis().model_dump(mode = "json")) + if merge_batches: + _merge_batches_to_single_parquet( + results.artifact_storage.base_dataset_path + ) + artifact_path = str(results.artifact_storage.base_dataset_path) + event_queue.put( + { + "type": EVENT_JOB_COMPLETED, + "ts": time.time(), + "analysis": analysis, + "artifact_path": artifact_path, + "execution_type": execution_type, + } + ) + except Exception as exc: + event_queue.put( + { + "type": EVENT_JOB_ERROR, + "ts": time.time(), + "error": str(exc), + "stack": traceback.format_exc(limit = 20), + } + ) + + +def _merge_batches_to_single_parquet(base_dataset_path: Path) -> None: + parquet_dir = base_dataset_path / "parquet-files" + parquet_files = sorted(parquet_dir.glob("*.parquet")) + if len(parquet_files) <= 1: + return + + try: + from data_designer.config.utils.io_helpers import read_parquet_dataset + except ImportError: + return + + dataframe = read_parquet_dataset(parquet_dir) + shutil.rmtree(parquet_dir) + parquet_dir.mkdir(parents = True, exist_ok = True) + merged_file = parquet_dir / "batch_00000.parquet" + dataframe.to_parquet(merged_file, index = False) + _rewrite_merged_metadata( + base_dataset_path = base_dataset_path, + parquet_file = merged_file, + ) + + +def _rewrite_merged_metadata(*, base_dataset_path: Path, parquet_file: Path) -> None: + metadata_path = base_dataset_path / "metadata.json" + if not metadata_path.exists(): + return + + try: + metadata = json.loads(metadata_path.read_text(encoding = "utf-8")) + except (OSError, TypeError, ValueError): + return + + if not isinstance(metadata, dict): + return + + relative_parquet_path = str(parquet_file.relative_to(base_dataset_path)) + file_paths = metadata.get("file_paths") + if not isinstance(file_paths, dict): + file_paths = {} + file_paths["parquet-files"] = [relative_parquet_path] + metadata["file_paths"] = file_paths + metadata["total_num_batches"] = 1 + metadata["num_completed_batches"] = 1 + + try: + metadata_path.write_text( + json.dumps(metadata, indent = 2, sort_keys = True), + encoding = "utf-8", + ) + except OSError: + return diff --git a/studio/backend/core/data_recipe/jsonable.py b/studio/backend/core/data_recipe/jsonable.py new file mode 100644 index 0000000000..8bec60cf82 --- /dev/null +++ b/studio/backend/core/data_recipe/jsonable.py @@ -0,0 +1,121 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +from __future__ import annotations + +import base64 +import io +from pathlib import Path +from typing import Any + + +def _pil_to_preview_payload(image: Any) -> dict[str, Any]: + buffer = io.BytesIO() + image.convert("RGB").save(buffer, format = "JPEG", quality = 85) + return { + "type": "image", + "mime": "image/jpeg", + "width": image.width, + "height": image.height, + "data": base64.b64encode(buffer.getvalue()).decode("ascii"), + } + + +def _open_pil_image_from_bytes(raw_bytes: bytes): + from PIL import Image # type: ignore + + with Image.open(io.BytesIO(raw_bytes)) as image: + return image.copy() + + +def _to_pil_from_hf_image_dict(value: Any) -> Any | None: + if not isinstance(value, dict): + return None + + raw_bytes = value.get("bytes") + if isinstance(raw_bytes, (bytes, bytearray)) and len(raw_bytes) > 0: + try: + return _open_pil_image_from_bytes(bytes(raw_bytes)) + except (OSError, ValueError): + pass + if ( + isinstance(raw_bytes, list) + and len(raw_bytes) > 0 + and all(isinstance(item, int) and 0 <= item <= 255 for item in raw_bytes) + ): + try: + return _open_pil_image_from_bytes(bytes(raw_bytes)) + except (OSError, ValueError): + pass + + path_value = value.get("path") + if isinstance(path_value, str) and path_value.strip(): + try: + from PIL import Image # type: ignore + + with Image.open(Path(path_value)) as image: + return image.copy() + except (OSError, ValueError, TypeError): + return None + + return None + + +def to_jsonable(value: Any) -> Any: + """Convert numpy/pandas-ish values into plain JSON-safe values.""" + try: + import numpy as np # type: ignore + except ImportError: # pragma: no cover + np = None # type: ignore + + if np is not None: + if isinstance(value, np.ndarray): + return value.tolist() + if isinstance(value, np.generic): + return value.item() + + if isinstance(value, dict): + return {str(k): to_jsonable(v) for k, v in value.items()} + if isinstance(value, (list, tuple, set)): + return [to_jsonable(v) for v in value] + + if hasattr(value, "isoformat") and callable(value.isoformat): + try: + return value.isoformat() + except (TypeError, ValueError): + return value + + return value + + +def _to_preview_image_payload(value: Any) -> dict[str, Any] | None: + try: + from PIL.Image import Image as PILImage # type: ignore + except ImportError: # pragma: no cover + return None + + if not isinstance(value, PILImage): + hf_image = _to_pil_from_hf_image_dict(value) + if hf_image is None: + return None + value = hf_image + + return _pil_to_preview_payload(value) + + +def to_preview_jsonable(value: Any) -> Any: + """Convert values into JSON-safe preview values, including PIL images.""" + image_payload = _to_preview_image_payload(value) + if image_payload is not None: + return image_payload + + converted = to_jsonable(value) + if converted is None or isinstance(converted, (str, int, float, bool)): + return converted + if isinstance(converted, dict): + return {str(k): to_preview_jsonable(v) for k, v in converted.items()} + if isinstance(converted, (list, tuple, set)): + return [to_preview_jsonable(v) for v in converted] + if isinstance(converted, (bytes, bytearray)): + return base64.b64encode(bytes(converted)).decode("ascii") + return str(converted) diff --git a/studio/backend/core/data_recipe/local_callable_validators.py b/studio/backend/core/data_recipe/local_callable_validators.py new file mode 100644 index 0000000000..c32b2fccaf --- /dev/null +++ b/studio/backend/core/data_recipe/local_callable_validators.py @@ -0,0 +1,340 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +from __future__ import annotations + +import json +import os +import structlog +import subprocess +from copy import deepcopy +from dataclasses import dataclass +from functools import lru_cache +from pathlib import Path +from typing import Any + +from loggers import get_logger +from utils.paths import ensure_dir, oxc_validator_tmp_root + +logger = get_logger(__name__) + +OXC_VALIDATION_FN_MARKER = "unsloth_oxc_validator" + +_OXC_LANG_TO_NODE_LANG = { + "javascript": "js", + "typescript": "ts", + "jsx": "jsx", + "tsx": "tsx", +} +_OXC_VALIDATION_MODES = {"syntax", "lint", "syntax+lint"} +_OXC_CODE_SHAPES = {"auto", "module", "snippet"} + +_OXC_TOOL_DIR = Path(__file__).resolve().parent / "oxc-validator" +_OXC_RUNNER_PATH = _OXC_TOOL_DIR / "validate.mjs" + + +@dataclass(frozen = True) +class OxcLocalCallableValidatorSpec: + name: str + drop: bool + target_columns: list[str] + batch_size: int + code_lang: str + validation_mode: str + code_shape: str + + +def split_oxc_local_callable_validators( + recipe_core: dict[str, Any], +) -> tuple[dict[str, Any], list[OxcLocalCallableValidatorSpec]]: + columns = recipe_core.get("columns") + if not isinstance(columns, list): + return recipe_core, [] + + sanitized = deepcopy(recipe_core) + sanitized_columns = sanitized.get("columns") + if not isinstance(sanitized_columns, list): + return sanitized, [] + + kept_columns: list[Any] = [] + oxc_specs: list[OxcLocalCallableValidatorSpec] = [] + + for column in sanitized_columns: + if not isinstance(column, dict): + kept_columns.append(column) + continue + + maybe_spec = _parse_oxc_spec(column = column) + if maybe_spec is None: + kept_columns.append(column) + continue + oxc_specs.append(maybe_spec) + + sanitized["columns"] = kept_columns + return sanitized, oxc_specs + + +def register_oxc_local_callable_validators( + *, + builder, + specs: list[OxcLocalCallableValidatorSpec], +) -> None: + if not specs: + return + + from data_designer.config.column_configs import ValidationColumnConfig + from data_designer.config.validator_params import ( + LocalCallableValidatorParams, + ValidatorType, + ) + + for spec in specs: + validation_function = _build_oxc_validation_function( + spec.code_lang, + spec.validation_mode, + spec.code_shape, + ) + builder.add_column( + ValidationColumnConfig( + name = spec.name, + drop = spec.drop, + target_columns = spec.target_columns, + validator_type = ValidatorType.LOCAL_CALLABLE, + validator_params = LocalCallableValidatorParams( + validation_function = validation_function, + ), + batch_size = spec.batch_size, + ) + ) + + +def _parse_oxc_spec( + *, + column: dict[str, Any], +) -> OxcLocalCallableValidatorSpec | None: + if str(column.get("column_type") or "").strip() != "validation": + return None + if str(column.get("validator_type") or "").strip() != "local_callable": + return None + + params = column.get("validator_params") + if not isinstance(params, dict): + return None + + fn_raw = params.get("validation_function") + fn_name = fn_raw.strip() if isinstance(fn_raw, str) else "" + if not fn_name.startswith(OXC_VALIDATION_FN_MARKER): + return None + + name = str(column.get("name") or "").strip() + if not name: + return None + + target_columns_raw = column.get("target_columns") + target_columns = ( + [ + value.strip() + for value in target_columns_raw + if isinstance(value, str) and value.strip() + ] + if isinstance(target_columns_raw, list) + else [] + ) + if not target_columns: + return None + + code_lang, validation_mode, code_shape = _parse_oxc_validation_marker(fn_name) + batch_size = _parse_batch_size(column.get("batch_size")) + drop = bool(column.get("drop") is True) + + return OxcLocalCallableValidatorSpec( + name = name, + drop = drop, + target_columns = target_columns, + batch_size = batch_size, + code_lang = code_lang, + validation_mode = validation_mode, + code_shape = code_shape, + ) + + +def _parse_batch_size(value: Any) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + return 10 + return parsed if parsed >= 1 else 10 + + +def _parse_oxc_validation_marker(fn_name: str) -> tuple[str, str, str]: + marker = f"{OXC_VALIDATION_FN_MARKER}:" + if not fn_name.startswith(marker): + return "javascript", "syntax", "auto" + suffix = fn_name[len(marker) :] + parts = [part.strip() for part in suffix.split(":") if part.strip()] + if len(parts) < 2: + return "javascript", "syntax", "auto" + code_lang = parts[0] if parts[0] in _OXC_LANG_TO_NODE_LANG else "javascript" + mode = parts[1] if parts[1] in _OXC_VALIDATION_MODES else "syntax" + code_shape = ( + parts[2] if len(parts) >= 3 and parts[2] in _OXC_CODE_SHAPES else "auto" + ) + return code_lang, mode, code_shape + + +@lru_cache(maxsize = 8) +def _build_oxc_validation_function(lang: str, validation_mode: str, code_shape: str): + node_lang = _OXC_LANG_TO_NODE_LANG.get(lang, "js") + mode = validation_mode if validation_mode in _OXC_VALIDATION_MODES else "syntax" + normalized_code_shape = code_shape if code_shape in _OXC_CODE_SHAPES else "auto" + + def _validator(df): + import pandas as pd # imported lazily for local callable runtime + + row_count = int(len(df.index)) + if row_count == 0: + return pd.DataFrame({"is_valid": []}) + + code_column = str(df.columns[0]) if len(df.columns) > 0 else "" + code_values = ( + ["" for _ in range(row_count)] + if not code_column + else [ + "" if value is None else str(value) + for value in df[code_column].tolist() + ] + ) + + results = _run_oxc_batch( + node_lang = node_lang, + validation_mode = mode, + code_shape = normalized_code_shape, + code_values = code_values, + ) + if len(results) != row_count: + results = _fallback_results( + row_count, + "OXC validator returned mismatched result size.", + ) + return pd.DataFrame(results) + + _validator.__name__ = f"{OXC_VALIDATION_FN_MARKER}_{node_lang}_{mode.replace('+', '_')}_{normalized_code_shape}" + return _validator + + +def _run_oxc_batch( + *, + node_lang: str, + validation_mode: str, + code_shape: str, + code_values: list[str], +) -> list[dict[str, Any]]: + if not _OXC_RUNNER_PATH.exists(): + return _fallback_results( + len(code_values), + f"OXC runner missing at {_OXC_RUNNER_PATH}", + ) + + payload = { + "lang": node_lang, + "mode": validation_mode, + "code_shape": code_shape, + "codes": code_values, + } + try: + tmp_dir = ensure_dir(oxc_validator_tmp_root()) + env = dict(os.environ) + tmp_dir_str = str(tmp_dir) + env["TMPDIR"] = tmp_dir_str + env["TMP"] = tmp_dir_str + env["TEMP"] = tmp_dir_str + proc = subprocess.run( + ["node", str(_OXC_RUNNER_PATH)], + cwd = str(_OXC_TOOL_DIR), + input = json.dumps(payload), + text = True, + capture_output = True, + check = False, + env = env, + ) + except (OSError, ValueError) as exc: + logger.warning("OXC subprocess launch failed: %s", exc) + return _fallback_results(len(code_values), f"OXC launch failed: {exc}") + + if proc.returncode != 0: + message = (proc.stderr or proc.stdout or "unknown error").strip() + if len(message) > 300: + message = f"{message[:300]}..." + return _fallback_results(len(code_values), f"OXC failed: {message}") + + try: + raw = json.loads(proc.stdout) + except json.JSONDecodeError: + return _fallback_results(len(code_values), "OXC output parse failed.") + + if not isinstance(raw, list): + return _fallback_results(len(code_values), "OXC output must be an array.") + + out: list[dict[str, Any]] = [] + for item in raw: + if not isinstance(item, dict): + out.append( + { + "is_valid": False, + "error_count": 1, + "error_message": "Invalid OXC result entry.", + "severity": None, + "code": None, + "labels": [], + "codeframe": None, + "warning_count": 0, + } + ) + continue + is_valid_raw = item.get("is_valid") + error_count_raw = item.get("error_count") + message_raw = item.get("error_message") + severity_raw = item.get("severity") + code_raw = item.get("code") + labels_raw = item.get("labels") + codeframe_raw = item.get("codeframe") + warning_count_raw = item.get("warning_count") + out.append( + { + "is_valid": bool(is_valid_raw) + if isinstance(is_valid_raw, bool) + else False, + "error_count": int(error_count_raw) + if isinstance(error_count_raw, int) + else 0, + "error_message": str(message_raw or ""), + "severity": str(severity_raw) + if isinstance(severity_raw, str) + else None, + "code": str(code_raw) if isinstance(code_raw, str) else None, + "labels": labels_raw if isinstance(labels_raw, list) else [], + "codeframe": str(codeframe_raw) + if isinstance(codeframe_raw, str) + else None, + "warning_count": int(warning_count_raw) + if isinstance(warning_count_raw, int) + else 0, + } + ) + return out + + +def _fallback_results(row_count: int, message: str) -> list[dict[str, Any]]: + return [ + { + "is_valid": False, + "error_count": 1, + "error_message": message, + "severity": None, + "code": None, + "labels": [], + "codeframe": None, + "warning_count": 0, + } + for _ in range(row_count) + ] diff --git a/studio/backend/core/data_recipe/oxc-validator/package-lock.json b/studio/backend/core/data_recipe/oxc-validator/package-lock.json new file mode 100644 index 0000000000..0e16cc4187 --- /dev/null +++ b/studio/backend/core/data_recipe/oxc-validator/package-lock.json @@ -0,0 +1,794 @@ +{ + "name": "unsloth-oxc-validator-runtime", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "unsloth-oxc-validator-runtime", + "version": "0.0.1", + "dependencies": { + "oxc-parser": "^0.116.0", + "oxlint": "^1.51.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", + "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", + "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@oxc-parser/binding-android-arm-eabi": { + "version": "0.116.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.116.0.tgz", + "integrity": "sha512-AOET7YIOU3+ANO/3xQeRVGN5Xx6+JGXaIwlqkcHSfxJ/zzw2B6jb0YaLhX45SeRluKVTU8rka4N/tHtNoJjoCg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-android-arm64": { + "version": "0.116.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.116.0.tgz", + "integrity": "sha512-yh0Zvth5cQ6XZkP3QF9MDrXf695zr5XxXq/wBQqpZb0uAgI9wpr98/Hx2RZITMfnNjkIq2VcyU44o3A0bdEmlQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-arm64": { + "version": "0.116.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.116.0.tgz", + "integrity": "sha512-plcTd/Jska55dToZz6XdRBPRVsj+asjD8QCpQFvt3Wj8pY+10D1pE53Mei3POAS/wSRSy7HiQ2twrm7H2A0CjA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-x64": { + "version": "0.116.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.116.0.tgz", + "integrity": "sha512-ahqcF3e3x5Z2ZepzXpZ8ugREdmxvBL+g1nQ0SxO11pIZfck6UtbOtwtdAAxnQXBHHtidu7lPcrBq1SEx26t1PQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-freebsd-x64": { + "version": "0.116.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.116.0.tgz", + "integrity": "sha512-yo2/LaSXtlzKBurvNbwun/sN/RJwW3XhbMr069FwNVtft7GBnaLLdPIz/sf47icxw/BPViEX6wFvzeD12mtrAg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-gnueabihf": { + "version": "0.116.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.116.0.tgz", + "integrity": "sha512-EiZeliIPPdFsuaPx8PzDMVijD/4YaUxO46/eYPk5raRocJqjjxOG6GAacQ8UrG3fbrgYjaEChfYL1e8DyE445A==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-musleabihf": { + "version": "0.116.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.116.0.tgz", + "integrity": "sha512-Nf7hnKRYRSIgglQcLAqE2St4b/Yr6dh+Z7in8mxol065Knevw71XZAiV1fmPSojq6uKPLV9eoH/wFrgr4TnZXw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-gnu": { + "version": "0.116.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.116.0.tgz", + "integrity": "sha512-9SJI0S4Qggn3QHpT8Y1jtZceA0m4BlpvO3ne2Wxd33UdTHMmelAnrXryjWutHWQtjCzOwSnFBEoQAdNNyt1u3A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-musl": { + "version": "0.116.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.116.0.tgz", + "integrity": "sha512-wMZ6//GI+q1JwO7G2OR51+eA5P8Gr3BobU8RAzCGJptvyGMkWb7KQ1E8s8naVZRr6bSGWAL2p3mCzKOxmEPmrA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-ppc64-gnu": { + "version": "0.116.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.116.0.tgz", + "integrity": "sha512-5BO0KCzTG2HZTnp3r6SCAOeCs/GwFBQJ1WAOG/ROfDf1fVVEy6hrtLKTLCuUMaamH38v+1+RVEmzRkzBj+rMDQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-gnu": { + "version": "0.116.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.116.0.tgz", + "integrity": "sha512-M24gYb/ocVMnLwnH2wY5sLt4sRBkAUHDmfiYtyUYdKTkfPOKtpopd5otsL/BPLnIhpMD8zby4uXVvw7BU0UIlw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-musl": { + "version": "0.116.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.116.0.tgz", + "integrity": "sha512-LHLXTHCH0bdvGjlitwr1ngeh32GAgq9HYzQ5VAgt0k0UT84AS8AkXj9Spoa9l20fXkVgSvAKcCEkydi4Ol23Dw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-s390x-gnu": { + "version": "0.116.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.116.0.tgz", + "integrity": "sha512-VE+XsztuE5jdHvLIDIQMuyDpz5NJGq1Vx/8EXYF0sS/gehlv9GhDpGVWU0SCZ/LjzIy4io/Z0W84UudqufvP3g==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-gnu": { + "version": "0.116.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.116.0.tgz", + "integrity": "sha512-rxUkauyjjCmgA7BoR63ogRGEtgubROnCm8AXE9ydg+p42jCGLLqG05mFcS2eC+FYyAU58ZFJNXXeqFW1iCyTGQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-musl": { + "version": "0.116.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.116.0.tgz", + "integrity": "sha512-0zoZlk9MmXe6oTgSh5lT1D51SDC1bfwC96JmE1amMFAPdEbJk5MFRisfTN9TFBpBigQua65842tjaxqMiorAYw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-openharmony-arm64": { + "version": "0.116.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.116.0.tgz", + "integrity": "sha512-PGS7Xqik77U9WMyW626gAD5A2rSN629UvyYJKAl/tgpT+KqZI4+56pJfExhv8IW/PpSHjYHwjmakwobLikz8ww==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-wasm32-wasi": { + "version": "0.116.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.116.0.tgz", + "integrity": "sha512-lGNf/9PU8XxB4Gt1Gr1AKwSrjxGYa6os0PlrT4bpoQsfE3gaZonQTKwJyKhiQdgy7pBCI+ed1LB1NNib1FYULw==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@oxc-parser/binding-win32-arm64-msvc": { + "version": "0.116.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.116.0.tgz", + "integrity": "sha512-tcsOHE31duBSRQXZ7NfdtjmMKZwQYlS00PwAMJ4w5oXs3iPCvisUuIXP7Ko4FzeOBTRvkd64btxtQ6cRM0Kwlw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-ia32-msvc": { + "version": "0.116.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.116.0.tgz", + "integrity": "sha512-higCz/x+dOQ264YEk22hnu4RDqvjhfehjFORpxoh42QyUxsP6eIembYesBUu5ilALWo0HvRD+m89az2BSTwqpQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-x64-msvc": { + "version": "0.116.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.116.0.tgz", + "integrity": "sha512-Lg2SRmVHpGG85knDVLbv44r1bYn0OpIV0vg9jVmoEIpDj3Q4kwXuQ6MWVtuslwHR8o2CSiqdBeEn1n1URrs6Eg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.116.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.116.0.tgz", + "integrity": "sha512-uOT8S1tlPmDckNxMNtIudN/yXpLdnhlJMX2oLS7cxCd7L0sUF09A/EbSVMWT3Y/iT44IwXCJSJfgfSxXAqWf9Q==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.51.0.tgz", + "integrity": "sha512-jJYIqbx4sX+suIxWstc4P7SzhEwb4ArWA2KVrmEuu9vH2i0qM6QIHz/ehmbGE4/2fZbpuMuBzTl7UkfNoqiSgw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.51.0.tgz", + "integrity": "sha512-GtXyBCcH4ti98YdiMNCrpBNGitx87EjEWxevnyhcBK12k/Vu4EzSB45rzSC4fGFUD6sQgeaxItRCEEWeVwPafw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.51.0.tgz", + "integrity": "sha512-3QJbeYaMHn6Bh2XeBXuITSsbnIctyTjvHf5nRjKYrT9pPeErNIpp5VDEeAXC0CZSwSVTsc8WOSDwgrAI24JolQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.51.0.tgz", + "integrity": "sha512-NzErhMaTEN1cY0E8C5APy74lw5VwsNfJfVPBMWPVQLqAbO0k4FFLjvHURvkUL+Y18Wu+8Vs1kbqPh2hjXYA4pg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.51.0.tgz", + "integrity": "sha512-msAIh3vPAoKoHlOE/oe6Q5C/n9umypv/k81lED82ibrJotn+3YG2Qp1kiR8o/Dg5iOEU97c6tl0utxcyFenpFw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.51.0.tgz", + "integrity": "sha512-CqQPcvqYyMe9ZBot2stjGogEzk1z8gGAngIX7srSzrzexmXixwVxBdFZyxTVM0CjGfDeV+Ru0w25/WNjlMM2Hw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.51.0.tgz", + "integrity": "sha512-dstrlYQgZMnyOssxSbolGCge/sDbko12N/35RBNuqLpoPbft2aeBidBAb0dvQlyBd9RJ6u8D4o4Eh8Un6iTgyQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.51.0.tgz", + "integrity": "sha512-QEjUpXO7d35rP1/raLGGbAsBLLGZIzV3ZbeSjqWlD3oRnxpRIZ6iL4o51XQHkconn3uKssc+1VKdtHJ81BBhDA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.51.0.tgz", + "integrity": "sha512-YSJua5irtG4DoMAjUapDTPhkQLHhBIY0G9JqlZS6/SZPzqDkPku/1GdWs0D6h/wyx0Iz31lNCfIaWKBQhzP0wQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.51.0.tgz", + "integrity": "sha512-7L4Wj2IEUNDETKssB9IDYt16T6WlF+X2jgC/hBq3diGHda9vJLpAgb09+D3quFq7TdkFtI7hwz/jmuQmQFPc1Q==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.51.0.tgz", + "integrity": "sha512-cBUHqtOXy76G41lOB401qpFoKx1xq17qYkhWrLSM7eEjiHM9sOtYqpr6ZdqCnN9s6ZpzudX4EkeHOFH2E9q0vA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.51.0.tgz", + "integrity": "sha512-WKbg8CysgZcHfZX0ixQFBRSBvFZUHa3SBnEjHY2FVYt2nbNJEjzTxA3ZR5wMU0NOCNKIAFUFvAh5/XJKPRJuJg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.51.0.tgz", + "integrity": "sha512-N1QRUvJTxqXNSu35YOufdjsAVmKVx5bkrggOWAhTWBc3J4qjcBwr1IfyLh/6YCg8sYRSR1GraldS9jUgJL/U4A==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.51.0.tgz", + "integrity": "sha512-e0Mz0DizsCoqNIjeOg6OUKe8JKJWZ5zZlwsd05Bmr51Jo3AOL4UJnPvwKumr4BBtBrDZkCmOLhCvDGm95nJM2g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.51.0.tgz", + "integrity": "sha512-wD8HGTWhYBKXvRDvoBVB1y+fEYV01samhWQSy1Zkxq2vpezvMnjaFKRuiP6tBNITLGuffbNDEXOwcAhJ3gI5Ug==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.51.0.tgz", + "integrity": "sha512-5NSwQ2hDEJ0GPXqikjWtwzgAQCsS7P9aLMNenjjKa+gknN3lTCwwwERsT6lKXSirfU3jLjexA2XQvQALh5h27w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.51.0.tgz", + "integrity": "sha512-JEZyah1M0RHMw8d+jjSSJmSmO8sABA1J1RtrHYujGPeCkYg1NeH0TGuClpe2h5QtioRTaF57y/TZfn/2IFV6fA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.51.0.tgz", + "integrity": "sha512-q3cEoKH6kwjz/WRyHwSf0nlD2F5Qw536kCXvmlSu+kaShzgrA0ojmh45CA81qL+7udfCaZL2SdKCZlLiGBVFlg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.51.0.tgz", + "integrity": "sha512-Q14+fOGb9T28nWF/0EUsYqERiRA7cl1oy4TJrGmLaqhm+aO2cV+JttboHI3CbdeMCAyDI1+NoSlrM7Melhp/cw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/oxc-parser": { + "version": "0.116.0", + "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.116.0.tgz", + "integrity": "sha512-ugEo6wwqaqCGcpi7GsLCwSkoD7gIXzvtdaTxE+mbrXFYazU5Q9YdpZdAj9z2b79i/xlv+uW2aAvyzGAlpUzhKQ==", + "license": "MIT", + "dependencies": { + "@oxc-project/types": "^0.116.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-parser/binding-android-arm-eabi": "0.116.0", + "@oxc-parser/binding-android-arm64": "0.116.0", + "@oxc-parser/binding-darwin-arm64": "0.116.0", + "@oxc-parser/binding-darwin-x64": "0.116.0", + "@oxc-parser/binding-freebsd-x64": "0.116.0", + "@oxc-parser/binding-linux-arm-gnueabihf": "0.116.0", + "@oxc-parser/binding-linux-arm-musleabihf": "0.116.0", + "@oxc-parser/binding-linux-arm64-gnu": "0.116.0", + "@oxc-parser/binding-linux-arm64-musl": "0.116.0", + "@oxc-parser/binding-linux-ppc64-gnu": "0.116.0", + "@oxc-parser/binding-linux-riscv64-gnu": "0.116.0", + "@oxc-parser/binding-linux-riscv64-musl": "0.116.0", + "@oxc-parser/binding-linux-s390x-gnu": "0.116.0", + "@oxc-parser/binding-linux-x64-gnu": "0.116.0", + "@oxc-parser/binding-linux-x64-musl": "0.116.0", + "@oxc-parser/binding-openharmony-arm64": "0.116.0", + "@oxc-parser/binding-wasm32-wasi": "0.116.0", + "@oxc-parser/binding-win32-arm64-msvc": "0.116.0", + "@oxc-parser/binding-win32-ia32-msvc": "0.116.0", + "@oxc-parser/binding-win32-x64-msvc": "0.116.0" + } + }, + "node_modules/oxlint": { + "version": "1.51.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.51.0.tgz", + "integrity": "sha512-g6DNPaV9/WI9MoX2XllafxQuxwY1TV++j7hP8fTJByVBuCoVtm3dy9f/2vtH/HU40JztcgWF4G7ua+gkainklQ==", + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.51.0", + "@oxlint/binding-android-arm64": "1.51.0", + "@oxlint/binding-darwin-arm64": "1.51.0", + "@oxlint/binding-darwin-x64": "1.51.0", + "@oxlint/binding-freebsd-x64": "1.51.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.51.0", + "@oxlint/binding-linux-arm-musleabihf": "1.51.0", + "@oxlint/binding-linux-arm64-gnu": "1.51.0", + "@oxlint/binding-linux-arm64-musl": "1.51.0", + "@oxlint/binding-linux-ppc64-gnu": "1.51.0", + "@oxlint/binding-linux-riscv64-gnu": "1.51.0", + "@oxlint/binding-linux-riscv64-musl": "1.51.0", + "@oxlint/binding-linux-s390x-gnu": "1.51.0", + "@oxlint/binding-linux-x64-gnu": "1.51.0", + "@oxlint/binding-linux-x64-musl": "1.51.0", + "@oxlint/binding-openharmony-arm64": "1.51.0", + "@oxlint/binding-win32-arm64-msvc": "1.51.0", + "@oxlint/binding-win32-ia32-msvc": "1.51.0", + "@oxlint/binding-win32-x64-msvc": "1.51.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=0.15.0" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + } + } +} diff --git a/studio/backend/core/data_recipe/oxc-validator/package.json b/studio/backend/core/data_recipe/oxc-validator/package.json new file mode 100644 index 0000000000..a47c0ea521 --- /dev/null +++ b/studio/backend/core/data_recipe/oxc-validator/package.json @@ -0,0 +1,10 @@ +{ + "name": "unsloth-oxc-validator-runtime", + "private": true, + "version": "0.0.1", + "type": "module", + "dependencies": { + "oxc-parser": "^0.116.0", + "oxlint": "^1.51.0" + } +} diff --git a/studio/backend/core/data_recipe/oxc-validator/validate.mjs b/studio/backend/core/data_recipe/oxc-validator/validate.mjs new file mode 100644 index 0000000000..ad61fb5a9e --- /dev/null +++ b/studio/backend/core/data_recipe/oxc-validator/validate.mjs @@ -0,0 +1,576 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseSync } from "oxc-parser"; + +const LANG_TO_EXT = { + js: "js", + jsx: "jsx", + ts: "ts", + tsx: "tsx", +}; + +const VALIDATION_MODES = new Set(["syntax", "lint", "syntax+lint"]); +const CODE_SHAPES = new Set(["auto", "module", "snippet"]); +const SNIPPET_PREFIX = "(() => {\n"; +const SNIPPET_SUFFIX = "\n})();\nexport {};\n"; +const OXLINT_SUPPRESSED_RULES = ["no-unused-vars", "no-new-array"]; +const TOOL_DIR = dirname(fileURLToPath(import.meta.url)); + +function mapLang(value) { + const normalized = String(value || "").trim().toLowerCase(); + if (normalized === "javascript" || normalized === "js") { + return "js"; + } + if (normalized === "typescript" || normalized === "ts") { + return "ts"; + } + if (normalized === "jsx") { + return "jsx"; + } + if (normalized === "tsx") { + return "tsx"; + } + return "js"; +} + +function mapMode(value) { + const normalized = String(value || "").trim().toLowerCase(); + if (VALIDATION_MODES.has(normalized)) { + return normalized; + } + return "syntax"; +} + +function mapCodeShape(value) { + const normalized = String(value || "").trim().toLowerCase(); + if (CODE_SHAPES.has(normalized)) { + return normalized; + } + return "auto"; +} + +function parseFileIndex(filePath) { + if (typeof filePath !== "string") { + return null; + } + const match = basename(filePath).match(/^snippet_(\d+)\./); + if (!match) { + return null; + } + const parsed = Number.parseInt(match[1], 10); + return Number.isFinite(parsed) ? parsed : null; +} + +function toCodeString(code) { + return typeof code === "string" ? code : String(code ?? ""); +} + +function makeValidationEntry({ code, index, lang, codeShape }) { + const source = toCodeString(code); + if (codeShape === "snippet") { + return { + index, + lang, + code: `${SNIPPET_PREFIX}${source}${SNIPPET_SUFFIX}`, + offset: SNIPPET_PREFIX.length, + }; + } + return { + index, + lang, + code: source, + offset: 0, + }; +} + +function shiftOffset(value, offset) { + if (!Number.isInteger(value)) { + return null; + } + const shifted = value - offset; + return shifted >= 0 ? shifted : null; +} + +function remapDiagnosticOffsets(diagnostic, offset) { + if (!diagnostic || typeof diagnostic !== "object" || offset <= 0) { + return diagnostic; + } + return { + ...diagnostic, + labels: Array.isArray(diagnostic.labels) + ? diagnostic.labels.map((label) => ({ + ...label, + start: shiftOffset(label.start, offset), + end: shiftOffset(label.end, offset), + })) + : [], + }; +} + +function normalizeParserError(error) { + if (typeof error === "string") { + return { + code: null, + message: error.trim() || "Unknown parser error", + severity: null, + labels: [], + codeframe: null, + }; + } + if (!error || typeof error !== "object") { + return { + code: null, + message: "Unknown parser error", + severity: null, + labels: [], + codeframe: null, + }; + } + const code = typeof error.code === "string" ? error.code : null; + const message = String(error.message || error.reason || "").trim() || "Unknown parser error"; + const severity = typeof error.severity === "string" ? error.severity : null; + const labels = Array.isArray(error.labels) + ? error.labels.map((label) => ({ + message: + label && typeof label === "object" && typeof label.message === "string" + ? label.message + : null, + start: + label && typeof label === "object" && Number.isInteger(label.start) + ? label.start + : null, + end: + label && typeof label === "object" && Number.isInteger(label.end) + ? label.end + : null, + })) + : []; + const codeframe = typeof error.codeframe === "string" ? error.codeframe : null; + return { + code, + message, + severity, + labels, + codeframe, + }; +} + +function normalizeLintDiagnostic(diagnostic) { + if (!diagnostic || typeof diagnostic !== "object") { + return null; + } + + const readString = (value) => + typeof value === "string" ? value : null; + const readInt = (value) => + Number.isInteger(value) ? value : null; + const asObject = (value) => + value && typeof value === "object" ? value : null; + + const message = String(diagnostic.message || "").trim(); + if (!message) { + return null; + } + + const severityRaw = String(diagnostic.severity || "").trim().toLowerCase(); + const severity = severityRaw === "error" ? "error" : "warning"; + + const labels = []; + if (Array.isArray(diagnostic.labels)) { + for (const label of diagnostic.labels) { + const labelObj = asObject(label); + const span = asObject(labelObj?.span); + const start = readInt(span?.offset); + const length = readInt(span?.length); + labels.push({ + message: readString(labelObj?.label), + start, + end: start !== null && length !== null ? start + length : null, + }); + } + } + + const code = typeof diagnostic.code === "string" ? diagnostic.code : null; + return { + code, + message: code ? `${code}: ${message}` : message, + severity, + labels, + codeframe: null, + }; +} + +function makeResult({ + isValid, + errorCount, + warningCount = 0, + message = "", + severity = null, + code = null, + labels = [], + codeframe = null, +}) { + return { + is_valid: Boolean(isValid), + error_count: Number.isInteger(errorCount) ? errorCount : 0, + warning_count: Number.isInteger(warningCount) ? warningCount : 0, + error_message: String(message || ""), + severity: typeof severity === "string" ? severity : null, + code: typeof code === "string" ? code : null, + labels: Array.isArray(labels) ? labels : [], + codeframe: typeof codeframe === "string" ? codeframe : null, + }; +} + +function syntaxResultFromErrors(errors) { + const first = errors[0] ?? null; + return makeResult({ + isValid: errors.length === 0, + errorCount: errors.length, + warningCount: 0, + message: errors.slice(0, 3).map((error) => error.message).join(" | "), + severity: first ? first.severity : null, + code: first ? first.code : null, + labels: first ? first.labels : [], + codeframe: first ? first.codeframe : null, + }); +} + +function runSyntaxParse(entry) { + const ext = LANG_TO_EXT[entry.lang] ?? "js"; + const filename = `snippet_${entry.index}.${ext}`; + try { + const parsed = parseSync(filename, entry.code, { + lang: entry.lang, + sourceType: "module", + showSemanticErrors: true, + }); + const errors = Array.isArray(parsed?.errors) + ? parsed.errors + .map(normalizeParserError) + .filter(Boolean) + .map((error) => remapDiagnosticOffsets(error, entry.offset)) + : []; + return errors; + } catch (error) { + return [ + remapDiagnosticOffsets( + normalizeParserError(error), + entry.offset, + ), + ]; + } +} + +function pickPreferredErrorList(firstErrors, secondErrors) { + if (secondErrors.length < firstErrors.length) { + return secondErrors; + } + return firstErrors; +} + +function validateSyntaxOne({ code, lang, index, codeShape }) { + if (codeShape !== "auto") { + const lintEntry = makeValidationEntry({ + code, + index, + lang, + codeShape, + }); + const errors = runSyntaxParse(lintEntry); + return { + result: syntaxResultFromErrors(errors), + lintEntry, + }; + } + + const moduleEntry = makeValidationEntry({ + code, + index, + lang, + codeShape: "module", + }); + const moduleErrors = runSyntaxParse(moduleEntry); + if (moduleErrors.length === 0) { + return { + result: syntaxResultFromErrors(moduleErrors), + lintEntry: moduleEntry, + }; + } + + const snippetEntry = makeValidationEntry({ + code, + index, + lang, + codeShape: "snippet", + }); + const snippetErrors = runSyntaxParse(snippetEntry); + if (snippetErrors.length === 0) { + return { + result: syntaxResultFromErrors(snippetErrors), + lintEntry: snippetEntry, + }; + } + + const chosenErrors = pickPreferredErrorList(moduleErrors, snippetErrors); + const lintEntry = chosenErrors === snippetErrors ? snippetEntry : moduleEntry; + return { + result: syntaxResultFromErrors(chosenErrors), + lintEntry, + }; +} + +function resolveLintEntry({ code, lang, index, codeShape }) { + if (codeShape !== "auto") { + return makeValidationEntry({ + code, + index, + lang, + codeShape, + }); + } + + const moduleEntry = makeValidationEntry({ + code, + index, + lang, + codeShape: "module", + }); + if (runSyntaxParse(moduleEntry).length === 0) { + return moduleEntry; + } + + const snippetEntry = makeValidationEntry({ + code, + index, + lang, + codeShape: "snippet", + }); + if (runSyntaxParse(snippetEntry).length === 0) { + return snippetEntry; + } + + return moduleEntry; +} + +function fallbackLintResults(entries, message) { + return new Map( + entries.map((entry) => [ + entry.index, + makeResult({ + isValid: false, + errorCount: 1, + warningCount: 0, + message, + severity: "error", + }), + ]), + ); +} + +function runLintBatch(entries) { + if (entries.length === 0) { + return new Map(); + } + + const entryByIndex = new Map(entries.map((entry) => [entry.index, entry])); + const tempDir = mkdtempSync(join(tmpdir(), "oxlint-")); + try { + for (const entry of entries) { + const ext = LANG_TO_EXT[entry.lang] ?? "js"; + const filePath = join(tempDir, `snippet_${entry.index}.${ext}`); + writeFileSync(filePath, entry.code, "utf8"); + } + + const oxlintBin = join(TOOL_DIR, "node_modules", ".bin", "oxlint"); + const oxlintArgs = [ + ...OXLINT_SUPPRESSED_RULES.flatMap((rule) => ["-A", rule]), + "--format", + "json", + tempDir, + ]; + const exec = spawnSync(oxlintBin, oxlintArgs, { + encoding: "utf8", + cwd: TOOL_DIR, + }); + if (exec.error) { + return fallbackLintResults( + entries, + `oxlint execution failed: ${exec.error.message}`, + ); + } + const stdout = String(exec.stdout || "").trim(); + if (!stdout) { + const stderr = String(exec.stderr || "").trim(); + return fallbackLintResults( + entries, + stderr || "oxlint returned empty output", + ); + } + + let parsed; + try { + parsed = JSON.parse(stdout); + } catch { + return fallbackLintResults(entries, "oxlint JSON parse failed"); + } + + const rawDiagnostics = Array.isArray(parsed?.diagnostics) + ? parsed.diagnostics + : []; + const byIndex = new Map(); + + for (const diag of rawDiagnostics) { + const filenameRaw = + typeof diag?.filename === "string" ? diag.filename : ""; + const filename = filenameRaw.startsWith("file://") + ? filenameRaw.replace("file://", "") + : filenameRaw; + const index = parseFileIndex(filename); + if (index === null) { + continue; + } + const normalized = normalizeLintDiagnostic(diag); + if (!normalized) { + continue; + } + const entry = entryByIndex.get(index); + const remapped = remapDiagnosticOffsets(normalized, entry?.offset ?? 0); + const list = byIndex.get(index) ?? []; + list.push(remapped); + byIndex.set(index, list); + } + + const results = new Map(); + for (const entry of entries) { + const diagnostics = byIndex.get(entry.index) ?? []; + const errorDiagnostics = diagnostics.filter( + (diag) => diag.severity === "error", + ); + const warningDiagnostics = diagnostics.filter( + (diag) => diag.severity !== "error", + ); + const top = errorDiagnostics[0] ?? warningDiagnostics[0] ?? null; + const messageSource = + errorDiagnostics.length > 0 ? errorDiagnostics : warningDiagnostics; + results.set( + entry.index, + makeResult({ + isValid: errorDiagnostics.length === 0, + errorCount: errorDiagnostics.length, + warningCount: warningDiagnostics.length, + message: messageSource + .slice(0, 3) + .map((diag) => diag.message) + .join(" | "), + severity: top ? top.severity : null, + code: top ? top.code : null, + labels: top ? top.labels : [], + codeframe: top ? top.codeframe : null, + }), + ); + } + return results; + } catch (error) { + return fallbackLintResults(entries, `oxlint execution failed: ${error}`); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +} + +function readStdin() { + return new Promise((resolve, reject) => { + let data = ""; + process.stdin.setEncoding("utf8"); + process.stdin.on("data", (chunk) => { + data += chunk; + }); + process.stdin.on("end", () => resolve(data)); + process.stdin.on("error", (error) => reject(error)); + }); +} + +function runValidation({ codes, lang, mode, codeShape }) { + if (mode === "syntax") { + return codes.map((code, index) => + validateSyntaxOne({ code, lang, index, codeShape }).result, + ); + } + + if (mode === "lint") { + const entries = codes.map((code, index) => + resolveLintEntry({ code, lang, index, codeShape }), + ); + const lintMap = runLintBatch(entries); + return entries.map( + (entry) => + lintMap.get(entry.index) ?? + makeResult({ + isValid: true, + errorCount: 0, + warningCount: 0, + }), + ); + } + + const syntaxRuns = codes.map((code, index) => + validateSyntaxOne({ code, lang, index, codeShape }), + ); + const lintTargets = syntaxRuns + .filter((run) => run.result.is_valid === true) + .map((run) => run.lintEntry); + const lintMap = runLintBatch(lintTargets); + + return syntaxRuns.map((run) => { + if (run.result.is_valid !== true) { + return run.result; + } + return ( + lintMap.get(run.lintEntry.index) ?? + makeResult({ + isValid: true, + errorCount: 0, + warningCount: 0, + }) + ); + }); +} + +async function main() { + const raw = await readStdin(); + let payload; + try { + payload = JSON.parse(raw || "{}"); + } catch { + process.stdout.write( + JSON.stringify([ + makeResult({ + isValid: false, + errorCount: 1, + warningCount: 0, + message: "Invalid JSON payload", + severity: "error", + }), + ]), + ); + return; + } + + const lang = mapLang(payload?.lang); + const mode = mapMode(payload?.mode); + const codeShape = mapCodeShape(payload?.code_shape); + const codes = Array.isArray(payload?.codes) ? payload.codes : []; + const out = runValidation({ codes, lang, mode, codeShape }); + process.stdout.write(JSON.stringify(out)); +} + +main().catch((error) => { + process.stderr.write(String(error?.stack || error)); + process.exit(1); +}); diff --git a/studio/backend/core/data_recipe/service.py b/studio/backend/core/data_recipe/service.py new file mode 100644 index 0000000000..1c86ac42d9 --- /dev/null +++ b/studio/backend/core/data_recipe/service.py @@ -0,0 +1,283 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +from __future__ import annotations + +import base64 +import io +import os +from pathlib import Path +from typing import Any + +from .jsonable import to_jsonable +from .local_callable_validators import ( + register_oxc_local_callable_validators, + split_oxc_local_callable_validators, +) + +_IMAGE_CONTEXT_PATCHED = False + + +def _encode_bytes_to_base64(value: bytes | bytearray) -> str: + return base64.b64encode(bytes(value)).decode("utf-8") + + +def _load_image_file_to_base64( + path_value: str, *, base_path: str | None = None +) -> str | None: + try: + path = Path(path_value) + candidates: list[Path] = [] + if path.is_absolute(): + candidates.append(path) + else: + if base_path: + candidates.append(Path(base_path) / path) + candidates.append(Path.cwd() / path) + + for candidate in candidates: + if not candidate.exists() or not candidate.is_file(): + continue + with candidate.open("rb") as f: + return _encode_bytes_to_base64(f.read()) + except (OSError, TypeError, ValueError): + return None + return None + + +def _pil_image_to_base64(value: Any) -> str | None: + try: + from PIL.Image import Image as PILImage # type: ignore + except ImportError: + return None + if not isinstance(value, PILImage): + return None + buffer = io.BytesIO() + image_format = str(getattr(value, "format", "") or "").upper() + if image_format not in {"PNG", "JPEG", "JPG", "WEBP", "GIF"}: + image_format = "PNG" + value.save(buffer, format = image_format) + return _encode_bytes_to_base64(buffer.getvalue()) + + +def _normalize_image_context_value(value: Any, *, base_path: str | None = None) -> Any: + if isinstance(value, str): + return value + + if isinstance(value, (bytes, bytearray)): + return _encode_bytes_to_base64(value) + + pil_base64 = _pil_image_to_base64(value) + if pil_base64 is not None: + return pil_base64 + + if isinstance(value, dict): + url = value.get("url") + if isinstance(url, str): + return url + + image_url = value.get("image_url") + if isinstance(image_url, str): + return image_url + if isinstance(image_url, dict): + nested_url = image_url.get("url") + if isinstance(nested_url, str): + return nested_url + + inline_data = value.get("data") + if isinstance(inline_data, str): + return inline_data + + raw_bytes = value.get("bytes") + if isinstance(raw_bytes, (bytes, bytearray)): + return _encode_bytes_to_base64(raw_bytes) + if isinstance(raw_bytes, str) and raw_bytes.strip(): + return raw_bytes + + path_value = value.get("path") + if isinstance(path_value, str) and path_value.strip(): + if as_base64 := _load_image_file_to_base64(path_value, base_path = base_path): + return as_base64 + return path_value + + return value + + +def _apply_data_designer_image_context_patch() -> None: + global _IMAGE_CONTEXT_PATCHED + if _IMAGE_CONTEXT_PATCHED: + return + + try: + from data_designer.config.models import ImageContext + except ImportError: + return + + if getattr(ImageContext, "_unsloth_image_context_patch_applied", False): + _IMAGE_CONTEXT_PATCHED = True + return + + original_auto_resolve = ImageContext._auto_resolve_context_value + + def _patched_auto_resolve( + self: Any, context_value: Any, base_path: str | None + ) -> Any: + normalized = _normalize_image_context_value(context_value, base_path = base_path) + return original_auto_resolve(self, normalized, base_path) + + ImageContext._auto_resolve_context_value = _patched_auto_resolve + setattr(ImageContext, "_unsloth_image_context_patch_applied", True) + _IMAGE_CONTEXT_PATCHED = True + + +def build_model_providers(recipe: dict[str, Any]): + from data_designer.config.default_model_settings import get_default_providers + from data_designer.config.models import ModelProvider + + providers: list[ModelProvider] = [] + for provider in recipe.get("model_providers", []): + api_key = provider.get("api_key") + api_key_env = provider.get("api_key_env") + if not api_key and api_key_env: + api_key = os.getenv(api_key_env) + providers.append( + ModelProvider( + name = provider["name"], + endpoint = provider["endpoint"], + provider_type = provider.get("provider_type", "openai"), + api_key = api_key, + extra_headers = provider.get("extra_headers"), + extra_body = provider.get("extra_body"), + ) + ) + + # DataDesigner currently expects at least one provider even if they only use static samplers, + # but it's fine it gives a warning only. + return providers or get_default_providers() + + +def build_mcp_providers( + recipe: dict[str, Any], +) -> list: + from data_designer.config.mcp import LocalStdioMCPProvider, MCPProvider + + providers: list[MCPProvider | LocalStdioMCPProvider] = [] + for provider in recipe.get("mcp_providers", []): + if not isinstance(provider, dict): + continue + provider_type = provider.get("provider_type") + if provider_type == "stdio": + env = provider.get("env") + if not isinstance(env, dict): + env = {} + args = provider.get("args") + if not isinstance(args, list): + args = [] + providers.append( + LocalStdioMCPProvider( + name = str(provider.get("name", "")), + command = str(provider.get("command", "")), + args = [str(value) for value in args], + env = {str(key): str(value) for key, value in env.items()}, + ) + ) + continue + + if provider_type in {"sse", "streamable_http"}: + api_key = provider.get("api_key") + api_key_env = provider.get("api_key_env") + if not api_key and api_key_env: + api_key = os.getenv(str(api_key_env)) + providers.append( + MCPProvider( + name = str(provider.get("name", "")), + endpoint = str(provider.get("endpoint", "")), + provider_type = str(provider_type), + api_key = str(api_key) if api_key else None, + ) + ) + return providers + + +def build_config_builder(recipe: dict[str, Any]): + _apply_data_designer_image_context_patch() + from data_designer.config import DataDesignerConfigBuilder + from data_designer.config.processors import ProcessorType + + recipe_core = { + key: value + for key, value in recipe.items() + if key not in {"model_providers", "mcp_providers"} + } + recipe_core, oxc_local_callable_specs = split_oxc_local_callable_validators( + recipe_core + ) + builder = DataDesignerConfigBuilder.from_config({"data_designer": recipe_core}) + register_oxc_local_callable_validators( + builder = builder, + specs = oxc_local_callable_specs, + ) + + # DataDesignerConfigBuilder.from_config currently skips processors. + # Re-attach explicitly so drop_columns/schema_transform survive API payload. + for processor in recipe_core.get("processors") or []: + if not isinstance(processor, dict): + continue + processor_type_raw = processor.get("processor_type") + if not isinstance(processor_type_raw, str): + continue + kwargs = {k: v for k, v in processor.items() if k != "processor_type"} + builder.add_processor( + processor_type = ProcessorType(processor_type_raw), + **kwargs, + ) + + return builder + + +def create_data_designer( + recipe: dict[str, Any], + *, + artifact_path: str | None = None, +): + _apply_data_designer_image_context_patch() + from data_designer.interface.data_designer import DataDesigner + + return DataDesigner( + artifact_path = artifact_path, + model_providers = build_model_providers(recipe), + mcp_providers = build_mcp_providers(recipe), + ) + + +def validate_recipe(recipe: dict[str, Any]) -> None: + builder = build_config_builder(recipe) + designer = create_data_designer(recipe) + designer.validate(builder) + + +def preview_recipe( + recipe: dict[str, Any], + num_records: int, +) -> tuple[list[dict[str, Any]], dict[str, Any] | None, dict[str, Any] | None]: + builder = build_config_builder(recipe) + designer = create_data_designer(recipe) + results = designer.preview(builder, num_records = num_records) + + dataset: list[dict[str, Any]] = [] + if results.dataset is not None: + raw_rows = results.dataset.to_dict(orient = "records") + dataset = [to_jsonable(row) for row in raw_rows] + + artifacts = ( + None + if results.processor_artifacts is None + else to_jsonable(results.processor_artifacts) + ) + analysis = ( + None + if results.analysis is None + else to_jsonable(results.analysis.model_dump(mode = "json")) + ) + + return dataset, artifacts, analysis diff --git a/studio/backend/core/export/__init__.py b/studio/backend/core/export/__init__.py new file mode 100644 index 0000000000..16fba368ef --- /dev/null +++ b/studio/backend/core/export/__init__.py @@ -0,0 +1,21 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Export submodule - Model export operations + +The default get_export_backend() returns an ExportOrchestrator that +delegates to a subprocess. The original ExportBackend runs inside +the subprocess and can be imported directly from .export when needed. +""" + +from .orchestrator import ExportOrchestrator, get_export_backend + +# Expose ExportOrchestrator as ExportBackend for backward compat +ExportBackend = ExportOrchestrator + +__all__ = [ + "ExportBackend", + "ExportOrchestrator", + "get_export_backend", +] diff --git a/studio/backend/core/export/export.py b/studio/backend/core/export/export.py new file mode 100644 index 0000000000..a6f67c5bae --- /dev/null +++ b/studio/backend/core/export/export.py @@ -0,0 +1,677 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +# backend/export.py +""" +Export backend - handles model exporting in various formats +""" + +import glob +import json +import structlog +from loggers import get_logger +import os +import shutil +from pathlib import Path +from typing import Optional, Tuple, List +from peft import PeftModel, PeftModelForCausalLM +from unsloth import FastLanguageModel, FastVisionModel +from huggingface_hub import HfApi, ModelCard +from transformers.modeling_utils import PushToHubMixin +import torch +from utils.hardware import clear_gpu_cache + +from utils.models import is_vision_model, get_base_model_from_lora +from utils.models.model_config import detect_audio_type +from utils.paths import ensure_dir, outputs_root, resolve_export_dir, resolve_output_dir +from core.inference import get_inference_backend + +logger = get_logger(__name__) + + +def _is_wsl(): + """Detect if running under Windows Subsystem for Linux.""" + try: + return "microsoft" in open("/proc/version").read().lower() + except Exception: + return False + + +def _apply_wsl_sudo_patch(): + """On WSL, monkey-patch do_we_need_sudo() to return False. + + WSL doesn't have passwordless sudo, and do_we_need_sudo() runs + `sudo apt-get update` which hangs waiting for a stdin password + inside a non-interactive subprocess. setup.sh pre-installs the + build dependencies on WSL, so sudo is not needed at runtime. + """ + if not _is_wsl(): + return + + try: + import unsloth_zoo.llama_cpp as llama_cpp_module + + def _wsl_do_we_need_sudo(system_type = "debian"): + logger.info( + "WSL detected — skipping sudo check " + "(build deps pre-installed by setup.sh)" + ) + return False + + llama_cpp_module.do_we_need_sudo = _wsl_do_we_need_sudo + logger.info( + "Applied WSL sudo patch to " "unsloth_zoo.llama_cpp.do_we_need_sudo" + ) + except Exception as e: + logger.warning(f"Could not apply WSL sudo patch: {e}") + + +# Model card template +MODEL_CARD = """--- +base_model: {base_model} +tags: +- text-generation-inference +- transformers +- unsloth +- {model_type} +- {extra} +license: apache-2.0 +language: +- en +--- + +# Uploaded finetuned {method} model + +- **Developed by:** {username} +- **License:** apache-2.0 +- **Finetuned from model :** {base_model} + +This {model_type} model was trained 2x faster with [Unsloth](https://github.com/unslothai/unsloth) and Huggingface's TRL library. + +[](https://github.com/unslothai/unsloth) +""" + + +class ExportBackend: + """Handles model export operations""" + + def __init__(self): + self.inference_backend = get_inference_backend() + self.current_checkpoint = None + self.current_model = None + self.current_tokenizer = None + self.is_vision = False + self.is_peft = False + self._audio_type = None + + def cleanup_memory(self): + """Offload and delete all models from memory""" + try: + logger.info("Starting memory cleanup...") + + # Unload all models from inference backend + model_names = list(self.inference_backend.models.keys()) + for model_name in model_names: + self.inference_backend.unload_model(model_name) + + # Clear current export state + self.current_model = None + self.current_tokenizer = None + self.current_checkpoint = None + self._audio_type = None + + # Clear GPU memory cache (handles gc + backend-specific cleanup) + clear_gpu_cache() + + logger.info("Memory cleanup completed successfully") + return True + + except Exception as e: + logger.error(f"Error during memory cleanup: {e}") + return False + + def scan_checkpoints( + self, outputs_dir: str = str(outputs_root()) + ) -> List[Tuple[str, List[Tuple[str, str]]]]: + """ + Scan outputs folder for training runs and their checkpoints. + + Returns: + List of tuples: [(model_name, [(display_name, checkpoint_path), ...]), ...] + """ + from utils.models.checkpoints import scan_checkpoints + + return scan_checkpoints(outputs_dir = outputs_dir) + + def load_checkpoint( + self, + checkpoint_path: str, + max_seq_length: int = 2048, + load_in_4bit: bool = True, + trust_remote_code: bool = False, + ) -> Tuple[bool, str]: + """ + Load a checkpoint for export. + + Returns: + Tuple of (success: bool, message: str) + """ + try: + logger.info(f"Loading checkpoint: {checkpoint_path}") + + # First, cleanup existing models + self.cleanup_memory() + + checkpoint_path_obj = Path(checkpoint_path) + + # Determine the model identity for type detection + adapter_config = checkpoint_path_obj / "adapter_config.json" + base_model = None + if adapter_config.exists(): + base_model = get_base_model_from_lora(checkpoint_path) + if not base_model: + return False, "Could not determine base model for adapter" + + model_id = base_model or checkpoint_path + + # Detect audio type and vision + self._audio_type = detect_audio_type(model_id) + self.is_vision = not self._audio_type and is_vision_model(model_id) + + # Load model based on type + if self._audio_type == "csm": + from unsloth import FastModel + from transformers import CsmForConditionalGeneration + + logger.info("Loading as CSM audio model...") + model, tokenizer = FastModel.from_pretrained( + model_name = checkpoint_path, + max_seq_length = max_seq_length, + dtype = None, + auto_model = CsmForConditionalGeneration, + load_in_4bit = False, + trust_remote_code = trust_remote_code, + ) + + elif self._audio_type == "whisper": + from unsloth import FastModel + from transformers import WhisperForConditionalGeneration + + logger.info("Loading as Whisper audio model...") + model, tokenizer = FastModel.from_pretrained( + model_name = checkpoint_path, + dtype = None, + load_in_4bit = False, + auto_model = WhisperForConditionalGeneration, + trust_remote_code = trust_remote_code, + ) + + elif self._audio_type == "snac": + logger.info("Loading as SNAC (Orpheus) audio model...") + model, tokenizer = FastLanguageModel.from_pretrained( + model_name = checkpoint_path, + max_seq_length = max_seq_length, + dtype = None, + load_in_4bit = load_in_4bit, + trust_remote_code = trust_remote_code, + ) + + elif self._audio_type == "bicodec": + from unsloth import FastModel + + logger.info("Loading as BiCodec (Spark-TTS) audio model...") + model, tokenizer = FastModel.from_pretrained( + model_name = checkpoint_path, + max_seq_length = max_seq_length, + dtype = torch.float32, + load_in_4bit = False, + trust_remote_code = trust_remote_code, + ) + + elif self._audio_type == "dac": + from unsloth import FastModel + + logger.info("Loading as DAC (OuteTTS) audio model...") + model, tokenizer = FastModel.from_pretrained( + model_name = checkpoint_path, + max_seq_length = max_seq_length, + load_in_4bit = False, + trust_remote_code = trust_remote_code, + ) + + elif self.is_vision: + logger.info("Loading as vision model...") + model, processor = FastVisionModel.from_pretrained( + model_name = checkpoint_path, + max_seq_length = max_seq_length, + dtype = None, + load_in_4bit = load_in_4bit, + trust_remote_code = trust_remote_code, + ) + tokenizer = processor # For vision models, processor acts as tokenizer + + else: + logger.info("Loading as text model...") + model, tokenizer = FastLanguageModel.from_pretrained( + model_name = checkpoint_path, + max_seq_length = max_seq_length, + dtype = None, + load_in_4bit = load_in_4bit, + trust_remote_code = trust_remote_code, + ) + + # Check if PEFT model + self.is_peft = isinstance(model, (PeftModel, PeftModelForCausalLM)) + + # Store loaded model + self.current_model = model + self.current_tokenizer = tokenizer + self.current_checkpoint = checkpoint_path + + if self._audio_type: + model_type = f"Audio ({self._audio_type})" + elif self.is_vision: + model_type = "Vision" + else: + model_type = "Text" + peft_info = " (PEFT Adapter)" if self.is_peft else " (Merged Model)" + + logger.info(f"Successfully loaded {model_type} model{peft_info}") + return True, f"Loaded {model_type} model{peft_info} successfully" + + except Exception as e: + logger.error(f"Error loading checkpoint: {e}") + import traceback + + logger.error(traceback.format_exc()) + return False, f"Failed to load checkpoint: {str(e)}" + + def _write_export_metadata(self, save_directory: str): + """Write export_metadata.json with base model info for Chat page discovery.""" + try: + base_model = ( + get_base_model_from_lora(self.current_checkpoint) + if self.current_checkpoint + else None + ) + metadata = {"base_model": base_model} + metadata_path = os.path.join(save_directory, "export_metadata.json") + with open(metadata_path, "w") as f: + json.dump(metadata, f, indent = 2) + logger.info(f"Wrote export metadata to {metadata_path}") + except Exception as e: + logger.warning(f"Could not write export metadata: {e}") + + def export_merged_model( + self, + save_directory: str, + format_type: str = "16-bit (FP16)", + push_to_hub: bool = False, + repo_id: Optional[str] = None, + hf_token: Optional[str] = None, + private: bool = False, + ) -> Tuple[bool, str]: + """ + Export merged model (for PEFT models). + + Args: + save_directory: Local directory to save model + format_type: "16-bit (FP16)" or "4-bit (FP4)" + push_to_hub: Whether to push to Hugging Face Hub + repo_id: Hub repository ID (username/model-name) + hf_token: Hugging Face token + private: Whether to make the repo private + + Returns: + Tuple of (success: bool, message: str) + """ + if not self.current_model or not self.current_tokenizer: + return False, "No model loaded. Please select a checkpoint first." + + if not self.is_peft: + return False, "This is not a PEFT model. Use 'Export Base Model' instead." + + try: + # Determine save method + if format_type == "4-bit (FP4)": + save_method = "merged_4bit_forced" + elif self._audio_type == "whisper": + # Whisper uses save_method=None for local 16-bit merged save + save_method = None + else: # 16-bit (FP16) + save_method = "merged_16bit" + + # Save locally if requested + if save_directory: + save_directory = str(resolve_export_dir(save_directory)) + logger.info(f"Saving merged model locally to: {save_directory}") + ensure_dir(Path(save_directory)) + + self.current_model.save_pretrained_merged( + save_directory, self.current_tokenizer, save_method = save_method + ) + + # Write export metadata so the Chat page can identify the base model + self._write_export_metadata(save_directory) + logger.info(f"Model saved successfully to {save_directory}") + + # Push to hub if requested + if push_to_hub: + if not repo_id or not hf_token: + return ( + False, + "Repository ID and Hugging Face token required for Hub upload", + ) + + logger.info(f"Pushing merged model to Hub: {repo_id}") + + # Whisper uses save_method=None for local but "merged_16bit" for hub push + hub_save_method = ( + save_method if save_method is not None else "merged_16bit" + ) + self.current_model.push_to_hub_merged( + repo_id, + self.current_tokenizer, + save_method = hub_save_method, + token = hf_token, + private = private, + ) + logger.info(f"Model pushed successfully to {repo_id}") + + return True, "Model exported successfully" + + except Exception as e: + logger.error(f"Error exporting merged model: {e}") + import traceback + + logger.error(traceback.format_exc()) + return False, f"Export failed: {str(e)}" + + def export_base_model( + self, + save_directory: str, + push_to_hub: bool = False, + repo_id: Optional[str] = None, + hf_token: Optional[str] = None, + private: bool = False, + base_model_id: Optional[str] = None, + ) -> Tuple[bool, str]: + """ + Export base model (for non-PEFT models). + + Returns: + Tuple of (success: bool, message: str) + """ + if not self.current_model or not self.current_tokenizer: + return False, "No model loaded. Please select a checkpoint first." + + if self.is_peft: + return ( + False, + "This is a PEFT model. Use 'Merged Model' export type instead.", + ) + + try: + # Save locally if requested + if save_directory: + save_directory = str(resolve_export_dir(save_directory)) + logger.info(f"Saving base model locally to: {save_directory}") + ensure_dir(Path(save_directory)) + + self.current_model.save_pretrained(save_directory) + self.current_tokenizer.save_pretrained(save_directory) + + # Write export metadata so the Chat page can identify the base model + self._write_export_metadata(save_directory) + logger.info(f"Model saved successfully to {save_directory}") + + # Push to hub if requested + if push_to_hub: + if not repo_id or not hf_token: + return ( + False, + "Repository ID and Hugging Face token required for Hub upload", + ) + + logger.info(f"Pushing base model to Hub: {repo_id}") + + # Get base model name from request or model config + base_model = ( + base_model_id + or self.current_model.config._name_or_path + or "unknown" + ) + + # Create repo + hf_api = HfApi(token = hf_token) + repo_id = PushToHubMixin._create_repo( + PushToHubMixin, + repo_id = repo_id, + private = private, + token = hf_token, + ) + username = repo_id.split("/")[0] + + # Create and push model card + content = MODEL_CARD.format( + username = username, + base_model = base_model, + model_type = self.current_model.config.model_type, + method = "", + extra = "unsloth", + ) + card = ModelCard(content) + card.push_to_hub( + repo_id, token = hf_token, commit_message = "Unsloth Model Card" + ) + + # Upload model files + if save_directory: + hf_api.upload_folder( + folder_path = save_directory, repo_id = repo_id, repo_type = "model" + ) + logger.info(f"Model pushed successfully to {repo_id}") + else: + return False, "Local save directory required for Hub upload" + + return True, "Model exported successfully" + + except Exception as e: + logger.error(f"Error exporting base model: {e}") + import traceback + + logger.error(traceback.format_exc()) + return False, f"Export failed: {str(e)}" + + def export_gguf( + self, + save_directory: str, + quantization_method: str = "Q4_K_M", + push_to_hub: bool = False, + repo_id: Optional[str] = None, + hf_token: Optional[str] = None, + ) -> Tuple[bool, str]: + """ + Export model in GGUF format. + + Args: + save_directory: Local directory to save model + quantization_method: GGUF quantization method (e.g., "Q4_K_M") + push_to_hub: Whether to push to Hugging Face Hub + repo_id: Hub repository ID + hf_token: Hugging Face token + + Returns: + Tuple of (success: bool, message: str) + """ + if not self.current_model or not self.current_tokenizer: + return False, "No model loaded. Please select a checkpoint first." + + try: + # Convert quantization method to lowercase for unsloth + quant_method = quantization_method.lower() + + # Save locally if requested + if save_directory: + save_directory = str(resolve_export_dir(save_directory)) + # Resolve to absolute path so unsloth's relative-path internals + # (check_llama_cpp, use_local_gguf, _download_convert_hf_to_gguf) + # all resolve against the repo root cwd, NOT the export directory. + abs_save_dir = os.path.abspath(save_directory) + logger.info(f"Saving GGUF model locally to: {abs_save_dir}") + + # Create the directory if it doesn't exist + ensure_dir(Path(abs_save_dir)) + + # On WSL, patch out sudo check before llama.cpp build + _apply_wsl_sudo_patch() + + # Snapshot existing .gguf files in cwd before conversion. + # unsloth's convert_to_gguf writes output files relative to + # cwd (repo root), so we diff afterwards and relocate them. + cwd = os.getcwd() + pre_existing_ggufs = set(glob.glob(os.path.join(cwd, "*.gguf"))) + + # Pass absolute path — no os.chdir needed. + # unsloth saves intermediate HF model files into model_save_path. + # unsloth-zoo's check_llama_cpp() uses ~/.unsloth/llama.cpp by default. + model_save_path = os.path.join(abs_save_dir, "model") + self.current_model.save_pretrained_gguf( + model_save_path, + self.current_tokenizer, + quantization_method = quant_method, + ) + + # Relocate GGUF artifacts into the export directory. + # convert_to_gguf writes .gguf files to cwd (repo root) + # because --outfile is a relative path like "model.Q4_K_M.gguf". + new_ggufs = ( + set(glob.glob(os.path.join(cwd, "*.gguf"))) - pre_existing_ggufs + ) + for src in sorted(new_ggufs): + dest = os.path.join(abs_save_dir, os.path.basename(src)) + shutil.move(src, dest) + logger.info( + f"Relocated GGUF: {os.path.basename(src)} → {abs_save_dir}/" + ) + + # Flatten any .gguf files from subdirectories into abs_save_dir. + # save_pretrained_gguf may create subdirs (e.g. model_gguf/) + # with a name different from model_save_path. + for sub in list(Path(abs_save_dir).iterdir()): + if not sub.is_dir(): + continue + for src in sub.glob("*.gguf"): + dest = os.path.join(abs_save_dir, src.name) + shutil.move(str(src), dest) + logger.info(f"Relocated GGUF: {src.name} → {abs_save_dir}/") + # Clean up the subdirectory (intermediate HF files, etc.) + shutil.rmtree(str(sub), ignore_errors = True) + logger.info(f"Cleaned up subdirectory: {sub.name}") + + # Write export metadata so the Chat page can identify the base model + self._write_export_metadata(abs_save_dir) + + # Log final file locations (after relocation) so it's clear + # where the GGUF files actually ended up. + final_ggufs = sorted(glob.glob(os.path.join(abs_save_dir, "*.gguf"))) + logger.info( + "GGUF export complete. Final files in %s:\n %s", + abs_save_dir, + "\n ".join(os.path.basename(f) for f in final_ggufs) or "(none)", + ) + + # Push to hub if requested + if push_to_hub: + if not repo_id or not hf_token: + return ( + False, + "Repository ID and Hugging Face token required for Hub upload", + ) + + logger.info(f"Pushing GGUF model to Hub: {repo_id}") + + self.current_model.push_to_hub_gguf( + repo_id, + self.current_tokenizer, + quantization_method = quant_method, + token = hf_token, + ) + logger.info(f"GGUF model pushed successfully to {repo_id}") + + return True, f"GGUF model exported successfully ({quantization_method})" + + except Exception as e: + logger.error(f"Error exporting GGUF model: {e}") + import traceback + + logger.error(traceback.format_exc()) + return False, f"GGUF export failed: {str(e)}" + + def export_lora_adapter( + self, + save_directory: str, + push_to_hub: bool = False, + repo_id: Optional[str] = None, + hf_token: Optional[str] = None, + private: bool = False, + ) -> Tuple[bool, str]: + """ + Export LoRA adapter only (not merged). + + Returns: + Tuple of (success: bool, message: str) + """ + if not self.current_model or not self.current_tokenizer: + return False, "No model loaded. Please select a checkpoint first." + + if not self.is_peft: + return False, "This is not a PEFT model. No adapter to export." + + try: + # Save locally if requested + if save_directory: + save_directory = str(resolve_export_dir(save_directory)) + logger.info(f"Saving LoRA adapter locally to: {save_directory}") + ensure_dir(Path(save_directory)) + + self.current_model.save_pretrained(save_directory) + self.current_tokenizer.save_pretrained(save_directory) + logger.info(f"Adapter saved successfully to {save_directory}") + + # Push to hub if requested + if push_to_hub: + if not repo_id or not hf_token: + return ( + False, + "Repository ID and Hugging Face token required for Hub upload", + ) + + logger.info(f"Pushing LoRA adapter to Hub: {repo_id}") + + self.current_model.push_to_hub(repo_id, token = hf_token, private = private) + self.current_tokenizer.push_to_hub( + repo_id, token = hf_token, private = private + ) + logger.info(f"Adapter pushed successfully to {repo_id}") + + return True, "LoRA adapter exported successfully" + + except Exception as e: + logger.error(f"Error exporting LoRA adapter: {e}") + import traceback + + logger.error(traceback.format_exc()) + return False, f"Adapter export failed: {str(e)}" + + +# Global export backend instance +_export_backend = None + + +def get_export_backend() -> ExportBackend: + """Get or create the global export backend instance""" + global _export_backend + if _export_backend is None: + _export_backend = ExportBackend() + return _export_backend diff --git a/studio/backend/core/export/orchestrator.py b/studio/backend/core/export/orchestrator.py new file mode 100644 index 0000000000..a9fbe659b3 --- /dev/null +++ b/studio/backend/core/export/orchestrator.py @@ -0,0 +1,407 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Export orchestrator — subprocess-based. + +Provides the same API as ExportBackend, but delegates all ML work +to a persistent subprocess. The subprocess is spawned on first checkpoint +load and stays alive for subsequent export operations. + +When switching between checkpoints that need different transformers versions, +the old subprocess is killed and a new one is spawned with the correct version. + +Pattern follows core/inference/orchestrator.py. +""" + +import atexit +import structlog +from loggers import get_logger +import multiprocessing as mp +import queue +import threading +import time +from pathlib import Path +from typing import Any, List, Optional, Tuple +from utils.paths import outputs_root + +logger = get_logger(__name__) + +_CTX = mp.get_context("spawn") + + +class ExportOrchestrator: + """ + Export backend orchestrator — subprocess-based. + + Exposes the same API surface as ExportBackend so routes/export.py + needs minimal changes. Internally, all heavy ML operations happen in + a persistent subprocess. + """ + + def __init__(self): + # Subprocess state + self._proc: Optional[mp.Process] = None + self._cmd_queue: Any = None + self._resp_queue: Any = None + self._lock = threading.Lock() + + # Local state mirrors (updated from subprocess responses) + self.current_checkpoint: Optional[str] = None + self.is_vision: bool = False + self.is_peft: bool = False + + atexit.register(self._cleanup) + logger.info("ExportOrchestrator initialized (subprocess mode)") + + # ------------------------------------------------------------------ + # Subprocess lifecycle + # ------------------------------------------------------------------ + + def _spawn_subprocess(self, config: dict) -> None: + """Spawn a new export subprocess.""" + from .worker import run_export_process + + self._cmd_queue = _CTX.Queue() + self._resp_queue = _CTX.Queue() + + self._proc = _CTX.Process( + target = run_export_process, + kwargs = { + "cmd_queue": self._cmd_queue, + "resp_queue": self._resp_queue, + "config": config, + }, + daemon = True, + ) + self._proc.start() + logger.info("Export subprocess started (pid=%s)", self._proc.pid) + + def _shutdown_subprocess(self, timeout: float = 10.0) -> None: + """Gracefully shut down the export subprocess.""" + if self._proc is None or not self._proc.is_alive(): + self._proc = None + return + + # 1. Drain stale responses + self._drain_queue() + + # 2. Send shutdown command + try: + self._cmd_queue.put({"type": "shutdown"}) + except (OSError, ValueError): + pass + + # 3. Wait for graceful shutdown + try: + self._proc.join(timeout = timeout) + except Exception: + pass + + # 4. Force kill if still alive + if self._proc is not None and self._proc.is_alive(): + logger.warning("Export subprocess did not exit gracefully, terminating") + try: + self._proc.terminate() + self._proc.join(timeout = 5) + except Exception: + pass + if self._proc is not None and self._proc.is_alive(): + logger.warning("Subprocess still alive after terminate, killing") + try: + self._proc.kill() + self._proc.join(timeout = 3) + except Exception: + pass + + self._proc = None + self._cmd_queue = None + self._resp_queue = None + logger.info("Export subprocess shut down") + + def _cleanup(self): + """atexit handler.""" + self._shutdown_subprocess(timeout = 5.0) + + def _ensure_subprocess_alive(self) -> bool: + """Check if subprocess is alive.""" + return self._proc is not None and self._proc.is_alive() + + # ------------------------------------------------------------------ + # Queue helpers + # ------------------------------------------------------------------ + + def _send_cmd(self, cmd: dict) -> None: + """Send a command to the subprocess.""" + if self._cmd_queue is None: + raise RuntimeError("No export subprocess running") + try: + self._cmd_queue.put(cmd) + except (OSError, ValueError) as exc: + raise RuntimeError(f"Failed to send command to subprocess: {exc}") + + def _read_resp(self, timeout: float = 1.0) -> Optional[dict]: + """Read a response from the subprocess (non-blocking with timeout).""" + if self._resp_queue is None: + return None + try: + return self._resp_queue.get(timeout = timeout) + except queue.Empty: + return None + except (EOFError, OSError, ValueError): + return None + + def _wait_response(self, expected_type: str, timeout: float = 3600.0) -> dict: + """Block until a response of the expected type arrives. + + Export operations can take a very long time — GGUF conversion for + large models (30B+) easily takes 20-30 minutes. Default timeout + is 1 hour. + """ + deadline = time.monotonic() + timeout + + while time.monotonic() < deadline: + remaining = max(0.1, deadline - time.monotonic()) + resp = self._read_resp(timeout = min(remaining, 2.0)) + + if resp is None: + # Check subprocess health + if not self._ensure_subprocess_alive(): + raise RuntimeError("Export subprocess crashed during wait") + continue + + rtype = resp.get("type", "") + + if rtype == expected_type: + return resp + + if rtype == "error": + error_msg = resp.get("error", "Unknown error") + raise RuntimeError(f"Subprocess error: {error_msg}") + + if rtype == "status": + logger.info("Export subprocess status: %s", resp.get("message", "")) + continue + + # Other response types during wait — skip + logger.debug( + "Skipping response type '%s' while waiting for '%s'", + rtype, + expected_type, + ) + + raise RuntimeError( + f"Timeout waiting for '{expected_type}' response after {timeout}s" + ) + + def _drain_queue(self) -> list: + """Drain all pending responses.""" + events = [] + if self._resp_queue is None: + return events + while True: + try: + events.append(self._resp_queue.get_nowait()) + except queue.Empty: + return events + except (EOFError, OSError, ValueError): + return events + + # ------------------------------------------------------------------ + # Public API — same interface as ExportBackend + # ------------------------------------------------------------------ + + def load_checkpoint( + self, + checkpoint_path: str, + max_seq_length: int = 2048, + load_in_4bit: bool = True, + trust_remote_code: bool = False, + ) -> Tuple[bool, str]: + """Load a checkpoint for export. + + Always spawns a fresh subprocess to ensure a clean Python interpreter. + """ + sub_config = { + "checkpoint_path": checkpoint_path, + "max_seq_length": max_seq_length, + "load_in_4bit": load_in_4bit, + "trust_remote_code": trust_remote_code, + } + + # Always kill existing subprocess and spawn fresh. + if self._ensure_subprocess_alive(): + self._shutdown_subprocess() + elif self._proc is not None: + self._shutdown_subprocess(timeout = 2) + + logger.info("Spawning fresh export subprocess for '%s'", checkpoint_path) + self._spawn_subprocess(sub_config) + + try: + resp = self._wait_response("loaded", timeout = 300) + except RuntimeError as exc: + self._shutdown_subprocess(timeout = 5) + self.current_checkpoint = None + self.is_vision = False + self.is_peft = False + return False, str(exc) + + if resp.get("success"): + self.current_checkpoint = resp.get("checkpoint") + self.is_vision = resp.get("is_vision", False) + self.is_peft = resp.get("is_peft", False) + logger.info("Checkpoint '%s' loaded in subprocess", checkpoint_path) + return True, resp.get("message", "Loaded successfully") + else: + error = resp.get("message", "Failed to load checkpoint") + logger.error("Failed to load checkpoint: %s", error) + self.current_checkpoint = None + self.is_vision = False + self.is_peft = False + return False, error + + def export_merged_model( + self, + save_directory: str, + format_type: str = "16-bit (FP16)", + push_to_hub: bool = False, + repo_id: Optional[str] = None, + hf_token: Optional[str] = None, + private: bool = False, + ) -> Tuple[bool, str]: + """Export merged PEFT model.""" + return self._run_export( + "merged", + { + "save_directory": save_directory, + "format_type": format_type, + "push_to_hub": push_to_hub, + "repo_id": repo_id, + "hf_token": hf_token, + "private": private, + }, + ) + + def export_base_model( + self, + save_directory: str, + push_to_hub: bool = False, + repo_id: Optional[str] = None, + hf_token: Optional[str] = None, + private: bool = False, + base_model_id: Optional[str] = None, + ) -> Tuple[bool, str]: + """Export base model (non-PEFT).""" + return self._run_export( + "base", + { + "save_directory": save_directory, + "push_to_hub": push_to_hub, + "repo_id": repo_id, + "hf_token": hf_token, + "private": private, + "base_model_id": base_model_id, + }, + ) + + def export_gguf( + self, + save_directory: str, + quantization_method: str = "Q4_K_M", + push_to_hub: bool = False, + repo_id: Optional[str] = None, + hf_token: Optional[str] = None, + ) -> Tuple[bool, str]: + """Export model in GGUF format.""" + return self._run_export( + "gguf", + { + "save_directory": save_directory, + "quantization_method": quantization_method, + "push_to_hub": push_to_hub, + "repo_id": repo_id, + "hf_token": hf_token, + }, + ) + + def export_lora_adapter( + self, + save_directory: str, + push_to_hub: bool = False, + repo_id: Optional[str] = None, + hf_token: Optional[str] = None, + private: bool = False, + ) -> Tuple[bool, str]: + """Export LoRA adapter only.""" + return self._run_export( + "lora", + { + "save_directory": save_directory, + "push_to_hub": push_to_hub, + "repo_id": repo_id, + "hf_token": hf_token, + "private": private, + }, + ) + + def _run_export(self, export_type: str, params: dict) -> Tuple[bool, str]: + """Send an export command to the subprocess and wait for result.""" + if not self._ensure_subprocess_alive(): + return False, "No export subprocess running. Load a checkpoint first." + + cmd = {"type": "export", "export_type": export_type, **params} + + try: + self._send_cmd(cmd) + resp = self._wait_response( + f"export_{export_type}_done", + timeout = 3600, # GGUF for 30B+ models can take 30+ min + ) + return resp.get("success", False), resp.get("message", "") + except RuntimeError as exc: + return False, str(exc) + + def cleanup_memory(self) -> bool: + """Cleanup export-related models from memory.""" + if not self._ensure_subprocess_alive(): + # No subprocess — just clear local state + self.current_checkpoint = None + self.is_vision = False + self.is_peft = False + return True + + try: + self._send_cmd({"type": "cleanup"}) + resp = self._wait_response("cleanup_done", timeout = 30) + success = resp.get("success", False) + except RuntimeError: + success = False + + # Shut down subprocess after cleanup — no model loaded + self._shutdown_subprocess() + + self.current_checkpoint = None + self.is_vision = False + self.is_peft = False + return success + + def scan_checkpoints( + self, outputs_dir: str = str(outputs_root()) + ) -> List[Tuple[str, list]]: + """Scan for checkpoints — no ML imports needed, runs locally.""" + from utils.models.checkpoints import scan_checkpoints + + return scan_checkpoints(outputs_dir = outputs_dir) + + +# ========== GLOBAL INSTANCE ========== +_export_backend = None + + +def get_export_backend() -> ExportOrchestrator: + """Get global export backend instance (orchestrator).""" + global _export_backend + if _export_backend is None: + _export_backend = ExportOrchestrator() + return _export_backend diff --git a/studio/backend/core/export/worker.py b/studio/backend/core/export/worker.py new file mode 100644 index 0000000000..1128a5014a --- /dev/null +++ b/studio/backend/core/export/worker.py @@ -0,0 +1,442 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Export subprocess entry point. + +Each export session runs in a persistent subprocess (mp.get_context("spawn")). +This gives us a clean Python interpreter with no stale module state — +solving the transformers version-switching problem completely. + +The subprocess stays alive while a model is loaded, accepting commands +(load, export_merged, export_base, export_gguf, export_lora, cleanup, +shutdown) via mp.Queue. + +Pattern follows core/inference/worker.py and core/training/worker.py. +""" + +from __future__ import annotations + +import structlog +from loggers import get_logger +import os +import sys +import time +import traceback +from pathlib import Path +from typing import Any + +logger = get_logger(__name__) + + +def _activate_transformers_version(model_name: str) -> None: + """Activate the correct transformers version BEFORE any ML imports. + + If the model needs transformers 5.x, prepend the pre-installed .venv_t5/ + directory to sys.path. Otherwise do nothing (default 4.57.x in .venv/). + """ + # Ensure backend is on path for utils imports + backend_path = str(Path(__file__).resolve().parent.parent.parent) + if backend_path not in sys.path: + sys.path.insert(0, backend_path) + + from utils.transformers_version import needs_transformers_5, _resolve_base_model + + resolved = _resolve_base_model(model_name) + if needs_transformers_5(resolved): + venv_t5 = os.path.join( + os.path.expanduser("~"), ".unsloth", "studio", ".venv_t5" + ) + if os.path.isdir(venv_t5): + sys.path.insert(0, venv_t5) + logger.info("Activated transformers 5.x from %s", venv_t5) + else: + # Fallback: pip install at runtime (slower, ~10-15s) + logger.warning(".venv_t5 not found at %s — installing at runtime", venv_t5) + import subprocess as sp + + os.makedirs(venv_t5, exist_ok = True) + r1 = sp.run( + [ + sys.executable, + "-m", + "pip", + "install", + "--target", + venv_t5, + "--no-deps", + "transformers==5.2.0", + ], + stdout = sp.PIPE, + stderr = sp.STDOUT, + ) + r2 = sp.run( + [ + sys.executable, + "-m", + "pip", + "install", + "--target", + venv_t5, + "--no-deps", + "huggingface_hub==1.3.0", + ], + stdout = sp.PIPE, + stderr = sp.STDOUT, + ) + if r1.returncode != 0 or r2.returncode != 0: + raise RuntimeError( + f"Failed to install transformers 5.x into {venv_t5}. " + f"pip returncode: transformers={r1.returncode}, huggingface_hub={r2.returncode}" + ) + sys.path.insert(0, venv_t5) + # Propagate to child subprocesses (e.g. GGUF converter) + _pp = os.environ.get("PYTHONPATH", "") + os.environ["PYTHONPATH"] = venv_t5 + (os.pathsep + _pp if _pp else "") + else: + logger.info("Using default transformers (4.57.x) for %s", model_name) + + +def _send_response(resp_queue: Any, response: dict) -> None: + """Send a response to the parent process.""" + try: + resp_queue.put(response) + except (OSError, ValueError) as exc: + logger.error("Failed to send response: %s", exc) + + +def _handle_load(backend, cmd: dict, resp_queue: Any) -> None: + """Handle a load_checkpoint command.""" + checkpoint_path = cmd["checkpoint_path"] + max_seq_length = cmd.get("max_seq_length", 2048) + load_in_4bit = cmd.get("load_in_4bit", True) + trust_remote_code = cmd.get("trust_remote_code", False) + + try: + _send_response( + resp_queue, + { + "type": "status", + "message": f"Loading checkpoint: {checkpoint_path}", + "ts": time.time(), + }, + ) + + success, message = backend.load_checkpoint( + checkpoint_path = checkpoint_path, + max_seq_length = max_seq_length, + load_in_4bit = load_in_4bit, + trust_remote_code = trust_remote_code, + ) + + _send_response( + resp_queue, + { + "type": "loaded", + "success": success, + "message": message, + "checkpoint": checkpoint_path if success else None, + "is_vision": backend.is_vision if success else False, + "is_peft": backend.is_peft if success else False, + "ts": time.time(), + }, + ) + + except Exception as exc: + _send_response( + resp_queue, + { + "type": "loaded", + "success": False, + "message": str(exc), + "stack": traceback.format_exc(limit = 20), + "ts": time.time(), + }, + ) + + +def _handle_export(backend, cmd: dict, resp_queue: Any) -> None: + """Handle any export command (merged, base, gguf, lora).""" + export_type = cmd["export_type"] # "merged", "base", "gguf", "lora" + response_type = f"export_{export_type}_done" + + try: + if export_type == "merged": + success, message = backend.export_merged_model( + save_directory = cmd.get("save_directory", ""), + format_type = cmd.get("format_type", "16-bit (FP16)"), + push_to_hub = cmd.get("push_to_hub", False), + repo_id = cmd.get("repo_id"), + hf_token = cmd.get("hf_token"), + private = cmd.get("private", False), + ) + elif export_type == "base": + success, message = backend.export_base_model( + save_directory = cmd.get("save_directory", ""), + push_to_hub = cmd.get("push_to_hub", False), + repo_id = cmd.get("repo_id"), + hf_token = cmd.get("hf_token"), + private = cmd.get("private", False), + base_model_id = cmd.get("base_model_id"), + ) + elif export_type == "gguf": + success, message = backend.export_gguf( + save_directory = cmd.get("save_directory", ""), + quantization_method = cmd.get("quantization_method", "Q4_K_M"), + push_to_hub = cmd.get("push_to_hub", False), + repo_id = cmd.get("repo_id"), + hf_token = cmd.get("hf_token"), + ) + elif export_type == "lora": + success, message = backend.export_lora_adapter( + save_directory = cmd.get("save_directory", ""), + push_to_hub = cmd.get("push_to_hub", False), + repo_id = cmd.get("repo_id"), + hf_token = cmd.get("hf_token"), + private = cmd.get("private", False), + ) + else: + success, message = False, f"Unknown export type: {export_type}" + + _send_response( + resp_queue, + { + "type": response_type, + "success": success, + "message": message, + "ts": time.time(), + }, + ) + + except Exception as exc: + _send_response( + resp_queue, + { + "type": response_type, + "success": False, + "message": str(exc), + "stack": traceback.format_exc(limit = 20), + "ts": time.time(), + }, + ) + + +def _handle_cleanup(backend, resp_queue: Any) -> None: + """Handle a cleanup command.""" + try: + success = backend.cleanup_memory() + _send_response( + resp_queue, + { + "type": "cleanup_done", + "success": success, + "ts": time.time(), + }, + ) + except Exception as exc: + _send_response( + resp_queue, + { + "type": "cleanup_done", + "success": False, + "message": str(exc), + "ts": time.time(), + }, + ) + + +def run_export_process( + *, + cmd_queue: Any, + resp_queue: Any, + config: dict, +) -> None: + """Subprocess entrypoint. Persistent — runs command loop until shutdown. + + Args: + cmd_queue: mp.Queue for receiving commands from parent. + resp_queue: mp.Queue for sending responses to parent. + config: Initial configuration dict with checkpoint_path. + """ + import queue as _queue + + os.environ["TOKENIZERS_PARALLELISM"] = "false" + os.environ["PYTHONWARNINGS"] = ( + "ignore" # Suppress warnings at C-level before imports + ) + + import warnings + from loggers.config import LogConfig + + if os.getenv("ENVIRONMENT_TYPE", "production") == "production": + warnings.filterwarnings("ignore") + + LogConfig.setup_logging( + service_name = "unsloth-studio-export-worker", + env = os.getenv("ENVIRONMENT_TYPE", "production"), + ) + + checkpoint_path = config["checkpoint_path"] + + # ── 1. Activate correct transformers version BEFORE any ML imports ── + try: + _activate_transformers_version(checkpoint_path) + except Exception as exc: + _send_response( + resp_queue, + { + "type": "error", + "error": f"Failed to activate transformers version: {exc}", + "stack": traceback.format_exc(limit = 20), + "ts": time.time(), + }, + ) + return + + # ── 1b. On Windows, check Triton availability (must be before import torch) ── + if sys.platform == "win32": + try: + import triton # noqa: F401 + + logger.info("Triton available — torch.compile enabled") + except ImportError: + os.environ["TORCHDYNAMO_DISABLE"] = "1" + logger.warning( + "Triton not found on Windows — torch.compile disabled. " + 'Install for better performance: pip install "triton-windows<3.7"' + ) + + # ── 2. Import ML libraries (fresh in this clean process) ── + try: + _send_response( + resp_queue, + { + "type": "status", + "message": "Importing ML libraries...", + "ts": time.time(), + }, + ) + + backend_path = str(Path(__file__).resolve().parent.parent.parent) + if backend_path not in sys.path: + sys.path.insert(0, backend_path) + + from core.export.export import ExportBackend + + import transformers + + logger.info( + "Export subprocess loaded transformers %s", transformers.__version__ + ) + + except Exception as exc: + _send_response( + resp_queue, + { + "type": "error", + "error": f"Failed to import ML libraries: {exc}", + "stack": traceback.format_exc(limit = 20), + "ts": time.time(), + }, + ) + return + + # ── 3. Create export backend and load initial checkpoint ── + try: + backend = ExportBackend() + + _handle_load(backend, config, resp_queue) + + except Exception as exc: + _send_response( + resp_queue, + { + "type": "error", + "error": f"Failed to initialize export backend: {exc}", + "stack": traceback.format_exc(limit = 20), + "ts": time.time(), + }, + ) + return + + # ── 4. Command loop — process commands until shutdown ── + logger.info("Export subprocess ready, entering command loop") + + while True: + try: + cmd = cmd_queue.get(timeout = 1.0) + except _queue.Empty: + continue + except (EOFError, OSError): + logger.info("Command queue closed, shutting down") + return + + if cmd is None: + continue + + cmd_type = cmd.get("type", "") + logger.info("Received command: %s", cmd_type) + + try: + if cmd_type == "load": + # Load a new checkpoint (reusing this subprocess) + backend.cleanup_memory() + _handle_load(backend, cmd, resp_queue) + + elif cmd_type == "export": + _handle_export(backend, cmd, resp_queue) + + elif cmd_type == "cleanup": + _handle_cleanup(backend, resp_queue) + + elif cmd_type == "status": + _send_response( + resp_queue, + { + "type": "status_response", + "checkpoint": backend.current_checkpoint, + "is_vision": backend.is_vision, + "is_peft": backend.is_peft, + "ts": time.time(), + }, + ) + + elif cmd_type == "shutdown": + logger.info("Shutdown command received, cleaning up and exiting") + try: + backend.cleanup_memory() + except Exception: + pass + _send_response( + resp_queue, + { + "type": "shutdown_ack", + "ts": time.time(), + }, + ) + return + + else: + logger.warning("Unknown command type: %s", cmd_type) + _send_response( + resp_queue, + { + "type": "error", + "error": f"Unknown command type: {cmd_type}", + "ts": time.time(), + }, + ) + + except Exception as exc: + logger.error( + "Error handling command '%s': %s", cmd_type, exc, exc_info = True + ) + _send_response( + resp_queue, + { + "type": "error", + "error": f"Command '{cmd_type}' failed: {exc}", + "stack": traceback.format_exc(limit = 20), + "ts": time.time(), + }, + ) diff --git a/studio/backend/core/inference/__init__.py b/studio/backend/core/inference/__init__.py new file mode 100644 index 0000000000..35318f6357 --- /dev/null +++ b/studio/backend/core/inference/__init__.py @@ -0,0 +1,23 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Inference submodule - Inference backend for model loading and generation + +The default get_inference_backend() returns an InferenceOrchestrator that +delegates to a subprocess. The original InferenceBackend runs inside +the subprocess and can be imported directly from .inference when needed. +""" + +from .orchestrator import InferenceOrchestrator, get_inference_backend +from .llama_cpp import LlamaCppBackend + +# Expose InferenceOrchestrator as InferenceBackend for backward compat +InferenceBackend = InferenceOrchestrator + +__all__ = [ + "InferenceBackend", + "InferenceOrchestrator", + "get_inference_backend", + "LlamaCppBackend", +] diff --git a/studio/backend/core/inference/audio_codecs.py b/studio/backend/core/inference/audio_codecs.py new file mode 100644 index 0000000000..3a418d921d --- /dev/null +++ b/studio/backend/core/inference/audio_codecs.py @@ -0,0 +1,319 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Audio codec loading and decoding for TTS inference. +Supports: SNAC (Orpheus), CSM (Sesame), BiCodec (Spark), DAC (OuteTTS) +""" + +import io +import re +import wave +import structlog +from loggers import get_logger +from typing import Optional, Tuple + +import numpy as np +import torch + +logger = get_logger(__name__) + + +def _numpy_to_wav_bytes(waveform: np.ndarray, sample_rate: int) -> bytes: + """Convert a float32 numpy waveform to WAV bytes (16-bit PCM).""" + waveform = waveform.flatten() + peak = max(abs(waveform.max()), abs(waveform.min())) + if peak > 1.0: + waveform = waveform / peak + pcm = (waveform * 32767).astype(np.int16) + + buf = io.BytesIO() + with wave.open(buf, "wb") as wf: + wf.setnchannels(1) + wf.setsampwidth(2) + wf.setframerate(sample_rate) + wf.writeframes(pcm.tobytes()) + + return buf.getvalue() + + +class AudioCodecManager: + """Manages loading and caching of audio codec models for TTS decoding.""" + + def __init__(self): + self._snac_model = None + self._bicodec_tokenizer = None + self._bicodec_repo_path = None + self._dac_audio_codec = None + + def load_codec( + self, + audio_type: str, + device: str = "cuda", + model_repo_path: Optional[str] = None, + ) -> None: + """Load the appropriate codec for the given audio type.""" + if audio_type == "snac": + self._load_snac(device) + elif audio_type == "bicodec": + self._load_bicodec(device, model_repo_path) + elif audio_type == "dac": + self._load_dac(device) + elif audio_type == "csm": + pass # CSM decoding is built into the model (output_audio=True) + else: + raise ValueError(f"Unknown audio_type: {audio_type}") + + # ── Lazy loaders ───────────────────────────────────────────── + + def _load_snac(self, device: str) -> None: + if self._snac_model is not None: + return + from snac import SNAC + + self._snac_model = ( + SNAC.from_pretrained("hubertsiuzdak/snac_24khz").to(device).eval() + ) + logger.info("Loaded SNAC codec (24kHz)") + + def _load_bicodec(self, device: str, model_repo_path: Optional[str] = None) -> None: + if self._bicodec_tokenizer is not None: + return + import os + import sys + import subprocess + + # Clone SparkAudio/Spark-TTS GitHub repo for the sparktts Python package + # (same approach as training — the HF model repos don't contain the package) + spark_code_dir = os.path.join( + os.path.dirname(model_repo_path or "."), "Spark-TTS" + ) + sparktts_pkg = os.path.join(spark_code_dir, "sparktts") + if not os.path.isdir(sparktts_pkg): + logger.info(f"Cloning SparkAudio/Spark-TTS to {spark_code_dir}...") + subprocess.run( + [ + "git", + "clone", + "--depth", + "1", + "https://github.com/SparkAudio/Spark-TTS", + spark_code_dir, + ], + check = True, + ) + + if spark_code_dir not in sys.path: + sys.path.insert(0, spark_code_dir) + + from sparktts.models.audio_tokenizer import BiCodecTokenizer + + # BiCodecTokenizer needs the MODEL repo path (contains BiCodec/ weights) + tokenizer_path = model_repo_path or spark_code_dir + self._bicodec_repo_path = tokenizer_path + self._bicodec_tokenizer = BiCodecTokenizer(tokenizer_path, device) + logger.info(f"Loaded BiCodec tokenizer from {tokenizer_path}") + + def _load_dac(self, device: str) -> None: + if self._dac_audio_codec is not None: + return + import os + import sys + import subprocess + + # Clone OuteTTS repo (same pattern as Spark-TTS / BiCodec) + # The pip package has problematic dependencies; the notebook clones and + # removes gguf_model.py, interface.py, __init__.py before importing. + base_dir = os.path.dirname(os.path.abspath(__file__)) + outetts_code_dir = os.path.join(base_dir, "OuteTTS") + outetts_pkg = os.path.join(outetts_code_dir, "outetts") + if not os.path.isdir(outetts_pkg): + logger.info(f"Cloning edwko/OuteTTS to {outetts_code_dir}...") + subprocess.run( + [ + "git", + "clone", + "--depth", + "1", + "https://github.com/edwko/OuteTTS", + outetts_code_dir, + ], + check = True, + ) + # Remove files that pull in heavy / incompatible dependencies + # (matches notebook: gguf_model.py is under models/, others under outetts/) + remove_paths = [ + os.path.join(outetts_pkg, "models", "gguf_model.py"), + os.path.join(outetts_pkg, "interface.py"), + os.path.join(outetts_pkg, "__init__.py"), + ] + for fpath in remove_paths: + if os.path.exists(fpath): + os.remove(fpath) + logger.info(f"Removed {fpath}") + + if outetts_code_dir not in sys.path: + sys.path.insert(0, outetts_code_dir) + + from outetts.version.v3.audio_processor import AudioProcessor + from outetts.models.config import ModelConfig as OuteTTSModelConfig + + dummy_config = OuteTTSModelConfig( + tokenizer_path = "OuteAI/Llama-OuteTTS-1.0-1B", + device = device, + audio_codec_path = None, + ) + processor = AudioProcessor(config = dummy_config) + self._dac_audio_codec = processor.audio_codec + logger.info("Loaded DAC audio codec") + + # ── Decoders ───────────────────────────────────────────────── + + def decode_snac( + self, generated_ids: torch.Tensor, device: str + ) -> Tuple[bytes, int]: + """ + Decode SNAC tokens (Orpheus) into WAV bytes. + + generated_ids: full model output including prompt tokens. + Looks for START_OF_SPEECH (128257) marker, extracts codes after it, + strips EOS (128258), redistributes 7-per-frame codes into 3 SNAC layers. + + Returns (wav_bytes, 24000). + """ + # Find START_OF_SPEECH token (128257) + token_indices = (generated_ids == 128257).nonzero(as_tuple = True) + if len(token_indices[1]) > 0: + cropped = generated_ids[:, token_indices[1][-1] + 1 :] + else: + # Gracefully fall back to using entire output if marker not found + logger.warning( + "No START_OF_SPEECH token (128257) found — using full generated output" + ) + cropped = generated_ids + row = cropped[0] + + # Remove EOS tokens (128258) + row = row[row != 128258] + + # Trim to multiple of 7 + row = row[: (len(row) // 7) * 7] + if len(row) == 0: + raise ValueError("No valid audio codes found after START_OF_SPEECH token") + + codes = [t.item() - 128266 for t in row] + + # Redistribute into 3 SNAC layers (7 codes per frame → 1+2+4) + layer_1, layer_2, layer_3 = [], [], [] + for i in range(len(codes) // 7): + layer_1.append(codes[7 * i]) + layer_2.append(codes[7 * i + 1] - 4096) + layer_3.append(codes[7 * i + 2] - 8192) + layer_3.append(codes[7 * i + 3] - 12288) + layer_2.append(codes[7 * i + 4] - 16384) + layer_3.append(codes[7 * i + 5] - 20480) + layer_3.append(codes[7 * i + 6] - 24576) + + snac_codes = [ + torch.tensor(layer).unsqueeze(0).to(device) + for layer in [layer_1, layer_2, layer_3] + ] + + with torch.no_grad(): + audio = self._snac_model.decode(snac_codes) + + waveform = audio.squeeze().cpu().numpy() + return _numpy_to_wav_bytes(waveform, 24000), 24000 + + def decode_csm(self, audio_values: torch.Tensor) -> Tuple[bytes, int]: + """ + Decode CSM output (already a waveform from model.generate(output_audio=True)). + Returns (wav_bytes, 24000). + """ + waveform = audio_values[0].to(torch.float32).cpu().numpy() + return _numpy_to_wav_bytes(waveform, 24000), 24000 + + def decode_bicodec(self, generated_text: str, device: str) -> Tuple[bytes, int]: + """ + Decode BiCodec tokens (Spark-TTS) from generated text. + Extracts bicodec_semantic_N and bicodec_global_N tokens via regex. + Returns (wav_bytes, sample_rate). + """ + semantic_matches = re.findall(r"<\|bicodec_semantic_(\d+)\|>", generated_text) + global_matches = re.findall(r"<\|bicodec_global_(\d+)\|>", generated_text) + + logger.info( + f"BiCodec decode: {len(global_matches)} global tokens, {len(semantic_matches)} semantic tokens" + ) + if len(global_matches) < 10: + logger.info( + f"BiCodec generated text (first 500 chars): {generated_text[:500]}" + ) + + if not semantic_matches: + raise ValueError("No bicodec_semantic tokens found in generated output") + + semantic_ids = ( + torch.tensor([int(t) for t in semantic_matches]).long().unsqueeze(0) + ) + + # Speaker encoder expects exactly 32 global tokens (token_num=32 in BiCodec config). + # Pad with zeros or truncate to 32. + GLOBAL_TOKEN_NUM = 32 + if global_matches: + raw = [int(t) for t in global_matches] + else: + raw = [] + if len(raw) < GLOBAL_TOKEN_NUM: + raw = raw + [0] * (GLOBAL_TOKEN_NUM - len(raw)) + raw = raw[:GLOBAL_TOKEN_NUM] + global_ids = torch.tensor(raw).long().unsqueeze(0) # (1, 32) + + self._bicodec_tokenizer.device = device + self._bicodec_tokenizer.model.to(device) + + wav_np = self._bicodec_tokenizer.detokenize( + global_ids.to(device), + semantic_ids.to(device), + ) + sr = self._bicodec_tokenizer.config.get("sample_rate", 16000) + return _numpy_to_wav_bytes(wav_np, sr), sr + + def decode_dac(self, generated_text: str, device: str) -> Tuple[bytes, int]: + """ + Decode DAC tokens (OuteTTS) from generated text. + Extracts c1_N and c2_N codec code tokens via regex. + Returns (wav_bytes, 24000). + """ + c1 = list(map(int, re.findall(r"<\|c1_(\d+)\|>", generated_text))) + c2 = list(map(int, re.findall(r"<\|c2_(\d+)\|>", generated_text))) + + if not c1 or not c2: + raise ValueError("No DAC code tokens (c1/c2) found in generated output") + + t = min(len(c1), len(c2)) + c1 = c1[:t] + c2 = c2[:t] + + codes = torch.tensor([[c1, c2]], dtype = torch.int64).to(device) + with torch.no_grad(): + audio = self._dac_audio_codec.decode(codes) + + waveform = audio.squeeze().cpu().numpy() + return _numpy_to_wav_bytes(waveform, 24000), 24000 + + # ── Cleanup ────────────────────────────────────────────────── + + def unload(self) -> None: + """Release all codec models from memory.""" + if self._snac_model is not None: + del self._snac_model + self._snac_model = None + if self._bicodec_tokenizer is not None: + del self._bicodec_tokenizer + self._bicodec_tokenizer = None + self._bicodec_repo_path = None + if self._dac_audio_codec is not None: + del self._dac_audio_codec + self._dac_audio_codec = None + logger.info("Unloaded all audio codecs") diff --git a/studio/backend/core/inference/inference.py b/studio/backend/core/inference/inference.py new file mode 100644 index 0000000000..ff02816953 --- /dev/null +++ b/studio/backend/core/inference/inference.py @@ -0,0 +1,1862 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Core inference backend - streamlined +""" + +from unsloth import FastLanguageModel, FastVisionModel +from unsloth.chat_templates import get_chat_template +from transformers import TextStreamer +from peft import PeftModel, PeftModelForCausalLM + +import json +import sys +import torch +from pathlib import Path +from typing import Optional, Union, Generator, Tuple +from utils.models import ModelConfig, get_base_model_from_lora +from utils.paths import is_model_cached +from utils.utils import format_error_message +from utils.hardware import get_device, clear_gpu_cache, log_gpu_memory +from core.inference.audio_codecs import AudioCodecManager +from io import StringIO +import structlog +from loggers import get_logger + + +logger = get_logger(__name__) + + +class InferenceBackend: + """Unified inference backend supporting text, vision, and LoRA models""" + + def __init__(self): + self.models = {} + self.active_model_name = None + self.loading_models = set() + self.loaded_local_models = [] # [(display_name, path), ...] + self.default_models = [ + "unsloth/Qwen3-4B-Instruct-2507", + "unsloth/Meta-Llama-3.1-8B-Instruct-bnb-4bit", + "unsloth/Mistral-Nemo-Instruct-2407-bnb-4bit", + "unsloth/Phi-3.5-mini-instruct", + "unsloth/Gemma-3-4B-it", + "unsloth/Qwen2-VL-2B-Instruct-bnb-4bit", + ] + self.device = get_device().value + self._audio_codec_manager = AudioCodecManager() + + # Thread safety — _generation_lock serializes model.generate() calls. + # Must be a regular Lock (NOT RLock) because in async FastAPI, multiple + # requests share the same event-loop thread, so RLock reentrancy lets + # concurrent compare-mode requests race on the GPU. The lock is + # acquired by the *background generation thread*, not the event-loop. + import threading + + self._generation_lock = threading.Lock() + self._model_state_lock = threading.Lock() + + logger.info(f"InferenceBackend initialized on {self.device}") + + @staticmethod + def _normalize_top_k(top_k: int) -> int: + # API supports -1 as "disable top-k"; transformers expects 0 to disable. + return 0 if top_k < 0 else top_k + + def load_model( + self, + config: ModelConfig, + max_seq_length: int = 2048, + dtype = None, + load_in_4bit: bool = True, + hf_token: Optional[str] = None, + trust_remote_code: bool = False, + ) -> bool: + """ + Load any model: base, LoRA adapter, text, or vision. + """ + try: + model_name = config.identifier + + # Check if already loaded + if model_name in self.models and self.models[model_name].get("model"): + logger.info(f"Model {model_name} already loaded") + self.active_model_name = model_name + return True + + # Check if currently loading + if model_name in self.loading_models: + logger.info(f"Model {model_name} is already being loaded") + return False + + self.loading_models.add(model_name) + + self.models[model_name] = { + "is_vision": config.is_vision, + "is_lora": config.is_lora, + "is_audio": config.is_audio, + "audio_type": config.audio_type, + "has_audio_input": config.has_audio_input, + "model_path": config.path, + "base_model": config.base_model if config.is_lora else None, + "loaded_adapters": {}, + "active_adapter": None, + } + + # ── Audio model loading path ────────────────────────── + if config.is_audio: + audio_type = config.audio_type + adapter_info = " (LoRA adapter)" if config.is_lora else "" + logger.info( + f"Loading audio ({audio_type}) model{adapter_info}: {model_name}" + ) + log_gpu_memory(f"Before loading {model_name}") + + if audio_type == "csm": + from unsloth import FastModel + from transformers import CsmForConditionalGeneration + + model, processor = FastModel.from_pretrained( + config.path, + auto_model = CsmForConditionalGeneration, + load_in_4bit = False, + token = hf_token if hf_token and hf_token.strip() else None, + trust_remote_code = trust_remote_code, + ) + FastModel.for_inference(model) + self.models[model_name]["model"] = model + self.models[model_name]["tokenizer"] = processor + self.models[model_name]["processor"] = processor + elif audio_type == "bicodec": + import os + from unsloth import FastModel + + if config.is_lora and config.base_model: + # LoRA adapter: load from local adapter path. + # base_model is e.g. /home/.../Spark-TTS-0.5B/LLM + # The BiCodec weights are in the parent dir (Spark-TTS-0.5B/). + base_path = config.base_model + if os.path.isdir(base_path): + abs_repo_path = os.path.abspath(os.path.dirname(base_path)) + else: + # base_model is an HF ID — download it + from huggingface_hub import snapshot_download + + local_dir = base_path.split("/")[-1] + repo_path = snapshot_download( + base_path, local_dir = local_dir + ) + abs_repo_path = os.path.abspath(repo_path) + + logger.info( + f"Spark-TTS LoRA: loading adapter from {config.path}, BiCodec from {abs_repo_path}" + ) + model, tokenizer = FastModel.from_pretrained( + config.path, + dtype = torch.float32, + load_in_4bit = False, + token = hf_token if hf_token and hf_token.strip() else None, + trust_remote_code = trust_remote_code, + ) + else: + # Base model: download full HF repo, then load from /LLM subfolder + from huggingface_hub import snapshot_download + + hf_repo = config.path + local_dir = hf_repo.split("/")[-1] + repo_path = snapshot_download(hf_repo, local_dir = local_dir) + abs_repo_path = os.path.abspath(repo_path) + llm_path = os.path.join(abs_repo_path, "LLM") + logger.info( + f"Spark-TTS: downloaded repo to {repo_path}, loading LLM from {llm_path}" + ) + + model, tokenizer = FastModel.from_pretrained( + llm_path, + dtype = torch.float32, + load_in_4bit = False, + token = hf_token if hf_token and hf_token.strip() else None, + trust_remote_code = trust_remote_code, + ) + + FastModel.for_inference(model) + self.models[model_name]["model"] = model + self.models[model_name]["tokenizer"] = tokenizer + self.models[model_name]["model_repo_path"] = abs_repo_path + elif audio_type == "dac": + # OuteTTS uses FastModel (not FastLanguageModel) + from unsloth import FastModel + + model, tokenizer = FastModel.from_pretrained( + config.path, + max_seq_length = max_seq_length, + load_in_4bit = False, + token = hf_token if hf_token and hf_token.strip() else None, + trust_remote_code = trust_remote_code, + ) + FastModel.for_inference(model) + self.models[model_name]["model"] = model + self.models[model_name]["tokenizer"] = tokenizer + elif audio_type == "whisper": + # Whisper ASR — uses FastModel with WhisperForConditionalGeneration + from unsloth import FastModel + from transformers import WhisperForConditionalGeneration + + model, tokenizer = FastModel.from_pretrained( + config.path, + auto_model = WhisperForConditionalGeneration, + whisper_language = "English", + whisper_task = "transcribe", + load_in_4bit = False, + token = hf_token if hf_token and hf_token.strip() else None, + trust_remote_code = trust_remote_code, + ) + FastModel.for_inference(model) + model.eval() + + # Create ASR pipeline (per notebook) + from transformers import pipeline as hf_pipeline + + whisper_pipe = hf_pipeline( + "automatic-speech-recognition", + model = model, + tokenizer = tokenizer.tokenizer, + feature_extractor = tokenizer.feature_extractor, + processor = tokenizer, + return_language = True, + torch_dtype = torch.float16, + ) + self.models[model_name]["model"] = model + self.models[model_name]["tokenizer"] = tokenizer + self.models[model_name]["whisper_pipeline"] = whisper_pipe + else: + # SNAC (Orpheus) uses FastLanguageModel + model, tokenizer = FastLanguageModel.from_pretrained( + model_name = config.path, + max_seq_length = max_seq_length, + load_in_4bit = False, + token = hf_token if hf_token and hf_token.strip() else None, + trust_remote_code = trust_remote_code, + ) + FastLanguageModel.for_inference(model) + self.models[model_name]["model"] = model + self.models[model_name]["tokenizer"] = tokenizer + + # Load the external codec for TTS audio types + # (Whisper is ASR, audio_vlm is audio input — neither needs a codec) + if audio_type not in ("whisper", "audio_vlm"): + model_repo_path = self.models[model_name].get("model_repo_path") + self._audio_codec_manager.load_codec( + audio_type, self.device, model_repo_path = model_repo_path + ) + + self.active_model_name = model_name + self.loading_models.discard(model_name) + logger.info(f"Successfully loaded audio model: {model_name}") + log_gpu_memory(f"After loading {model_name}") + return True + + model_type = "vision" if config.is_vision else "text" + adapter_info = ( + " (LoRA adapter)" if self.models[model_name]["is_lora"] else "" + ) + logger.info(f"Loading {model_type} model{adapter_info}: {model_name}") + log_gpu_memory(f"Before loading {model_name}") + + # Load model - same approach for base models and LoRA adapters + if config.is_vision: + # Vision model (or vision LoRA adapter) + model, processor = FastVisionModel.from_pretrained( + model_name = config.path, # Can be base model OR LoRA adapter path + max_seq_length = max_seq_length, + dtype = dtype, + load_in_4bit = load_in_4bit, + token = hf_token if hf_token and hf_token.strip() else None, + trust_remote_code = trust_remote_code, + ) + + # Apply inference optimization + FastVisionModel.for_inference(model) + + # FastVisionModel may return a raw tokenizer (e.g. GemmaTokenizerFast) + # instead of a proper Processor for some models (e.g. Gemma-3). + # In that case, load the real processor from the base model. + from transformers import ProcessorMixin + + if not ( + isinstance(processor, ProcessorMixin) + or hasattr(processor, "image_processor") + ): + # For LoRA adapters, use the base model. For local merged exports, + # read export_metadata.json to find the original base model. + processor_source = ( + config.base_model if config.is_lora else config.identifier + ) + if not config.is_lora and config.is_local: + _meta_path = Path(config.path) / "export_metadata.json" + try: + if _meta_path.exists(): + _meta = json.loads(_meta_path.read_text()) + if _meta.get("base_model"): + processor_source = _meta["base_model"] + except Exception: + pass + logger.warning( + f"FastVisionModel returned {type(processor).__name__} (no image_processor) " + f"for '{model_name}' — loading proper processor from '{processor_source}'" + ) + from transformers import AutoProcessor + + processor = AutoProcessor.from_pretrained( + processor_source, + token = hf_token if hf_token and hf_token.strip() else None, + trust_remote_code = trust_remote_code, + ) + logger.info( + f"Loaded {type(processor).__name__} from {processor_source}" + ) + + self.models[model_name]["model"] = model + self.models[model_name]["tokenizer"] = processor + self.models[model_name]["processor"] = processor + + else: + # Text model (or text LoRA adapter) + model, tokenizer = FastLanguageModel.from_pretrained( + model_name = config.path, # Can be base model OR LoRA adapter path + max_seq_length = max_seq_length, + dtype = dtype, + load_in_4bit = load_in_4bit, + token = hf_token if hf_token and hf_token.strip() else None, + trust_remote_code = trust_remote_code, + ) + + # Apply inference optimization + FastLanguageModel.for_inference(model) + + self.models[model_name]["model"] = model + self.models[model_name]["tokenizer"] = tokenizer + + # Load chat template info + self._load_chat_template_info(model_name) + + self.active_model_name = model_name + self.loading_models.discard(model_name) + + logger.info(f"Successfully loaded model: {model_name}") + log_gpu_memory(f"After loading {model_name}") + return True + + except Exception as e: + logger.error(f"Failed to load model: {e}") + error_msg = format_error_message(e, config.identifier) + + # Cleanup on failure + if model_name in self.models: + del self.models[model_name] + self.loading_models.discard(model_name) + + raise Exception(error_msg) + + def unload_model(self, model_name: str) -> bool: + """ + Completely removes a model from the registry and clears GPU memory. + """ + if model_name in self.models: + try: + # If this was an audio model, clean up codecs + if self.models[model_name].get("is_audio"): + self._audio_codec_manager.unload() + + logger.info(f"Unloading model '{model_name}' from memory.") + # Delete the model entry from our registry + del self.models[model_name] + + # Clear the active model if it was the one being unloaded + if self.active_model_name == model_name: + self.active_model_name = None + + # Clear GPU memory cache + clear_gpu_cache() + + # Remove stale compiled cache so the next model gets a fresh one + from utils.cache_cleanup import clear_unsloth_compiled_cache + + clear_unsloth_compiled_cache() + + logger.info(f"Model '{model_name}' successfully unloaded.") + return True + except Exception as e: + logger.error(f"Error while unloading model '{model_name}': {e}") + return False + else: + logger.warning( + f"Attempted to unload model '{model_name}', but it was not found in the registry." + ) + return True + + def revert_to_base_model(self, base_model_name: str) -> bool: + """ + Reverts the model to its pristine base state by unloading AND + deleting all adapter configurations, as instructed. + """ + if base_model_name not in self.models: + return False + + model = self.models[base_model_name].get("model") + + try: + # Step 1: Unload the adapter weights if model is a PeftModel. + if isinstance(model, (PeftModel, PeftModelForCausalLM)): + logger.info(f"Unloading LoRA adapters from '{base_model_name}'...") + unwrapped_base_model = model.unload() + self.models[base_model_name]["model"] = unwrapped_base_model + model = unwrapped_base_model + + # Step 2: Clear any lingering peft_config from the unwrapped model. + # After model.unload(), the base model may still carry a peft_config + # attribute. Removing it ensures PeftModel.from_pretrained() gets + # a clean base model without "multiple adapters" warnings. + if hasattr(model, "peft_config"): + del model.peft_config + + logger.info(f"Model '{base_model_name}' reverted to clean base state.") + return True + + except Exception as e: + logger.error(f"Failed to revert model to base state: {e}") + import traceback + + logger.error(traceback.format_exc()) + return False + + def load_for_eval( + self, + lora_path: str, + max_seq_length: int = 2048, + dtype = None, + load_in_4bit: bool = True, + hf_token: Optional[str] = None, + ) -> Tuple[bool, Optional[str], Optional[str]]: + """ + Final Corrected Version: + Ensures the base model and the specified adapter are loaded. + This function is idempotent and handles all states correctly. + """ + try: + from utils.models import ModelConfig + + lora_config = ModelConfig.from_lora_path(lora_path, hf_token) + if not lora_config: + return False, None, None + + base_model_name = lora_config.base_model + + # 1. Load the base model if it's not already in memory + if base_model_name not in self.models or not self.models[ + base_model_name + ].get("model"): + logger.info(f"Base model '{base_model_name}' not loaded, loading now.") + base_config = ModelConfig.from_ui_selection( + base_model_name, None, is_lora = False + ) + if not self.load_model( + base_config, max_seq_length, dtype, load_in_4bit, hf_token + ): + return False, None, None + + self.active_model_name = base_model_name + + # 2. Determine the required adapter name from the user's selection + adapter_name = lora_path.split("/")[-1].replace(".", "_") + + # 3. Call our robust load_adapter function to ensure this specific adapter is loaded. + # It will only load from disk if the model doesn't already have it. + adapter_success = self.load_adapter( + base_model_name = base_model_name, + adapter_path = lora_path, + adapter_name = adapter_name, + ) + if not adapter_success: + return False, base_model_name, None + + # 4. Return the correct, verified adapter name for the UI logic to use. + return True, base_model_name, adapter_name + + except Exception as e: + logger.error(f"Error during load_for_eval: {e}") + import traceback + + logger.error(traceback.format_exc()) + return False, None, None + + def load_adapter( + self, base_model_name: str, adapter_path: str, adapter_name: str + ) -> bool: + """ + Loads an adapter onto the model ONLY if it's not already attached. + """ + model = self.models[base_model_name].get("model") + + # Check if this adapter name is already part of the model's config. This is the most reliable check. + if hasattr(model, "peft_config") and adapter_name in model.peft_config: + logger.info( + f"Adapter '{adapter_name}' is already attached to the model. Skipping load." + ) + return True + + try: + logger.info( + f"Loading new adapter '{adapter_name}' from '{adapter_path}' onto {base_model_name}" + ) + model.load_adapter(adapter_path, adapter_name = adapter_name) + + # Update our internal registry ONLY after a successful load. + if "loaded_adapters" not in self.models[base_model_name]: + self.models[base_model_name]["loaded_adapters"] = {} + self.models[base_model_name]["loaded_adapters"][adapter_name] = adapter_path + + total_adapters = len(getattr(model, "peft_config", {})) + logger.info( + f"Adapter '{adapter_name}' loaded successfully. (Total unique adapters on model: {total_adapters})" + ) + return True + except Exception as e: + logger.error(f"Failed to load adapter '{adapter_name}': {e}") + return False + + def set_active_adapter(self, base_model_name: str, adapter_name: str) -> bool: + """ + Sets the active adapter for generation. This replaces the flawed 'enable_adapter'. + """ + model = self.models[base_model_name].get("model") + try: + logger.info(f"Setting active adapter to: '{adapter_name}'") + model.set_adapter(adapter_name) + self.models[base_model_name]["active_adapter"] = adapter_name + return True + except Exception as e: + # This will catch the "adapter not found" error if something goes wrong. + logger.error(f"Failed to set active adapter to '{adapter_name}': {e}") + return False + + def _apply_adapter_state(self, use_adapter: Optional[Union[bool, str]]) -> None: + """ + Apply adapter state before generation. Must be called under _generation_lock. + + Uses PEFT's disable_adapter_layers() / enable_adapter_layers() which toggle + a boolean flag on each LoRA layer. Unsloth's fast_linear_forward checks this + flag (proj.disable_adapters) and skips LoRA computation when True. + This is non-destructive — no model unloading/reloading needed. + + Args: + use_adapter: None = no change, False = disable (base model), + True = enable current adapter, str = enable specific adapter. + """ + if use_adapter is None: + return + + base = self.active_model_name + if not base or base not in self.models: + return + + model_info = self.models[base] + model = model_info.get("model") + if model is None: + return + + if use_adapter is False: + # Disable LoRA layers → base model output + if isinstance(model, (PeftModel, PeftModelForCausalLM)): + logger.info( + f"Compare mode: disabling adapters on '{base}' for base model generation" + ) + model.base_model.disable_adapter_layers() + else: + logger.info( + f"Compare mode: model '{base}' is not a PeftModel, already base" + ) + + elif use_adapter is True: + # Re-enable LoRA layers → adapter output + if isinstance(model, (PeftModel, PeftModelForCausalLM)): + logger.info( + f"Compare mode: enabling adapters on '{base}' for LoRA generation" + ) + model.base_model.enable_adapter_layers() + else: + logger.warning("use_adapter=true but model is not a PeftModel") + + elif isinstance(use_adapter, str): + # Enable adapters and set the specific one active + if isinstance(model, (PeftModel, PeftModelForCausalLM)): + logger.info( + f"Compare mode: enabling adapter '{use_adapter}' on '{base}'" + ) + model.base_model.enable_adapter_layers() + self.set_active_adapter(base, use_adapter) + else: + logger.warning( + f"use_adapter='{use_adapter}' but model is not a PeftModel" + ) + + def generate_with_adapter_control( + self, + use_adapter: Optional[Union[bool, str]] = None, + cancel_event = None, + **gen_kwargs, + ) -> Generator[str, None, None]: + """ + Thread-safe generation with optional adapter toggling. + + The adapter toggle + model.generate() are serialized by _generation_lock + inside the background generation thread — NOT in the event-loop thread. + This prevents the RLock-reentrant race that occurs when two async SSE + handlers share the same event-loop thread. + + Args: + use_adapter: Adapter control (None/False/True/str). See _apply_adapter_state. + **gen_kwargs: Forwarded to generate_chat_response. + """ + yield from self._generate_chat_response_inner( + cancel_event = cancel_event, _adapter_state = use_adapter, **gen_kwargs + ) + + def generate_chat_response( + self, + messages: list, + system_prompt: str, + image = None, + temperature: float = 0.7, + top_p: float = 0.9, + top_k: int = 40, + min_p: float = 0.0, + max_new_tokens: int = 256, + repetition_penalty: float = 1.1, + cancel_event = None, + ) -> Generator[str, None, None]: + """ + Generate response for text or vision models. + The generation lock is acquired by the background generation thread. + """ + yield from self._generate_chat_response_inner( + messages = messages, + system_prompt = system_prompt, + image = image, + temperature = temperature, + top_p = top_p, + top_k = top_k, + min_p = min_p, + max_new_tokens = max_new_tokens, + repetition_penalty = repetition_penalty, + cancel_event = cancel_event, + ) + + def _generate_chat_response_inner( + self, + messages: list, + system_prompt: str = "", + image = None, + temperature: float = 0.7, + top_p: float = 0.9, + top_k: int = 40, + min_p: float = 0.0, + max_new_tokens: int = 256, + repetition_penalty: float = 1.1, + cancel_event = None, + _adapter_state = None, + ) -> Generator[str, None, None]: + """ + Inner generation logic. Called by both generate_chat_response + and generate_with_adapter_control. + + _adapter_state is passed to generate_stream/vision so the background + thread can toggle adapters under the generation lock. + """ + if not self.active_model_name: + yield "Error: No active model" + return + + model_info = self.models[self.active_model_name] + is_vision = model_info.get("is_vision", False) + tokenizer = model_info.get("tokenizer") or model_info.get("processor") + # Unwrap processor → raw tokenizer for VLMs on the text path + tokenizer = getattr(tokenizer, "tokenizer", tokenizer) + top_k = self._normalize_top_k(top_k) + + if is_vision and image: + # Vision model generation (only when an image is actually provided) + # Check that the stored processor can actually handle images. + # FastVisionModel may return a raw tokenizer (e.g. GemmaTokenizerFast) + # instead of a proper ProcessorMixin for some models (e.g. Gemma-3). + from transformers import ProcessorMixin + + processor = model_info.get("processor") + has_image_processing = processor is not None and ( + isinstance(processor, ProcessorMixin) + or hasattr(processor, "image_processor") + ) + if has_image_processing: + yield from self._generate_vision_response( + messages, + system_prompt, + image, + temperature, + top_p, + top_k, + min_p, + max_new_tokens, + repetition_penalty, + cancel_event = cancel_event, + ) + return + else: + logger.warning( + f"Model '{self.active_model_name}' is marked as vision but its processor " + f"({type(processor).__name__}) has no image_processor — " + f"falling back to text-only generation (image will be ignored)." + ) + + # Text path: Use training pipeline approach + # Messages are already in ChatML format from eval.py + + # Step 1: Apply get_chat_template if model is in mapper + try: + from utils.datasets import ( + MODEL_TO_TEMPLATE_MAPPER, + get_tokenizer_chat_template, + ) + + model_name_lower = self.active_model_name.lower() + + # Check if model has a registered template + if model_name_lower in MODEL_TO_TEMPLATE_MAPPER: + template_name = MODEL_TO_TEMPLATE_MAPPER[model_name_lower] + logger.info( + f"Applying chat template '{template_name}' for {self.active_model_name}" + ) + + # This modifies the tokenizer with the correct template + tokenizer = get_chat_template( + tokenizer, + chat_template = template_name, + ) + else: + logger.info( + f"No registered template for {self.active_model_name}, using tokenizer default" + ) + except Exception as e: + logger.warning(f"Could not apply get_chat_template: {e}") + + # Step 2: Format with tokenizer.apply_chat_template() + try: + formatted_prompt = tokenizer.apply_chat_template( + messages, tokenize = False, add_generation_prompt = True + ) + logger.debug(f"Formatted prompt: {formatted_prompt[:200]}...") + except Exception as e: + logger.error(f"Error applying chat template: {e}") + # Fallback to manual formatting + formatted_prompt = self.format_chat_prompt(messages, system_prompt) + + # Step 3: Generate + yield from self.generate_stream( + formatted_prompt, + temperature, + top_p, + top_k, + min_p, + max_new_tokens, + repetition_penalty, + cancel_event = cancel_event, + _adapter_state = _adapter_state, + ) + + def _generate_vision_response( + self, + messages, + system_prompt, + image, + temperature, + top_p, + top_k, + min_p, + max_new_tokens, + repetition_penalty, + cancel_event = None, + ) -> Generator[str, None, None]: + """Handle vision model generation with true token-by-token streaming.""" + model_info = self.models[self.active_model_name] + model = model_info["model"] + processor = model_info["processor"] + # FastVisionModel may return a raw tokenizer (e.g. GemmaTokenizerFast) + # instead of a Processor for some models. Safe unwrap for tokenize-only ops. + raw_tokenizer = getattr(processor, "tokenizer", processor) + + # Extract user message + user_message = "" + if messages and messages[-1]["role"] == "user": + import re + + user_message = messages[-1]["content"] + user_message = re.sub(r"]*>", "", user_message).strip() + + if not user_message: + user_message = "Describe this image." if image else "Hello" + + # Prepare vision messages + if image: + vision_messages = [ + { + "role": "user", + "content": [ + {"type": "image"}, + {"type": "text", "text": user_message}, + ], + } + ] + + input_text = processor.apply_chat_template( + vision_messages, add_generation_prompt = True, tokenize = False + ) + inputs = processor( + image, + input_text, + add_special_tokens = False, + return_tensors = "pt", + ).to(self.device) + else: + # Text-only for vision model + formatted_prompt = self.format_chat_prompt(messages, system_prompt) + inputs = raw_tokenizer(formatted_prompt, return_tensors = "pt").to( + self.device + ) + + # Stream with TextIteratorStreamer + background thread + try: + from transformers import TextIteratorStreamer + import threading + + streamer = TextIteratorStreamer( + raw_tokenizer, + skip_prompt = True, + skip_special_tokens = True, + timeout = 0.2, + ) + + generation_kwargs = dict( + **inputs, + streamer = streamer, + max_new_tokens = max_new_tokens, + use_cache = True, + do_sample = temperature > 0, + temperature = temperature, + top_p = top_p, + top_k = top_k, + min_p = min_p, + ) + + err: dict[str, str] = {} + + def generate_fn(): + with self._generation_lock: + try: + model.generate(**generation_kwargs) + except Exception as e: + err["msg"] = str(e) + logger.error(f"Vision generation error in thread: {e}") + finally: + try: + streamer.end() + except Exception: + pass + + thread = threading.Thread(target = generate_fn) + thread.start() + + output = "" + from queue import Empty + + try: + while True: + if cancel_event is not None and cancel_event.is_set(): + break + try: + new_token = next(streamer) + except StopIteration: + break + except Empty: + if not thread.is_alive(): + break + continue + if new_token: + output += new_token + cleaned = self._clean_generated_text(output) + yield cleaned + finally: + if cancel_event is not None: + cancel_event.set() + thread.join(timeout = 10) + if thread.is_alive(): + logger.warning( + "Vision generation thread did not exit after cancel/join timeout" + ) + + if err.get("msg"): + yield f"Error: {err['msg']}" + + except Exception as e: + logger.error(f"Vision generation error: {e}") + yield f"Error: {str(e)}" + + def generate_audio_input_response( + self, + messages, + system_prompt, + audio_array, + temperature, + top_p, + top_k, + min_p, + max_new_tokens, + repetition_penalty, + cancel_event = None, + ) -> Generator[str, None, None]: + """Handle audio input (ASR) generation — accepts audio numpy array, streams text output. + + Uses processor.apply_chat_template with audio embedded in messages (Gemma 3n pattern). + """ + import threading + import numpy as np + + model_info = self.models[self.active_model_name] + model = model_info["model"] + processor = model_info.get("processor") or model_info.get("tokenizer") + raw_tokenizer = getattr(processor, "tokenizer", processor) + + # Extract last user text — default matches notebook prompt + user_text = "Please transcribe this audio." + if messages: + for msg in reversed(messages): + if msg["role"] == "user" and msg.get("content"): + user_text = msg["content"] + break + + # Use ASR-specific system prompt if user hasn't set a custom one + if not system_prompt or system_prompt == "You are a helpful AI assistant.": + system_prompt = "You are an assistant that transcribes speech accurately." + + # Build messages in Gemma 3n format — audio goes INTO apply_chat_template + audio_messages = [ + {"role": "system", "content": [{"type": "text", "text": system_prompt}]}, + { + "role": "user", + "content": [ + {"type": "audio", "audio": audio_array}, + {"type": "text", "text": user_text}, + ], + }, + ] + + # apply_chat_template handles audio embedding + tokenization in one step + inputs = processor.apply_chat_template( + audio_messages, + add_generation_prompt = True, + tokenize = True, + return_dict = True, + return_tensors = "pt", + truncation = False, + ).to(self.device) + + try: + from transformers import TextIteratorStreamer + from queue import Empty + + streamer = TextIteratorStreamer( + raw_tokenizer, + skip_prompt = True, + skip_special_tokens = True, + timeout = 0.2, + ) + + # Notebook uses do_sample=False for ASR (greedy decoding for accuracy) + generation_kwargs = dict( + **inputs, + streamer = streamer, + max_new_tokens = max_new_tokens, + use_cache = True, + do_sample = False, + ) + + err: dict[str, str] = {} + + def generate_fn(): + with self._generation_lock: + try: + model.generate(**generation_kwargs) + except Exception as e: + err["msg"] = str(e) + logger.error(f"Audio input generation error in thread: {e}") + finally: + try: + streamer.end() + except Exception: + pass + + thread = threading.Thread(target = generate_fn) + thread.start() + + output = "" + try: + while True: + if cancel_event is not None and cancel_event.is_set(): + break + try: + new_token = next(streamer) + except StopIteration: + break + except Empty: + if not thread.is_alive(): + break + continue + if new_token: + output += new_token + yield new_token + finally: + if cancel_event is not None: + cancel_event.set() + thread.join(timeout = 10) + if thread.is_alive(): + logger.warning( + "Audio input generation thread did not exit after cancel/join timeout" + ) + + if err.get("msg"): + yield f"Error: {err['msg']}" + + except Exception as e: + logger.error(f"Audio input generation error: {e}") + yield f"Error: {str(e)}" + + def generate_whisper_response( + self, audio_array, cancel_event = None + ) -> Generator[str, None, None]: + """Whisper ASR — takes audio numpy array, yields transcribed text. + + Uses the pre-built transformers pipeline (created during model loading). + """ + model_info = self.models[self.active_model_name] + whisper_pipe = model_info.get("whisper_pipeline") + if not whisper_pipe: + yield "Error: Whisper pipeline not initialized" + return + + try: + with self._generation_lock: + result = whisper_pipe({"raw": audio_array, "sampling_rate": 16000}) + + text = result.get("text", "") if isinstance(result, dict) else str(result) + if text: + yield text + except Exception as e: + logger.error(f"Whisper ASR error: {e}") + yield f"Error: {str(e)}" + + def generate_stream( + self, + prompt: str, + temperature: float = 0.7, + top_p: float = 0.9, + top_k: int = 40, + min_p: float = 0.0, + max_new_tokens: int = 256, + repetition_penalty: float = 1.1, + cancel_event = None, + _adapter_state = None, + ) -> Generator[str, None, None]: + """Generate streaming text response (text models only). + + _adapter_state: if not None, the background thread toggles adapters + before model.generate(), all under _generation_lock. + """ + if not self.active_model_name: + yield "Error: No active model" + return + + model_info = self.models[self.active_model_name] + model = model_info["model"] + # For VLMs the stored "tokenizer" is actually the processor. + # Unwrap to get the real tokenizer so TextIteratorStreamer's + # skip_prompt / skip_special_tokens work correctly. + tokenizer = model_info["tokenizer"] + tokenizer = getattr(tokenizer, "tokenizer", tokenizer) + + try: + inputs = tokenizer(prompt, return_tensors = "pt").to(model.device) + + from transformers import TextIteratorStreamer + import threading + + streamer = TextIteratorStreamer( + tokenizer, + skip_prompt = True, + skip_special_tokens = True, + timeout = 0.2, + ) + + generation_kwargs = dict( + **inputs, + streamer = streamer, + max_new_tokens = max_new_tokens, + temperature = temperature, + top_p = top_p, + top_k = top_k, + min_p = min_p, + repetition_penalty = repetition_penalty, + do_sample = temperature > 0, + eos_token_id = tokenizer.eos_token_id, + pad_token_id = tokenizer.eos_token_id + if tokenizer.pad_token_id is None + else tokenizer.pad_token_id, + ) + if cancel_event is not None: + from transformers.generation.stopping_criteria import ( + StoppingCriteria, + StoppingCriteriaList, + ) + + class _CancelCriteria(StoppingCriteria): + def __init__(self, ev): + self.ev = ev + + def __call__(self, input_ids, scores, **kwargs): + return self.ev.is_set() + + generation_kwargs["stopping_criteria"] = StoppingCriteriaList( + [_CancelCriteria(cancel_event)] + ) + + def generate_fn(): + with self._generation_lock: + try: + if _adapter_state is not None: + self._apply_adapter_state(_adapter_state) + model.generate(**generation_kwargs) + except Exception as e: + err["msg"] = str(e) + logger.error(f"Generation error: {e}") + finally: + try: + streamer.end() + except Exception: + pass + + err: dict[str, str] = {} + thread = threading.Thread(target = generate_fn) + thread.start() + + output = "" + from queue import Empty + + try: + while True: + if cancel_event is not None and cancel_event.is_set(): + break + try: + new_token = next(streamer) + except StopIteration: + break + except Empty: + if not thread.is_alive(): + break + continue + if new_token: + output += new_token + cleaned = self._clean_generated_text(output) + yield cleaned + finally: + if cancel_event is not None: + cancel_event.set() + thread.join(timeout = 10) + if thread.is_alive(): + logger.warning( + "Generation thread did not exit after cancel/join timeout" + ) + + if err.get("msg"): + yield f"Error: {err['msg']}" + + except Exception as e: + logger.error(f"Error during generation: {e}") + yield f"Error: {str(e)}" + + # ── Audio (TTS) Generation ──────────────────────────────────── + + def generate_audio_response( + self, + text: str, + temperature: float = 0.6, + top_p: float = 0.95, + top_k: int = 50, + min_p: float = 0.0, + max_new_tokens: int = 2048, + repetition_penalty: float = 1.1, + use_adapter: Optional[Union[bool, str]] = None, + ) -> Tuple[bytes, int]: + """ + Generate audio from text for TTS models. + Returns (wav_bytes, sample_rate). + Blocking — generates complete audio before returning. + """ + if not self.active_model_name: + raise RuntimeError("No active model") + + model_info = self.models[self.active_model_name] + audio_type = model_info.get("audio_type") + model = model_info["model"] + tokenizer = model_info.get("tokenizer") + + if not audio_type: + raise RuntimeError(f"Model {self.active_model_name} is not an audio model") + + top_k = self._normalize_top_k(top_k) + + with self._generation_lock: + if use_adapter is not None: + self._apply_adapter_state(use_adapter) + + if audio_type == "snac": + return self._generate_snac( + model, + tokenizer, + text, + temperature, + top_p, + max_new_tokens, + repetition_penalty, + ) + elif audio_type == "csm": + processor = model_info.get("processor", tokenizer) + return self._generate_csm(model, processor, text, max_new_tokens) + elif audio_type == "bicodec": + return self._generate_bicodec( + model, tokenizer, text, temperature, top_k, max_new_tokens + ) + elif audio_type == "dac": + return self._generate_dac( + model, + tokenizer, + text, + temperature, + top_k, + top_p, + min_p, + max_new_tokens, + repetition_penalty, + ) + else: + raise RuntimeError(f"Unknown audio_type: {audio_type}") + + def _generate_snac( + self, + model, + tokenizer, + text, + temperature, + top_p, + max_new_tokens, + repetition_penalty, + ): + """Generate audio using SNAC codec (Orpheus).""" + device = model.device + start_token = torch.tensor([[128259]], device = device) # START_OF_HUMAN + end_tokens = torch.tensor( + [[128009, 128260]], device = device + ) # EOT, END_OF_HUMAN + text_ids = tokenizer(text, return_tensors = "pt").input_ids.to(device) + input_ids = torch.cat([start_token, text_ids, end_tokens], dim = 1) + attention_mask = torch.ones_like(input_ids) + + generated = model.generate( + input_ids = input_ids, + attention_mask = attention_mask, + max_new_tokens = max_new_tokens, + do_sample = True, + temperature = temperature, + top_p = top_p, + repetition_penalty = repetition_penalty, + eos_token_id = 128258, # END_OF_SPEECH + use_cache = True, + ) + return self._audio_codec_manager.decode_snac(generated, str(device)) + + def _generate_csm(self, model, processor, text, max_new_tokens): + """Generate audio using CSM (Sesame).""" + speaker_id = 0 + inputs = processor( + f"[{speaker_id}]{text}", add_special_tokens = True, return_tensors = "pt" + ).to(model.device) + audio_values = model.generate( + **inputs, max_new_tokens = max_new_tokens, output_audio = True + ) + return self._audio_codec_manager.decode_csm(audio_values) + + def _generate_bicodec( + self, model, tokenizer, text, temperature, top_k, max_new_tokens + ): + """Generate audio using BiCodec (Spark-TTS).""" + prompt = ( + "<|task_tts|><|start_content|>" + + text + + "<|end_content|><|start_global_token|>" + ) + inputs = tokenizer([prompt], return_tensors = "pt").to(model.device) + generated = model.generate( + **inputs, + max_new_tokens = max_new_tokens, + do_sample = True, + temperature = temperature, + top_k = top_k, + eos_token_id = tokenizer.eos_token_id, + pad_token_id = tokenizer.pad_token_id, + ) + new_tokens = generated[:, inputs.input_ids.shape[1] :] + decoded_text = tokenizer.batch_decode(new_tokens, skip_special_tokens = False)[0] + return self._audio_codec_manager.decode_bicodec(decoded_text, str(model.device)) + + def _generate_dac( + self, + model, + tokenizer, + text, + temperature, + top_k, + top_p, + min_p, + max_new_tokens, + repetition_penalty, + ): + """Generate audio using DAC (OuteTTS). Follows Oute_TTS_(1B).ipynb exactly.""" + # Monkey-patch RepetitionPenaltyLogitsProcessor with a 64-token penalty + # window (same as the OuteTTS notebook) to avoid degenerate repetition. + self._patch_repetition_penalty_processor() + + prompt = ( + "<|im_start|>\n<|text_start|>" + + text + + "<|text_end|>\n<|audio_start|><|global_features_start|>\n" + ) + with torch.inference_mode(): + with torch.amp.autocast("cuda", dtype = model.dtype): + inputs = tokenizer([prompt], return_tensors = "pt").to(model.device) + generated = model.generate( + **inputs, + temperature = temperature, + top_k = top_k, + top_p = top_p, + min_p = min_p, + repetition_penalty = repetition_penalty, + max_new_tokens = max_new_tokens, + ) + decoded_text = tokenizer.batch_decode(generated, skip_special_tokens = False)[0] + return self._audio_codec_manager.decode_dac(decoded_text, str(model.device)) + + _repetition_penalty_patched = False + + @classmethod + def _patch_repetition_penalty_processor(cls): + """ + Monkey-patch transformers' RepetitionPenaltyLogitsProcessor with a + 64-token sliding window variant (from the OuteTTS notebook). + Only applied once per process. + """ + if cls._repetition_penalty_patched: + return + cls._repetition_penalty_patched = True + + from transformers import LogitsProcessor + import transformers.generation.utils as generation_utils + + class RepetitionPenaltyLogitsProcessorPatch(LogitsProcessor): + def __init__(self, penalty: float): + self.penalty_last_n = 64 + if not isinstance(penalty, float) or penalty <= 0: + raise ValueError( + f"`penalty` has to be a positive float, but is {penalty}" + ) + self.penalty = penalty + + @torch.no_grad() + def __call__( + self, input_ids: torch.LongTensor, scores: torch.FloatTensor + ) -> torch.FloatTensor: + if self.penalty_last_n == 0 or self.penalty == 1.0: + return scores + batch_size, seq_len = input_ids.shape + vocab_size = scores.shape[-1] + for b in range(batch_size): + start_index = max(0, seq_len - self.penalty_last_n) + window_indices = input_ids[b, start_index:] + if window_indices.numel() == 0: + continue + for token_id in set(window_indices.tolist()): + if token_id >= vocab_size: + continue + logit = scores[b, token_id] + scores[b, token_id] = ( + logit * self.penalty if logit <= 0 else logit / self.penalty + ) + return scores + + generation_utils.RepetitionPenaltyLogitsProcessor = ( + RepetitionPenaltyLogitsProcessorPatch + ) + logger.info( + "Patched RepetitionPenaltyLogitsProcessor with 64-token window for OuteTTS" + ) + + def format_chat_prompt(self, messages: list, system_prompt: str = None) -> str: + if not self.active_model_name or self.active_model_name not in self.models: + logger.error("No active model available") + return "" + + if self.models[self.active_model_name].get("tokenizer") is None: + logger.error("Tokenizer not loaded for active model") + return "" + + chat_template_info = self.models[self.active_model_name].get( + "chat_template_info", {} + ) + tokenizer = self.models[self.active_model_name]["tokenizer"] + tokenizer = getattr(tokenizer, "tokenizer", tokenizer) + + chat_messages = [] + + if system_prompt: + chat_messages.append({"role": "system", "content": system_prompt}) + + last_role = "system" if system_prompt else None + + for msg in messages: + role = msg.get("role", "") + content = msg.get("content", "") + + if role in ["system", "user", "assistant"] and content.strip(): + if role == last_role: + logger.debug( + f"Skipping consecutive {role} message to maintain alternation" + ) + continue + + if role == "user": + import re + + clean_content = re.sub(r"<[^>]+>", "", content).strip() + if clean_content: + chat_messages.append({"role": role, "content": clean_content}) + last_role = role + elif role == "assistant" and content.strip(): + chat_messages.append({"role": role, "content": content}) + last_role = role + elif role == "system": + continue + + if chat_messages and chat_messages[-1]["role"] == "assistant": + logger.debug( + "Removing final assistant message to ensure proper alternation" + ) + chat_messages.pop() + + logger.info(f"Sending {len(chat_messages)} messages to tokenizer:") + for i, msg in enumerate(chat_messages): + logger.info(f" {i}: {msg['role']} - {msg['content'][:50]}...") + + try: + formatted_prompt = tokenizer.apply_chat_template( + chat_messages, tokenize = False, add_generation_prompt = True + ) + logger.info(f"Successfully applied tokenizer's native chat template") + return formatted_prompt + except Exception as e: + error_msg = str(e).lower() + if ( + "chat_template is not set" in error_msg + or "no template argument" in error_msg + ): + logger.info( + f"Base model detected - no built-in chat template available, using fallback formatting" + ) + else: + logger.warning(f"Failed to apply tokenizer chat template: {e}") + logger.debug( + f"""Failed with messages: {[f"{m['role']}: {m['content'][:30]}..." for m in chat_messages]}""" + ) + + if chat_template_info.get("has_template", False): + logger.info( + "Falling back to manual template formatting based on detected patterns" + ) + template_type = chat_template_info.get("format_type", "generic") + manual_prompt = self._format_chat_manual( + chat_messages, + template_type, + chat_template_info.get("special_tokens", {}), + ) + logger.info(f"Manual template result: {manual_prompt[:200]}...") + return manual_prompt + else: + logger.info("Using generic chat formatting for base model") + return self._format_generic_template(chat_messages, {}) + + def _format_chat_manual( + self, messages: list, template_type: str, special_tokens: dict + ) -> str: + """ + Manual chat formatting fallback for when tokenizer template fails + + Args: + messages: List of message dictionaries + template_type: Detected template type + special_tokens: Dictionary of special tokens + + Returns: + str: Manually formatted prompt + """ + if template_type == "llama3": + return self._format_llama3_template(messages, special_tokens) + elif template_type == "mistral": + return self._format_mistral_template(messages, special_tokens) + elif template_type == "chatml": + return self._format_chatml_template(messages, special_tokens) + elif template_type == "alpaca": + return self._format_alpaca_template(messages, special_tokens) + else: + return self._format_generic_template(messages, special_tokens) + + def _format_llama3_template(self, messages: list, special_tokens: dict) -> str: + """Format messages using Llama 3 template""" + bos_token = special_tokens.get("bos_token", "<|begin_of_text|>") + formatted = bos_token + + for msg in messages: + role = msg["role"] + content = msg["content"] + formatted += ( + f"<|start_header_id|>{role}<|end_header_id|>\n\n{content}<|eot_id|>" + ) + + formatted += "<|start_header_id|>assistant<|end_header_id|>\n\n" + return formatted + + def _format_mistral_template(self, messages: list, special_tokens: dict) -> str: + """Format messages using Mistral template""" + bos_token = special_tokens.get("bos_token", "") + formatted = bos_token + + system_msg = None + conversation = [] + + for msg in messages: + if msg["role"] == "system": + system_msg = msg["content"] + else: + conversation.append(msg) + + i = 0 + while i < len(conversation): + if conversation[i]["role"] == "user": + user_content = conversation[i]["content"] + + if system_msg and i == 0: + user_content = f"{system_msg}\n\n{user_content}" + + formatted += f"[INST] {user_content} [/INST]" + + if ( + i + 1 < len(conversation) + and conversation[i + 1]["role"] == "assistant" + ): + formatted += f" {conversation[i + 1]['content']}" + i += 2 + else: + formatted += " " + break + else: + i += 1 + + return formatted + + def _format_chatml_template(self, messages: list, special_tokens: dict) -> str: + """Format messages using ChatML template""" + formatted = "" + + for msg in messages: + role = msg["role"] + content = msg["content"] + formatted += f"<|im_start|>{role}\n{content}<|im_end|>\n" + + formatted += "<|im_start|>assistant\n" + return formatted + + def _format_alpaca_template(self, messages: list, special_tokens: dict) -> str: + """Format messages using Alpaca template""" + formatted = "" + system_msg = None + + for msg in messages: + if msg["role"] == "system": + system_msg = msg["content"] + elif msg["role"] == "user": + if system_msg: + formatted += f"### Instruction:\n{system_msg}\n\n### Input:\n{msg['content']}\n\n### Response:\n" + system_msg = None + else: + formatted += f"### Human:\n{msg['content']}\n\n### Assistant:\n" + elif msg["role"] == "assistant": + formatted += f"{msg['content']}\n\n" + + return formatted + + def _format_generic_template(self, messages: list, special_tokens: dict) -> str: + """Generic fallback formatting""" + formatted = "" + + for msg in messages: + role = msg["role"].title() + content = msg["content"] + formatted += f"{role}: {content}\n" + + formatted += "Assistant: " + return formatted + + def check_vision_model_compatibility(self) -> bool: + """ + Check if current model supports vision. + + Returns: + bool: True if current model supports vision, False otherwise + """ + current_model = self.get_current_model() + if current_model and current_model in self.models: + return self.models[current_model].get("is_vision", False) + return False + + def _reset_model_generation_state(self, model_name: str): + """Reset generation state for a specific model to prevent contamination.""" + if model_name not in self.models: + return + + model = self.models[model_name].get("model") + if not model: + return + + try: + # This is a common pattern for Unsloth/Hugging Face models + if hasattr(model, "past_key_values"): + model.past_key_values = None + if hasattr(model, "generation_config"): + if hasattr(model.generation_config, "past_key_values"): + model.generation_config.past_key_values = None + + logger.debug(f"Reset generation state for model: {model_name}") + except Exception as e: + logger.warning(f"Could not fully reset model state for {model_name}: {e}") + + def reset_generation_state(self): + """Reset any cached generation state to prevent hanging after errors""" + try: + # Clear cached states for ALL loaded models + for model_name in self.models.keys(): + self._reset_model_generation_state(model_name) + + clear_gpu_cache() + logger.debug("Cleared GPU cache") + + import gc + + gc.collect() + logger.info("Performed comprehensive generation state reset") + + except Exception as e: + logger.warning(f"Could not fully reset generation state: {e}") + + def resize_image(self, img, max_size: int = 800): + """Resize image while maintaining aspect ratio if either dimension exceeds max_size""" + if img is None: + return None + if img.size[0] > max_size or img.size[1] > max_size: + from PIL import Image + + ratio = min(max_size / img.size[0], max_size / img.size[1]) + new_size = (int(img.size[0] * ratio), int(img.size[1] * ratio)) + return img.resize(new_size, Image.Resampling.LANCZOS) + return img + + def _clean_generated_text(self, text: str) -> str: + """Strip leaked special tokens using the tokenizer's own token list.""" + tokenizer = self.models.get(self.active_model_name, {}).get("tokenizer") + if tokenizer: + for token in getattr(tokenizer, "all_special_tokens", []): + if token in text: + text = text.replace(token, "") + return text.strip() + + def _load_chat_template_info(self, model_name: str): + if model_name not in self.models or not self.models[model_name].get( + "tokenizer" + ): + return + + tokenizer = self.models[model_name]["tokenizer"] + chat_template_info = { + "has_template": False, + "template": None, + "format_type": "generic", + "special_tokens": {}, + "template_name": None, + } + + try: + from utils.datasets import MODEL_TO_TEMPLATE_MAPPER + + # Try exact match first + model_name_lower = model_name.lower() + if model_name_lower in MODEL_TO_TEMPLATE_MAPPER: + chat_template_info["template_name"] = MODEL_TO_TEMPLATE_MAPPER[ + model_name_lower + ] + logger.info( + f"Detected template '{chat_template_info['template_name']}' for {model_name} from mapper" + ) + else: + # Try partial match (for variants like model_name-bnb-4bit) + for key in MODEL_TO_TEMPLATE_MAPPER: + if key in model_name_lower or model_name_lower in key: + chat_template_info["template_name"] = MODEL_TO_TEMPLATE_MAPPER[ + key + ] + logger.info( + f"Detected template '{chat_template_info['template_name']}' for {model_name} (partial match)" + ) + break + except Exception as e: + logger.warning( + f"Could not detect template from mapper for {model_name}: {e}" + ) + + try: + if hasattr(tokenizer, "chat_template") and tokenizer.chat_template: + chat_template_info["has_template"] = True + chat_template_info["template"] = tokenizer.chat_template + + template_str = tokenizer.chat_template.lower() + + if ( + "start_header_id" in template_str + and "end_header_id" in template_str + ): + chat_template_info["format_type"] = "llama3" + elif "[inst]" in template_str and "[/inst]" in template_str: + chat_template_info["format_type"] = "mistral" + elif "<|im_start|>" in template_str and "<|im_end|>" in template_str: + chat_template_info["format_type"] = "chatml" + elif "### instruction:" in template_str or "### human:" in template_str: + chat_template_info["format_type"] = "alpaca" + else: + chat_template_info["format_type"] = "custom" + + logger.info( + f"Loaded chat template for {model_name} (detected as {chat_template_info['format_type']} format)" + ) + logger.debug(f"Template preview: {tokenizer.chat_template[:200]}...") + + special_tokens = {} + if hasattr(tokenizer, "bos_token") and tokenizer.bos_token: + special_tokens["bos_token"] = tokenizer.bos_token + if hasattr(tokenizer, "eos_token") and tokenizer.eos_token: + special_tokens["eos_token"] = tokenizer.eos_token + if hasattr(tokenizer, "pad_token") and tokenizer.pad_token: + special_tokens["pad_token"] = tokenizer.pad_token + + chat_template_info["special_tokens"] = special_tokens + + else: + logger.info( + f"No chat template found for {model_name}, will use generic formatting" + ) + + except Exception as e: + logger.error(f"Error loading chat template info for {model_name}: {e}") + + self.models[model_name]["chat_template_info"] = chat_template_info + + if chat_template_info["has_template"]: + logger.info( + f"Chat template loaded for {model_name}: {chat_template_info['format_type']} format" + ) + else: + logger.info( + f"No built-in chat template for {model_name}, will use generic formatting" + ) + + def get_current_model(self) -> Optional[str]: + """Get currently active model name""" + return self.active_model_name + + def is_model_loading(self) -> bool: + """Check if any model is currently loading""" + return len(self.loading_models) > 0 + + def get_loading_model(self) -> Optional[str]: + """Get name of currently loading model""" + return next(iter(self.loading_models)) if self.loading_models else None + + def load_model_simple( + self, + model_path: str, + hf_token: Optional[str] = None, + max_seq_length: int = 2048, + load_in_4bit: bool = True, + ) -> bool: + """ + Simple model loading wrapper for chat interface. + Accepts model path as string and handles ModelConfig creation internally. + + Args: + model_path: Model name or path (e.g., "unsloth/llama-3-8b") + hf_token: HuggingFace token for gated models + max_seq_length: Maximum sequence length + load_in_4bit: Whether to use 4-bit quantization + + Returns: + bool: True if successful, False otherwise + """ + try: + # Create config from string path + config = ModelConfig.from_ui_selection( + model_path, + lora_path = None, # No LoRA for chat + is_lora = False, + ) + + # Call existing load_model with config + return self.load_model( + config = config, + max_seq_length = max_seq_length, + dtype = None, # Auto-detect + load_in_4bit = load_in_4bit, + hf_token = hf_token, + ) + + except Exception as e: + logger.error(f"Error in load_model_simple: {e}") + return False + + +# Global inference backend instance +inference_backend = InferenceBackend() + + +def get_inference_backend() -> InferenceBackend: + return inference_backend diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py new file mode 100644 index 0000000000..21a25d51b2 --- /dev/null +++ b/studio/backend/core/inference/llama_cpp.py @@ -0,0 +1,641 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +llama-server inference backend for GGUF models. + +Manages a llama-server subprocess and proxies chat completions +through its OpenAI-compatible /v1/chat/completions endpoint. +""" + +import atexit +import json +import structlog +from loggers import get_logger +import shutil +import signal +import socket +import subprocess +import threading +import time +from pathlib import Path +from typing import Generator, Optional + +import httpx + +logger = get_logger(__name__) + + +class LlamaCppBackend: + """ + Manages a llama-server subprocess for GGUF model inference. + + Lifecycle: + 1. load_model() — starts llama-server with the GGUF file + 2. generate_chat_completion() — proxies to /v1/chat/completions, streams back + 3. unload_model() — terminates llama-server subprocess + """ + + def __init__(self): + self._process: Optional[subprocess.Popen] = None + self._port: Optional[int] = None + self._model_identifier: Optional[str] = None + self._gguf_path: Optional[str] = None + self._hf_repo: Optional[str] = None + self._hf_variant: Optional[str] = None + self._is_vision: bool = False + self._healthy = False + self._lock = threading.Lock() + self._stdout_lines: list[str] = [] + self._stdout_thread: Optional[threading.Thread] = None + + atexit.register(self._cleanup) + + # ── Properties ──────────────────────────────────────────────── + + @property + def is_loaded(self) -> bool: + return self._process is not None and self._healthy + + @property + def base_url(self) -> str: + return f"http://127.0.0.1:{self._port}" + + @property + def model_identifier(self) -> Optional[str]: + return self._model_identifier + + @property + def is_vision(self) -> bool: + return self._is_vision + + @property + def hf_variant(self) -> Optional[str]: + return self._hf_variant + + # ── Binary discovery ────────────────────────────────────────── + + @staticmethod + def _find_llama_server_binary() -> Optional[str]: + """ + Locate the llama-server binary. + + Search order: + 1. LLAMA_SERVER_PATH environment variable (direct path to binary) + 1b. UNSLOTH_LLAMA_CPP_PATH env var (custom llama.cpp install dir) + 2. ~/.unsloth/llama.cpp/llama-server (make build, root dir) + 3. ~/.unsloth/llama.cpp/build/bin/llama-server (cmake build, Linux) + 4. ~/.unsloth/llama.cpp/build/bin/Release/llama-server.exe (cmake build, Windows) + 5. ./llama.cpp/llama-server (legacy: make build, root dir) + 6. ./llama.cpp/build/bin/llama-server (legacy: cmake in-tree build) + 7. llama-server on PATH (system install) + 8. ./bin/llama-server (legacy: extracted binary) + """ + import os + import sys + + binary_name = "llama-server.exe" if sys.platform == "win32" else "llama-server" + + # 1. Env var — direct path to binary + env_path = os.environ.get("LLAMA_SERVER_PATH") + if env_path and Path(env_path).is_file(): + return env_path + + # 1b. UNSLOTH_LLAMA_CPP_PATH — custom llama.cpp install directory + custom_llama_cpp = os.environ.get("UNSLOTH_LLAMA_CPP_PATH") + if custom_llama_cpp: + custom_dir = Path(custom_llama_cpp) + # Root dir (make builds) + root_bin = custom_dir / binary_name + if root_bin.is_file(): + return str(root_bin) + # build/bin/ (cmake builds on Linux) + cmake_bin = custom_dir / "build" / "bin" / binary_name + if cmake_bin.is_file(): + return str(cmake_bin) + # build/bin/Release/ (cmake builds on Windows) + if sys.platform == "win32": + win_bin = custom_dir / "build" / "bin" / "Release" / binary_name + if win_bin.is_file(): + return str(win_bin) + + # 2–4. ~/.unsloth/llama.cpp (primary — setup.sh / setup.ps1 build here) + unsloth_home = Path.home() / ".unsloth" / "llama.cpp" + # Root dir (make builds copy binaries here) + home_root = unsloth_home / binary_name + if home_root.is_file(): + return str(home_root) + # build/bin/ (cmake builds on Linux) + home_linux = unsloth_home / "build" / "bin" / binary_name + if home_linux.is_file(): + return str(home_linux) + + # 3. Windows MSVC build has Release subdir + if sys.platform == "win32": + home_win = unsloth_home / "build" / "bin" / "Release" / binary_name + if home_win.is_file(): + return str(home_win) + + # 5–6. Legacy: in-tree build (older setup.sh / setup.ps1 versions) + project_root = Path(__file__).resolve().parents[4] + # Root dir (make builds) + root_path = project_root / "llama.cpp" / binary_name + if root_path.is_file(): + return str(root_path) + # build/bin/ (cmake builds) + build_path = project_root / "llama.cpp" / "build" / "bin" / binary_name + if build_path.is_file(): + return str(build_path) + if sys.platform == "win32": + win_path = ( + project_root / "llama.cpp" / "build" / "bin" / "Release" / binary_name + ) + if win_path.is_file(): + return str(win_path) + + # 7. System PATH + system_path = shutil.which("llama-server") + if system_path: + return system_path + + # 8. Legacy: extracted to bin/ + bin_path = project_root / "bin" / binary_name + if bin_path.is_file(): + return str(bin_path) + + return None + + # ── Port allocation ─────────────────────────────────────────── + + @staticmethod + def _find_free_port() -> int: + """Find an available TCP port.""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + # ── Stdout drain (prevents pipe deadlock on Windows) ───────── + + def _drain_stdout(self): + """ + Read lines from the subprocess stdout in a background thread. + + This prevents a pipe-buffer deadlock on Windows where the default + pipe buffer is only ~4 KB. Without draining, llama-server blocks + on writes and never becomes healthy. + """ + try: + for line in self._process.stdout: + line = line.rstrip() + if line: + self._stdout_lines.append(line) + logger.info(f"[llama-server] {line}") + except (ValueError, OSError): + # Pipe closed — process is terminating + pass + + # ── Lifecycle ───────────────────────────────────────────────── + + def load_model( + self, + *, + # Local mode: pass a path to a .gguf file + gguf_path: Optional[str] = None, + # Vision projection (mmproj) for local vision models + mmproj_path: Optional[str] = None, + # HF mode: let llama-server download via -hf "repo:quant" + hf_repo: Optional[str] = None, + hf_variant: Optional[str] = None, + hf_token: Optional[str] = None, + # Common + model_identifier: str, + is_vision: bool = False, + n_ctx: int = 4096, + n_gpu_layers: int = -1, + n_threads: Optional[int] = None, + ) -> bool: + """ + Start llama-server with a GGUF model. + + Two modes: + - Local: ``gguf_path="/path/to/model.gguf"`` → uses ``-m`` + - HF: ``hf_repo="unsloth/gemma-3-4b-it-GGUF", hf_variant="Q4_K_M"`` → uses ``-hf`` + + In HF mode, llama-server handles downloading, caching, and + auto-loading mmproj files for vision models. + + Returns True if server started and health check passed. + """ + with self._lock: + self._kill_process() + + binary = self._find_llama_server_binary() + if not binary: + raise RuntimeError( + "llama-server binary not found. " + "Run setup.sh to build it, install llama.cpp, " + "or set LLAMA_SERVER_PATH environment variable." + ) + + self._port = self._find_free_port() + + # Build command based on mode + if hf_repo: + # Download the GGUF file ourselves using huggingface_hub + # (llama-server's -hf flag requires HTTPS/curl which may not + # be available, e.g. Windows builds with -DLLAMA_CURL=OFF) + try: + from huggingface_hub import hf_hub_download + except ImportError: + raise RuntimeError( + "huggingface_hub is required for HF model loading. " + "Install it with: pip install huggingface_hub" + ) + + # Determine the filename from the variant (e.g., "Q4_K_M" -> find matching file) + # For split GGUFs (e.g., *-00001-of-00003.gguf) we must download ALL shards. + gguf_filename = None + gguf_extra_shards: list[str] = [] + if hf_variant: + # Try common naming patterns + try: + import re + from huggingface_hub import list_repo_files + + files = list_repo_files(hf_repo, token = hf_token) + variant_lower = hf_variant.lower() + # Use word-boundary matching so "Q8_0" doesn't also + # match "IQ8_0" or other superset variant names. + boundary = re.compile( + r"(? try model name + repo_name = hf_repo.split("/")[-1].replace("-GGUF", "") + gguf_filename = f"{repo_name}-{hf_variant}.gguf" + + logger.info( + f"Downloading GGUF: {hf_repo}/{gguf_filename}" + + ( + f" (+{len(gguf_extra_shards)} shards)" + if gguf_extra_shards + else "" + ) + ) + try: + local_path = hf_hub_download( + repo_id = hf_repo, + filename = gguf_filename, + token = hf_token, + ) + # Download remaining shards for split GGUFs — llama-server + # auto-discovers them when they are in the same directory. + for shard in gguf_extra_shards: + logger.info(f"Downloading GGUF shard: {shard}") + hf_hub_download( + repo_id = hf_repo, + filename = shard, + token = hf_token, + ) + except Exception as e: + raise RuntimeError( + f"Failed to download GGUF file '{gguf_filename}' from {hf_repo}: {e}" + ) + + logger.info(f"GGUF downloaded to: {local_path}") + cmd = [ + binary, + "-m", + local_path, + "--port", + str(self._port), + "-c", + str(n_ctx), + "-ngl", + str(n_gpu_layers), + ] + elif gguf_path: + if not Path(gguf_path).is_file(): + raise FileNotFoundError(f"GGUF file not found: {gguf_path}") + cmd = [ + binary, + "-m", + gguf_path, + "--port", + str(self._port), + "-c", + str(n_ctx), + "-ngl", + str(n_gpu_layers), + ] + else: + raise ValueError("Either gguf_path or hf_repo must be provided") + + if n_threads is not None: + cmd.extend(["--threads", str(n_threads)]) + + # Append mmproj for local vision models + if mmproj_path: + if not Path(mmproj_path).is_file(): + logger.warning(f"mmproj file not found: {mmproj_path}") + else: + cmd.extend(["--mmproj", mmproj_path]) + logger.info(f"Using mmproj for vision: {mmproj_path}") + + logger.info(f"Starting llama-server: {' '.join(cmd)}") + + # Set library paths so llama-server can find its shared libs and CUDA DLLs + import os + import sys + + env = os.environ.copy() + binary_dir = str(Path(binary).parent) + + if sys.platform == "win32": + # On Windows, CUDA DLLs (cublas64_12.dll, cudart64_12.dll, etc.) + # must be on PATH. Add CUDA_PATH\bin if available. + path_dirs = [binary_dir] + cuda_path = os.environ.get("CUDA_PATH", "") + if cuda_path: + cuda_bin = os.path.join(cuda_path, "bin") + if os.path.isdir(cuda_bin): + path_dirs.append(cuda_bin) + # Some CUDA installs put DLLs in bin\x64 + cuda_bin_x64 = os.path.join(cuda_path, "bin", "x64") + if os.path.isdir(cuda_bin_x64): + path_dirs.append(cuda_bin_x64) + existing_path = env.get("PATH", "") + env["PATH"] = ";".join(path_dirs) + ";" + existing_path + else: + # Linux: set LD_LIBRARY_PATH for shared libs next to the binary + existing_ld = env.get("LD_LIBRARY_PATH", "") + env["LD_LIBRARY_PATH"] = ( + f"{binary_dir}:{existing_ld}" if existing_ld else binary_dir + ) + + self._stdout_lines = [] + self._process = subprocess.Popen( + cmd, + stdout = subprocess.PIPE, + stderr = subprocess.STDOUT, + text = True, + env = env, + ) + + # Start background thread to drain stdout and prevent pipe deadlock + self._stdout_thread = threading.Thread( + target = self._drain_stdout, daemon = True, name = "llama-stdout" + ) + self._stdout_thread.start() + + self._gguf_path = gguf_path + self._hf_repo = hf_repo + self._hf_variant = hf_variant + self._is_vision = is_vision + self._model_identifier = model_identifier + + # Wait for llama-server to become healthy + if not self._wait_for_health(timeout = 120.0): + self._kill_process() + raise RuntimeError( + "llama-server failed to start. " + "Check that the GGUF file is valid and you have enough memory." + ) + + self._healthy = True + + logger.info( + f"llama-server ready on port {self._port} " + f"for model '{model_identifier}'" + ) + return True + + def unload_model(self) -> bool: + """Terminate the llama-server subprocess and clean up state.""" + with self._lock: + self._kill_process() + logger.info(f"Unloaded GGUF model: {self._model_identifier}") + self._model_identifier = None + self._gguf_path = None + self._hf_repo = None + self._hf_variant = None + self._is_vision = False + self._port = None + self._healthy = False + return True + + def _kill_process(self): + """Terminate the subprocess if running.""" + if self._process is None: + return + try: + self._process.terminate() + self._process.wait(timeout = 5) + except subprocess.TimeoutExpired: + logger.warning("llama-server did not exit on SIGTERM, sending SIGKILL") + self._process.kill() + self._process.wait(timeout = 5) + except Exception as e: + logger.warning(f"Error killing llama-server process: {e}") + finally: + self._process = None + if self._stdout_thread is not None: + self._stdout_thread.join(timeout = 2) + self._stdout_thread = None + + def _cleanup(self): + """atexit handler to ensure llama-server is terminated.""" + self._kill_process() + + def _wait_for_health(self, timeout: float = 120.0, interval: float = 0.5) -> bool: + """ + Poll llama-server's /health endpoint until it responds 200. + + Also monitors subprocess for early exit/crash. + """ + deadline = time.monotonic() + timeout + url = f"http://127.0.0.1:{self._port}/health" + + while time.monotonic() < deadline: + # Check if process crashed + if self._process.poll() is not None: + # Give the drain thread a moment to collect final output + if self._stdout_thread is not None: + self._stdout_thread.join(timeout = 2) + output = "\n".join(self._stdout_lines[-50:]) + logger.error( + f"llama-server exited with code {self._process.returncode}. " + f"Output: {output[:2000]}" + ) + return False + + try: + resp = httpx.get(url, timeout = 2.0) + if resp.status_code == 200: + return True + except (httpx.ConnectError, httpx.TimeoutException): + pass + + time.sleep(interval) + + logger.error(f"llama-server health check timed out after {timeout}s") + return False + + # ── Message building (OpenAI format) ────────────────────────── + + @staticmethod + def _build_openai_messages( + messages: list[dict], + image_b64: Optional[str] = None, + ) -> list[dict]: + """ + Build OpenAI-format messages, optionally injecting an image_url + content part into the last user message for vision models. + + If no image is provided, returns messages as-is. + """ + if not image_b64: + return messages + + # Find the last user message and convert to multimodal content parts + result = [msg.copy() for msg in messages] + last_user_idx = None + for i, msg in enumerate(result): + if msg["role"] == "user": + last_user_idx = i + + if last_user_idx is not None: + text_content = result[last_user_idx].get("content", "") + result[last_user_idx]["content"] = [ + {"type": "text", "text": text_content}, + { + "type": "image_url", + "image_url": { + "url": f"data:image/png;base64,{image_b64}", + }, + }, + ] + + return result + + # ── Generation (proxy to llama-server) ──────────────────────── + + def generate_chat_completion( + self, + messages: list[dict], + image_b64: Optional[str] = None, + temperature: float = 0.7, + top_p: float = 0.9, + top_k: int = 40, + min_p: float = 0.0, + max_tokens: int = 512, + repetition_penalty: float = 1.1, + stop: Optional[list[str]] = None, + cancel_event: Optional[threading.Event] = None, + ) -> Generator[str, None, None]: + """ + Send a chat completion request to llama-server and stream tokens back. + + Uses /v1/chat/completions — llama-server handles chat template + application and vision (multimodal image_url parts) natively. + + Yields cumulative text (matching InferenceBackend's convention). + """ + if not self.is_loaded: + raise RuntimeError("llama-server is not loaded") + + openai_messages = self._build_openai_messages(messages, image_b64) + + payload = { + "messages": openai_messages, + "stream": True, + "temperature": temperature, + "top_p": top_p, + "top_k": top_k if top_k >= 0 else 0, + "min_p": min_p, + "max_tokens": max_tokens, + "repeat_penalty": repetition_penalty, + } + if stop: + payload["stop"] = stop + + url = f"{self.base_url}/v1/chat/completions" + cumulative = "" + + try: + with httpx.Client(timeout = None) as client: + with client.stream("POST", url, json = payload) as response: + if response.status_code != 200: + error_body = response.read().decode() + raise RuntimeError( + f"llama-server returned {response.status_code}: {error_body}" + ) + + buffer = "" + for raw_chunk in response.iter_text(): + if cancel_event is not None and cancel_event.is_set(): + break + + buffer += raw_chunk + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) + line = line.strip() + + if not line: + continue + if line == "data: [DONE]": + return + if not line.startswith("data: "): + continue + + try: + data = json.loads(line[6:]) + choices = data.get("choices", []) + if choices: + delta = choices[0].get("delta", {}) + token = delta.get("content", "") + if token: + cumulative += token + yield cumulative + except json.JSONDecodeError: + logger.debug( + f"Skipping malformed SSE line: {line[:100]}" + ) + + except httpx.ConnectError: + raise RuntimeError("Lost connection to llama-server") + except Exception as e: + if cancel_event is not None and cancel_event.is_set(): + return + raise diff --git a/studio/backend/core/inference/orchestrator.py b/studio/backend/core/inference/orchestrator.py new file mode 100644 index 0000000000..25f4b2875e --- /dev/null +++ b/studio/backend/core/inference/orchestrator.py @@ -0,0 +1,821 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Inference orchestrator — subprocess-based. + +Provides the same API as InferenceBackend, but delegates all ML work +to a persistent subprocess. The subprocess is spawned on first model load +and stays alive for subsequent requests. + +When switching between models that need different transformers versions +(e.g. GLM-4.7-Flash needs 5.x, Qwen needs 4.57.x), the old subprocess +is killed and a new one is spawned with the correct version. + +Pattern follows core/training/training.py. +""" + +import atexit +import base64 +import structlog +from loggers import get_logger +import multiprocessing as mp +import queue +import threading +import time +import uuid +from io import BytesIO +from pathlib import Path +from typing import Any, Generator, Optional, Tuple, Union + +logger = get_logger(__name__) + +_CTX = mp.get_context("spawn") + + +class InferenceOrchestrator: + """ + Inference backend orchestrator — subprocess-based. + + Exposes the same API surface as InferenceBackend so routes/inference.py + needs minimal changes. Internally, all heavy ML operations happen in + a persistent subprocess. + """ + + def __init__(self): + # Subprocess state + self._proc: Optional[mp.Process] = None + self._cmd_queue: Any = None + self._resp_queue: Any = None + self._cancel_event: Any = None # mp.Event — set to cancel generation instantly + self._lock = threading.Lock() + self._gen_lock = ( + threading.Lock() + ) # Serializes generation — one request at a time + + # Local state mirrors (updated from subprocess responses) + self.active_model_name: Optional[str] = None + self.models: dict = {} + self.loading_models: set = set() + self.loaded_local_models: list = [] + self.default_models = [ + "unsloth/Qwen3-4B-Instruct-2507", + "unsloth/Meta-Llama-3.1-8B-Instruct-bnb-4bit", + "unsloth/Mistral-Nemo-Instruct-2407-bnb-4bit", + "unsloth/Phi-3.5-mini-instruct", + "unsloth/Gemma-3-4B-it", + "unsloth/Qwen2-VL-2B-Instruct-bnb-4bit", + ] + + # Version tracking for subprocess reuse + self._current_transformers_major: Optional[str] = None # "4" or "5" + + atexit.register(self._cleanup) + logger.info("InferenceOrchestrator initialized (subprocess mode)") + + # ------------------------------------------------------------------ + # Subprocess lifecycle + # ------------------------------------------------------------------ + + def _spawn_subprocess(self, config: dict) -> None: + """Spawn a new inference subprocess.""" + from .worker import run_inference_process + + self._cmd_queue = _CTX.Queue() + self._resp_queue = _CTX.Queue() + self._cancel_event = _CTX.Event() + + self._proc = _CTX.Process( + target = run_inference_process, + kwargs = { + "cmd_queue": self._cmd_queue, + "resp_queue": self._resp_queue, + "cancel_event": self._cancel_event, + "config": config, + }, + daemon = True, + ) + self._proc.start() + logger.info("Inference subprocess started (pid=%s)", self._proc.pid) + + def _cancel_generation(self) -> None: + """Cancel any ongoing generation in the subprocess (instant).""" + if self._cancel_event is not None: + self._cancel_event.set() + + def _shutdown_subprocess(self, timeout: float = 10.0) -> None: + """Gracefully shut down the inference subprocess.""" + if self._proc is None or not self._proc.is_alive(): + self._proc = None + return + + # 1. Cancel any ongoing generation first (instant via mp.Event) + self._cancel_generation() + time.sleep(0.5) # Brief wait for generation to stop + + # 2. Drain stale responses from queue + self._drain_queue() + + # 3. Send shutdown command + try: + self._cmd_queue.put({"type": "shutdown"}) + except (OSError, ValueError): + pass + + # 4. Wait for graceful shutdown + try: + self._proc.join(timeout = timeout) + except Exception: + pass + + # 5. Force kill if still alive + if self._proc is not None and self._proc.is_alive(): + logger.warning("Inference subprocess did not exit gracefully, terminating") + try: + self._proc.terminate() + self._proc.join(timeout = 5) + except Exception: + pass + if self._proc is not None and self._proc.is_alive(): + logger.warning("Subprocess still alive after terminate, killing") + try: + self._proc.kill() + self._proc.join(timeout = 3) + except Exception: + pass + + self._proc = None + self._cmd_queue = None + self._resp_queue = None + self._cancel_event = None + logger.info("Inference subprocess shut down") + + def _cleanup(self): + """atexit handler.""" + self._shutdown_subprocess(timeout = 5.0) + + def _ensure_subprocess_alive(self) -> bool: + """Check if subprocess is alive.""" + return self._proc is not None and self._proc.is_alive() + + # ------------------------------------------------------------------ + # Queue helpers + # ------------------------------------------------------------------ + + def _send_cmd(self, cmd: dict) -> None: + """Send a command to the subprocess.""" + if self._cmd_queue is None: + raise RuntimeError("No inference subprocess running") + try: + self._cmd_queue.put(cmd) + except (OSError, ValueError) as exc: + raise RuntimeError(f"Failed to send command to subprocess: {exc}") + + def _read_resp(self, timeout: float = 1.0) -> Optional[dict]: + """Read a response from the subprocess (non-blocking with timeout).""" + if self._resp_queue is None: + return None + try: + return self._resp_queue.get(timeout = timeout) + except queue.Empty: + return None + except (EOFError, OSError, ValueError): + return None + + def _wait_response(self, expected_type: str, timeout: float = 120.0) -> dict: + """Block until a response of the expected type arrives. + + Also handles 'status' and 'error' events during the wait. + Returns the matching response dict. + Raises RuntimeError on timeout or subprocess crash. + """ + deadline = time.monotonic() + timeout + + while time.monotonic() < deadline: + remaining = max(0.1, deadline - time.monotonic()) + resp = self._read_resp(timeout = min(remaining, 1.0)) + + if resp is None: + # Check subprocess health + if not self._ensure_subprocess_alive(): + raise RuntimeError("Inference subprocess crashed during wait") + continue + + rtype = resp.get("type", "") + + if rtype == expected_type: + return resp + + if rtype == "error": + error_msg = resp.get("error", "Unknown error") + raise RuntimeError(f"Subprocess error: {error_msg}") + + if rtype == "status": + logger.info("Subprocess status: %s", resp.get("message", "")) + continue + + # Other response types during wait — skip + logger.debug( + "Skipping response type '%s' while waiting for '%s'", + rtype, + expected_type, + ) + + raise RuntimeError( + f"Timeout waiting for '{expected_type}' response after {timeout}s" + ) + + def _drain_queue(self) -> list: + """Drain all pending responses.""" + events = [] + if self._resp_queue is None: + return events + while True: + try: + events.append(self._resp_queue.get_nowait()) + except queue.Empty: + return events + except (EOFError, OSError, ValueError): + return events + + def _drain_until_gen_done(self, timeout: float = 5.0) -> None: + """Consume resp_queue events until gen_done/gen_error, discarding them. + + Called after cancel to ensure stale tokens from the cancelled + generation don't leak into the next request. + """ + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + resp = self._read_resp(timeout = min(0.5, deadline - time.monotonic())) + if resp is None: + if not self._ensure_subprocess_alive(): + return + continue + rtype = resp.get("type", "") + if rtype in ("gen_done", "gen_error"): + return + logger.warning("Timed out waiting for gen_done after cancel") + + # ------------------------------------------------------------------ + # Public API — same interface as InferenceBackend + # ------------------------------------------------------------------ + + def load_model( + self, + config, # ModelConfig + max_seq_length: int = 2048, + dtype = None, + load_in_4bit: bool = True, + hf_token: Optional[str] = None, + trust_remote_code: bool = False, + ) -> bool: + """Load a model for inference. + + Always spawns a fresh subprocess for each model load. This ensures + a clean Python interpreter — no stale unsloth patches, torch.compile + caches, or inspect.getsource() failures from a previous model. + """ + from utils.transformers_version import needs_transformers_5 + + model_name = config.identifier + self.loading_models.add(model_name) + + try: + needed_major = "5" if needs_transformers_5(model_name) else "4" + + # Build config dict for subprocess + sub_config = { + "model_name": model_name, + "max_seq_length": max_seq_length, + "load_in_4bit": load_in_4bit, + "hf_token": hf_token or "", + "gguf_variant": getattr(config, "gguf_variant", None), + "trust_remote_code": trust_remote_code, + } + + # Always kill existing subprocess and spawn fresh. + # Reusing a subprocess after unsloth patches torch internals + # causes inspect.getsource() failures on the next model load. + if self._ensure_subprocess_alive(): + self._cancel_generation() + time.sleep(0.3) + self._shutdown_subprocess() + + elif self._proc is not None: + # Dead subprocess — clean up + self._shutdown_subprocess(timeout = 2) + + logger.info( + "Spawning fresh inference subprocess for '%s' (transformers %s.x)", + model_name, + needed_major, + ) + self._spawn_subprocess(sub_config) + resp = self._wait_response("loaded", timeout = 180) + + # Update local state from response + if resp.get("success"): + self._current_transformers_major = needed_major + model_info = resp.get("model_info", {}) + self.active_model_name = model_info.get("identifier", model_name) + self.models[self.active_model_name] = { + "is_vision": model_info.get("is_vision", False), + "is_lora": model_info.get("is_lora", False), + "display_name": model_info.get("display_name", model_name), + "is_audio": model_info.get("is_audio", False), + "audio_type": model_info.get("audio_type"), + "has_audio_input": model_info.get("has_audio_input", False), + } + self.loading_models.discard(model_name) + logger.info("Model '%s' loaded successfully in subprocess", model_name) + return True + else: + error = resp.get("error", "Failed to load model") + self.loading_models.discard(model_name) + self.active_model_name = None + self.models.clear() + raise Exception(error) + + except Exception: + self.loading_models.discard(model_name) + self.active_model_name = None + self.models.clear() + raise + + def unload_model(self, model_name: str) -> bool: + """Unload a model from the subprocess.""" + if not self._ensure_subprocess_alive(): + # No subprocess — just clear local state + self.models.pop(model_name, None) + if self.active_model_name == model_name: + self.active_model_name = None + return True + + try: + self._send_cmd( + { + "type": "unload", + "model_name": model_name, + } + ) + resp = self._wait_response("unloaded", timeout = 30) + + # Update local state + self.models.pop(model_name, None) + if self.active_model_name == model_name: + self.active_model_name = None + + logger.info("Model '%s' unloaded from subprocess", model_name) + return True + + except Exception as exc: + logger.error("Error unloading model '%s': %s", model_name, exc) + # Clear local state anyway + self.models.pop(model_name, None) + if self.active_model_name == model_name: + self.active_model_name = None + return False + + def generate_chat_response( + self, + messages: list, + system_prompt: str = "", + image = None, + temperature: float = 0.7, + top_p: float = 0.9, + top_k: int = 40, + min_p: float = 0.0, + max_new_tokens: int = 256, + repetition_penalty: float = 1.1, + cancel_event = None, + ) -> Generator[str, None, None]: + """Generate response, streaming tokens from subprocess.""" + yield from self._generate_inner( + messages = messages, + system_prompt = system_prompt, + image = image, + temperature = temperature, + top_p = top_p, + top_k = top_k, + min_p = min_p, + max_new_tokens = max_new_tokens, + repetition_penalty = repetition_penalty, + cancel_event = cancel_event, + use_adapter = None, + ) + + def generate_with_adapter_control( + self, + use_adapter: Optional[Union[bool, str]] = None, + cancel_event = None, + **gen_kwargs, + ) -> Generator[str, None, None]: + """Generate with adapter control, streaming tokens from subprocess.""" + yield from self._generate_inner( + use_adapter = use_adapter, + cancel_event = cancel_event, + **gen_kwargs, + ) + + def _generate_inner( + self, + messages: list = None, + system_prompt: str = "", + image = None, + temperature: float = 0.7, + top_p: float = 0.9, + top_k: int = 40, + min_p: float = 0.0, + max_new_tokens: int = 256, + repetition_penalty: float = 1.1, + cancel_event = None, + use_adapter = None, + ) -> Generator[str, None, None]: + """Inner generation logic — sends command to subprocess, yields tokens. + + Serialized by _gen_lock: only one generation runs at a time. + This prevents concurrent readers from consuming each other's + tokens off the shared resp_queue. + """ + if not self._ensure_subprocess_alive(): + yield "Error: Inference subprocess is not running" + return + + if not self.active_model_name: + yield "Error: No active model" + return + + # Serialize generation — single GPU, one generation at a time. + # Without this lock, two concurrent readers on the same resp_queue + # can consume and drop each other's token events. + with self._gen_lock: + yield from self._generate_locked( + messages = messages, + system_prompt = system_prompt, + image = image, + temperature = temperature, + top_p = top_p, + top_k = top_k, + min_p = min_p, + max_new_tokens = max_new_tokens, + repetition_penalty = repetition_penalty, + cancel_event = cancel_event, + use_adapter = use_adapter, + ) + + def _generate_locked( + self, + messages: list = None, + system_prompt: str = "", + image = None, + temperature: float = 0.7, + top_p: float = 0.9, + top_k: int = 40, + min_p: float = 0.0, + max_new_tokens: int = 256, + repetition_penalty: float = 1.1, + cancel_event = None, + use_adapter = None, + ) -> Generator[str, None, None]: + """Actual generation logic — must be called under _gen_lock.""" + request_id = str(uuid.uuid4()) + + # Convert PIL Image to base64 if needed + image_b64 = None + if image is not None: + image_b64 = self._pil_to_base64(image) + + cmd = { + "type": "generate", + "request_id": request_id, + "messages": messages or [], + "system_prompt": system_prompt, + "image_base64": image_b64, + "temperature": temperature, + "top_p": top_p, + "top_k": top_k, + "min_p": min_p, + "max_new_tokens": max_new_tokens, + "repetition_penalty": repetition_penalty, + } + + if use_adapter is not None: + cmd["use_adapter"] = use_adapter + + try: + self._send_cmd(cmd) + except RuntimeError as exc: + yield f"Error: {exc}" + return + + # Yield tokens from response queue — we are the only reader + # because _gen_lock is held. + while True: + resp = self._read_resp(timeout = 30.0) + + if resp is None: + # Check subprocess health + if not self._ensure_subprocess_alive(): + yield "Error: Inference subprocess crashed during generation" + return + continue + + rtype = resp.get("type", "") + + # Status messages — skip + if rtype == "status": + continue + + # Error without request_id = subprocess-level error + resp_rid = resp.get("request_id") + if rtype == "error" and not resp_rid: + yield f"Error: {resp.get('error', 'Unknown error')}" + return + + if rtype == "token": + # Check cancel from route (e.g. SSE connection closed) + if cancel_event is not None and cancel_event.is_set(): + self._cancel_generation() + # Wait for the subprocess to acknowledge cancellation + # (gen_done/gen_error) so stale events don't leak into + # the next generation request. + self._drain_until_gen_done(timeout = 5.0) + return + yield resp.get("text", "") + + elif rtype == "gen_done": + return + + elif rtype == "gen_error": + yield f"Error: {resp.get('error', 'Unknown error')}" + return + + def reset_generation_state(self): + """Cancel any ongoing generation and reset state.""" + self._cancel_generation() + if not self._ensure_subprocess_alive(): + return + try: + self._send_cmd({"type": "reset"}) + except RuntimeError: + pass + + # ------------------------------------------------------------------ + # Audio generation — TTS, ASR, audio input + # ------------------------------------------------------------------ + + def generate_audio_response( + self, + text: str, + temperature: float = 0.6, + top_p: float = 0.95, + top_k: int = 50, + min_p: float = 0.0, + max_new_tokens: int = 2048, + repetition_penalty: float = 1.1, + use_adapter: Optional[Union[bool, str]] = None, + ) -> Tuple[bytes, int]: + """Generate TTS audio. Returns (wav_bytes, sample_rate). + + Blocking — sends command and waits for the complete audio response. + """ + if not self._ensure_subprocess_alive(): + raise RuntimeError("Inference subprocess is not running") + if not self.active_model_name: + raise RuntimeError("No active model") + + import uuid + + request_id = str(uuid.uuid4()) + + cmd = { + "type": "generate_audio", + "request_id": request_id, + "text": text, + "temperature": temperature, + "top_p": top_p, + "top_k": top_k, + "min_p": min_p, + "max_new_tokens": max_new_tokens, + "repetition_penalty": repetition_penalty, + } + if use_adapter is not None: + cmd["use_adapter"] = use_adapter + + self._send_cmd(cmd) + + # Wait for audio_done or audio_error + deadline = time.monotonic() + 120.0 + while time.monotonic() < deadline: + remaining = max(0.1, deadline - time.monotonic()) + resp = self._read_resp(timeout = min(remaining, 1.0)) + + if resp is None: + if not self._ensure_subprocess_alive(): + raise RuntimeError( + "Inference subprocess crashed during audio generation" + ) + continue + + rtype = resp.get("type", "") + + if rtype == "audio_done": + wav_bytes = base64.b64decode(resp["wav_base64"]) + sample_rate = resp["sample_rate"] + return wav_bytes, sample_rate + + if rtype == "audio_error": + raise RuntimeError(resp.get("error", "Audio generation failed")) + + if rtype == "error": + raise RuntimeError(resp.get("error", "Unknown error")) + + if rtype == "status": + continue + + raise RuntimeError("Timeout waiting for audio generation (120s)") + + def generate_whisper_response( + self, + audio_array, + cancel_event = None, + ) -> Generator[str, None, None]: + """Whisper ASR — sends audio to subprocess, yields text.""" + yield from self._generate_audio_input_inner( + audio_array = audio_array, + audio_type = "whisper", + messages = [], + system_prompt = "", + cancel_event = cancel_event, + ) + + def generate_audio_input_response( + self, + messages, + system_prompt, + audio_array, + temperature: float = 0.7, + top_p: float = 0.9, + top_k: int = 40, + min_p: float = 0.0, + max_new_tokens: int = 512, + repetition_penalty: float = 1.1, + cancel_event = None, + ) -> Generator[str, None, None]: + """Audio input generation (e.g. Gemma 3n) — streams text tokens.""" + yield from self._generate_audio_input_inner( + audio_array = audio_array, + audio_type = None, # worker will use generate_audio_input_response + messages = messages, + system_prompt = system_prompt, + temperature = temperature, + top_p = top_p, + top_k = top_k, + min_p = min_p, + max_new_tokens = max_new_tokens, + repetition_penalty = repetition_penalty, + cancel_event = cancel_event, + ) + + def _generate_audio_input_inner( + self, + audio_array, + audio_type: Optional[str] = None, + messages: list = None, + system_prompt: str = "", + temperature: float = 0.7, + top_p: float = 0.9, + top_k: int = 40, + min_p: float = 0.0, + max_new_tokens: int = 512, + repetition_penalty: float = 1.1, + cancel_event = None, + ) -> Generator[str, None, None]: + """Shared inner logic for audio input generation (Whisper + ASR).""" + if not self._ensure_subprocess_alive(): + yield "Error: Inference subprocess is not running" + return + if not self.active_model_name: + yield "Error: No active model" + return + + with self._gen_lock: + import uuid + + request_id = str(uuid.uuid4()) + + # Convert numpy array to list for mp.Queue serialization + audio_data = ( + audio_array.tolist() + if hasattr(audio_array, "tolist") + else list(audio_array) + ) + + cmd = { + "type": "generate_audio_input", + "request_id": request_id, + "audio_data": audio_data, + "audio_type": audio_type, + "messages": messages or [], + "system_prompt": system_prompt, + "temperature": temperature, + "top_p": top_p, + "top_k": top_k, + "min_p": min_p, + "max_new_tokens": max_new_tokens, + "repetition_penalty": repetition_penalty, + } + + try: + self._send_cmd(cmd) + except RuntimeError as exc: + yield f"Error: {exc}" + return + + # Yield tokens — same pattern as _generate_locked + while True: + resp = self._read_resp(timeout = 30.0) + + if resp is None: + if not self._ensure_subprocess_alive(): + yield "Error: Inference subprocess crashed during audio input generation" + return + continue + + rtype = resp.get("type", "") + + if rtype == "status": + continue + + if rtype == "error" and not resp.get("request_id"): + yield f"Error: {resp.get('error', 'Unknown error')}" + return + + if rtype == "token": + if cancel_event is not None and cancel_event.is_set(): + self._cancel_generation() + self._drain_until_gen_done(timeout = 5.0) + return + yield resp.get("text", "") + + elif rtype == "gen_done": + return + + elif rtype == "gen_error": + yield f"Error: {resp.get('error', 'Unknown error')}" + return + + # ------------------------------------------------------------------ + # Local helpers (no subprocess needed) + # ------------------------------------------------------------------ + + def resize_image(self, img, max_size: int = 800): + """Resize image while maintaining aspect ratio. + No ML imports needed — runs locally in parent process. + """ + if img is None: + return None + if img.size[0] > max_size or img.size[1] > max_size: + from PIL import Image + + ratio = min(max_size / img.size[0], max_size / img.size[1]) + new_size = (int(img.size[0] * ratio), int(img.size[1] * ratio)) + return img.resize(new_size, Image.Resampling.LANCZOS) + return img + + @staticmethod + def _pil_to_base64(img) -> str: + """Convert a PIL Image to base64 string for IPC.""" + buf = BytesIO() + img.save(buf, format = "PNG") + return base64.b64encode(buf.getvalue()).decode("ascii") + + def get_current_model(self) -> Optional[str]: + """Get currently active model name.""" + return self.active_model_name + + def is_model_loading(self) -> bool: + """Check if any model is currently loading.""" + return len(self.loading_models) > 0 + + def get_loading_model(self) -> Optional[str]: + """Get name of currently loading model.""" + return next(iter(self.loading_models)) if self.loading_models else None + + def check_vision_model_compatibility(self) -> bool: + """Check if current model supports vision.""" + if self.active_model_name and self.active_model_name in self.models: + return self.models[self.active_model_name].get("is_vision", False) + return False + + +# ========== GLOBAL INSTANCE ========== +_inference_backend = None + + +def get_inference_backend() -> InferenceOrchestrator: + """Get global inference backend instance (orchestrator).""" + global _inference_backend + if _inference_backend is None: + _inference_backend = InferenceOrchestrator() + return _inference_backend diff --git a/studio/backend/core/inference/worker.py b/studio/backend/core/inference/worker.py new file mode 100644 index 0000000000..b266f5e0d4 --- /dev/null +++ b/studio/backend/core/inference/worker.py @@ -0,0 +1,730 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Inference subprocess entry point. + +Each inference session runs in a persistent subprocess (mp.get_context("spawn")). +This gives us a clean Python interpreter with no stale module state — +solving the transformers version-switching problem completely. + +The subprocess stays alive while a model is loaded, accepting commands +(generate, load, unload) via mp.Queue. It exits on shutdown or unload. + +Pattern follows core/training/worker.py. +""" + +from __future__ import annotations + +import base64 +import structlog +from loggers import get_logger +import os +import queue as _queue +import sys +import time +import traceback +from io import BytesIO +from pathlib import Path +from typing import Any + +logger = get_logger(__name__) + + +def _activate_transformers_version(model_name: str) -> None: + """Activate the correct transformers version BEFORE any ML imports. + + If the model needs transformers 5.x, prepend the pre-installed .venv_t5/ + directory to sys.path. Otherwise do nothing (default 4.57.x in .venv/). + """ + # Ensure backend is on path for utils imports + backend_path = str(Path(__file__).resolve().parent.parent.parent) + if backend_path not in sys.path: + sys.path.insert(0, backend_path) + + from utils.transformers_version import needs_transformers_5, _resolve_base_model + + resolved = _resolve_base_model(model_name) + if needs_transformers_5(resolved): + venv_t5 = os.path.join( + os.path.expanduser("~"), ".unsloth", "studio", ".venv_t5" + ) + if os.path.isdir(venv_t5): + sys.path.insert(0, venv_t5) + logger.info("Activated transformers 5.x from %s", venv_t5) + else: + # Fallback: pip install at runtime (slower, ~10-15s) + logger.warning(".venv_t5 not found at %s — installing at runtime", venv_t5) + import subprocess as sp + + os.makedirs(venv_t5, exist_ok = True) + r1 = sp.run( + [ + sys.executable, + "-m", + "pip", + "install", + "--target", + venv_t5, + "--no-deps", + "transformers==5.2.0", + ], + stdout = sp.PIPE, + stderr = sp.STDOUT, + ) + r2 = sp.run( + [ + sys.executable, + "-m", + "pip", + "install", + "--target", + venv_t5, + "--no-deps", + "huggingface_hub==1.3.0", + ], + stdout = sp.PIPE, + stderr = sp.STDOUT, + ) + if r1.returncode != 0 or r2.returncode != 0: + raise RuntimeError( + f"Failed to install transformers 5.x into {venv_t5}. " + f"pip returncode: transformers={r1.returncode}, huggingface_hub={r2.returncode}" + ) + sys.path.insert(0, venv_t5) + # Propagate to child subprocesses (e.g. GGUF converter) + _pp = os.environ.get("PYTHONPATH", "") + os.environ["PYTHONPATH"] = venv_t5 + (os.pathsep + _pp if _pp else "") + else: + logger.info("Using default transformers (4.57.x) for %s", model_name) + + +def _decode_image(image_base64: str): + """Decode base64 string to PIL.Image.""" + from PIL import Image + + image_data = base64.b64decode(image_base64) + return Image.open(BytesIO(image_data)) + + +def _resize_image(img, max_size: int = 800): + """Resize image while maintaining aspect ratio.""" + if img is None: + return None + if img.size[0] > max_size or img.size[1] > max_size: + from PIL import Image + + ratio = min(max_size / img.size[0], max_size / img.size[1]) + new_size = (int(img.size[0] * ratio), int(img.size[1] * ratio)) + return img.resize(new_size, Image.Resampling.LANCZOS) + return img + + +def _send_response(resp_queue: Any, response: dict) -> None: + """Send a response to the parent process.""" + try: + resp_queue.put(response) + except (OSError, ValueError) as exc: + logger.error("Failed to send response: %s", exc) + + +def _build_model_config(config: dict): + """Build a ModelConfig from the config dict.""" + from utils.models import ModelConfig + + model_name = config["model_name"] + hf_token = config.get("hf_token") + hf_token = hf_token if hf_token and hf_token.strip() else None + gguf_variant = config.get("gguf_variant") + + mc = ModelConfig.from_identifier( + model_id = model_name, + hf_token = hf_token, + gguf_variant = gguf_variant, + ) + if not mc: + raise ValueError(f"Invalid model identifier: {model_name}") + return mc + + +def _handle_load(backend, config: dict, resp_queue: Any) -> None: + """Handle a load command: load a model into the backend.""" + try: + mc = _build_model_config(config) + + hf_token = config.get("hf_token") + hf_token = hf_token if hf_token and hf_token.strip() else None + + # Auto-detect quantization for LoRA adapters + load_in_4bit = config.get("load_in_4bit", True) + if mc.is_lora and mc.path: + import json + from pathlib import Path + + adapter_cfg_path = Path(mc.path) / "adapter_config.json" + if adapter_cfg_path.exists(): + try: + with open(adapter_cfg_path) as f: + adapter_cfg = json.load(f) + training_method = adapter_cfg.get("unsloth_training_method") + if training_method == "lora" and load_in_4bit: + logger.info( + "adapter_config.json says lora — setting load_in_4bit=False" + ) + load_in_4bit = False + elif training_method == "qlora" and not load_in_4bit: + logger.info( + "adapter_config.json says qlora — setting load_in_4bit=True" + ) + load_in_4bit = True + elif not training_method: + if ( + mc.base_model + and "-bnb-4bit" not in mc.base_model.lower() + and load_in_4bit + ): + logger.info( + "No training method, base model has no -bnb-4bit — setting load_in_4bit=False" + ) + load_in_4bit = False + except Exception as e: + logger.warning("Could not read adapter_config.json: %s", e) + + success = backend.load_model( + config = mc, + max_seq_length = config.get("max_seq_length", 2048), + load_in_4bit = load_in_4bit, + hf_token = hf_token, + trust_remote_code = config.get("trust_remote_code", False), + ) + + if success: + # Build model_info for the parent to mirror + model_info = { + "identifier": mc.identifier, + "display_name": mc.display_name, + "is_vision": mc.is_vision, + "is_lora": mc.is_lora, + "is_gguf": False, + "is_audio": getattr(mc, "is_audio", False), + "audio_type": getattr(mc, "audio_type", None), + "has_audio_input": getattr(mc, "has_audio_input", False), + } + _send_response( + resp_queue, + { + "type": "loaded", + "success": True, + "model_info": model_info, + "ts": time.time(), + }, + ) + else: + _send_response( + resp_queue, + { + "type": "loaded", + "success": False, + "error": "Failed to load model", + "ts": time.time(), + }, + ) + + except Exception as exc: + _send_response( + resp_queue, + { + "type": "loaded", + "success": False, + "error": str(exc), + "stack": traceback.format_exc(limit = 20), + "ts": time.time(), + }, + ) + + +def _handle_generate( + backend, + cmd: dict, + resp_queue: Any, + cancel_event, +) -> None: + """Handle a generate command: stream tokens back via resp_queue. + + cancel_event is an mp.Event shared with the parent process. + The parent can set it at any time (e.g. user stops generation, + or user loads a new model while generating) and generation + stops within 1-2 tokens. + """ + request_id = cmd.get("request_id", "") + + try: + # Decode image if provided + image = None + image_b64 = cmd.get("image_base64") + if image_b64: + image = _decode_image(image_b64) + image = _resize_image(image) + + # Build generation kwargs + gen_kwargs = { + "messages": cmd["messages"], + "system_prompt": cmd.get("system_prompt", ""), + "image": image, + "temperature": cmd.get("temperature", 0.7), + "top_p": cmd.get("top_p", 0.9), + "top_k": cmd.get("top_k", 40), + "min_p": cmd.get("min_p", 0.0), + "max_new_tokens": cmd.get("max_new_tokens", 256), + "repetition_penalty": cmd.get("repetition_penalty", 1.1), + "cancel_event": cancel_event, + } + + # Choose generation path + use_adapter = cmd.get("use_adapter") + if use_adapter is not None: + generator = backend.generate_with_adapter_control( + use_adapter = use_adapter, + **gen_kwargs, + ) + else: + generator = backend.generate_chat_response(**gen_kwargs) + + logger.info("Starting text generation for request_id=%s", request_id) + + for cumulative_text in generator: + # cancel_event is an mp.Event — checked instantly, no queue polling + if cancel_event.is_set(): + logger.info("Generation cancelled for request %s", request_id) + break + + _send_response( + resp_queue, + { + "type": "token", + "request_id": request_id, + "text": cumulative_text, + "ts": time.time(), + }, + ) + + _send_response( + resp_queue, + { + "type": "gen_done", + "request_id": request_id, + "ts": time.time(), + }, + ) + logger.info("Finished text generation for request_id=%s", request_id) + + except Exception as exc: + logger.error("Generation error: %s", exc, exc_info = True) + _send_response( + resp_queue, + { + "type": "gen_error", + "request_id": request_id, + "error": str(exc), + "stack": traceback.format_exc(limit = 20), + "ts": time.time(), + }, + ) + + +def _handle_generate_audio( + backend, + cmd: dict, + resp_queue: Any, +) -> None: + """Handle TTS audio generation — returns WAV bytes + sample_rate.""" + request_id = cmd.get("request_id", "") + try: + logger.info("Starting audio generation for request_id=%s", request_id) + wav_bytes, sample_rate = backend.generate_audio_response( + text = cmd["text"], + temperature = cmd.get("temperature", 0.6), + top_p = cmd.get("top_p", 0.95), + top_k = cmd.get("top_k", 50), + min_p = cmd.get("min_p", 0.0), + max_new_tokens = cmd.get("max_new_tokens", 2048), + repetition_penalty = cmd.get("repetition_penalty", 1.1), + use_adapter = cmd.get("use_adapter"), + ) + + # Send WAV bytes as base64 (bytes can't go through mp.Queue directly) + _send_response( + resp_queue, + { + "type": "audio_done", + "request_id": request_id, + "wav_base64": base64.b64encode(wav_bytes).decode("ascii"), + "sample_rate": sample_rate, + "ts": time.time(), + }, + ) + logger.info("Finished audio generation for request_id=%s", request_id) + + except Exception as exc: + logger.error("Audio generation error: %s", exc, exc_info = True) + _send_response( + resp_queue, + { + "type": "audio_error", + "request_id": request_id, + "error": str(exc), + "stack": traceback.format_exc(limit = 20), + "ts": time.time(), + }, + ) + + +def _handle_generate_audio_input( + backend, + cmd: dict, + resp_queue: Any, + cancel_event, +) -> None: + """Handle audio input generation (ASR/Whisper) — streams text tokens back.""" + request_id = cmd.get("request_id", "") + + try: + import numpy as np + + # Decode audio array from list (numpy arrays can't go through mp.Queue) + audio_array = np.array(cmd["audio_data"], dtype = np.float32) + + audio_type = cmd.get("audio_type") + + if audio_type == "whisper": + generator = backend.generate_whisper_response( + audio_array = audio_array, + cancel_event = cancel_event, + ) + else: + generator = backend.generate_audio_input_response( + messages = cmd.get("messages", []), + system_prompt = cmd.get("system_prompt", ""), + audio_array = audio_array, + temperature = cmd.get("temperature", 0.7), + top_p = cmd.get("top_p", 0.9), + top_k = cmd.get("top_k", 40), + min_p = cmd.get("min_p", 0.0), + max_new_tokens = cmd.get("max_new_tokens", 512), + repetition_penalty = cmd.get("repetition_penalty", 1.1), + cancel_event = cancel_event, + ) + + logger.info("Starting audio input generation for request_id=%s", request_id) + + for text_chunk in generator: + if cancel_event.is_set(): + logger.info( + "Audio input generation cancelled for request %s", request_id + ) + break + + _send_response( + resp_queue, + { + "type": "token", + "request_id": request_id, + "text": text_chunk, + "ts": time.time(), + }, + ) + + _send_response( + resp_queue, + { + "type": "gen_done", + "request_id": request_id, + "ts": time.time(), + }, + ) + logger.info("Finished audio input generation for request_id=%s", request_id) + + except Exception as exc: + logger.error("Audio input generation error: %s", exc, exc_info = True) + _send_response( + resp_queue, + { + "type": "gen_error", + "request_id": request_id, + "error": str(exc), + "stack": traceback.format_exc(limit = 20), + "ts": time.time(), + }, + ) + + +def _handle_unload(backend, cmd: dict, resp_queue: Any) -> None: + """Handle an unload command.""" + model_name = cmd.get("model_name", "") + try: + if model_name and model_name in backend.models: + backend.unload_model(model_name) + elif backend.active_model_name: + backend.unload_model(backend.active_model_name) + + _send_response( + resp_queue, + { + "type": "unloaded", + "model_name": model_name, + "ts": time.time(), + }, + ) + except Exception as exc: + logger.error("Unload error: %s", exc) + _send_response( + resp_queue, + { + "type": "unloaded", + "model_name": model_name, + "error": str(exc), + "ts": time.time(), + }, + ) + + +def run_inference_process( + *, + cmd_queue: Any, + resp_queue: Any, + cancel_event, + config: dict, +) -> None: + """Subprocess entrypoint. Persistent — runs command loop until shutdown. + + Args: + cmd_queue: mp.Queue for receiving commands from parent. + resp_queue: mp.Queue for sending responses to parent. + cancel_event: mp.Event shared with parent — set by parent to cancel generation. + config: Initial configuration dict with model info. + """ + os.environ["TOKENIZERS_PARALLELISM"] = "false" + os.environ["PYTHONWARNINGS"] = ( + "ignore" # Suppress warnings at C-level before imports + ) + + import warnings + from loggers.config import LogConfig + + if os.getenv("ENVIRONMENT_TYPE", "production") == "production": + warnings.filterwarnings("ignore") + + LogConfig.setup_logging( + service_name = "unsloth-studio-inference-worker", + env = os.getenv("ENVIRONMENT_TYPE", "production"), + ) + + model_name = config["model_name"] + + # ── 1. Activate correct transformers version BEFORE any ML imports ── + try: + _activate_transformers_version(model_name) + except Exception as exc: + _send_response( + resp_queue, + { + "type": "error", + "error": f"Failed to activate transformers version: {exc}", + "stack": traceback.format_exc(limit = 20), + "ts": time.time(), + }, + ) + return + + # ── 1b. On Windows, check Triton availability (must be before import torch) ── + if sys.platform == "win32": + try: + import triton # noqa: F401 + + logger.info("Triton available — torch.compile enabled") + except ImportError: + os.environ["TORCHDYNAMO_DISABLE"] = "1" + logger.warning( + "Triton not found on Windows — torch.compile disabled. " + 'Install for better performance: pip install "triton-windows<3.7"' + ) + + # ── 2. Import ML libraries (fresh in this clean process) ── + try: + _send_response( + resp_queue, + { + "type": "status", + "message": "Importing ML libraries...", + "ts": time.time(), + }, + ) + + backend_path = str(Path(__file__).resolve().parent.parent.parent) + if backend_path not in sys.path: + sys.path.insert(0, backend_path) + + from core.inference.inference import InferenceBackend + + import transformers + + logger.info("Subprocess loaded transformers %s", transformers.__version__) + + except Exception as exc: + _send_response( + resp_queue, + { + "type": "error", + "error": f"Failed to import ML libraries: {exc}", + "stack": traceback.format_exc(limit = 20), + "ts": time.time(), + }, + ) + return + + # ── 3. Create inference backend and load initial model ── + try: + backend = InferenceBackend() + + _send_response( + resp_queue, + { + "type": "status", + "message": "Loading model...", + "ts": time.time(), + }, + ) + + _handle_load(backend, config, resp_queue) + + except Exception as exc: + _send_response( + resp_queue, + { + "type": "error", + "error": f"Failed to initialize inference backend: {exc}", + "stack": traceback.format_exc(limit = 20), + "ts": time.time(), + }, + ) + return + + # ── 4. Command loop — process commands until shutdown ── + # cancel_event is an mp.Event shared with parent — parent can set it + # at any time to cancel generation instantly (no queue polling needed). + logger.info("Inference subprocess ready, entering command loop") + + while True: + try: + cmd = cmd_queue.get(timeout = 1.0) + except _queue.Empty: + continue + except (EOFError, OSError): + logger.info("Command queue closed, shutting down") + return + + if cmd is None: + continue + + cmd_type = cmd.get("type", "") + logger.info("Received command: %s", cmd_type) + + try: + if cmd_type == "generate": + cancel_event.clear() + _handle_generate(backend, cmd, resp_queue, cancel_event) + + elif cmd_type == "load": + # Load a new model (reusing this subprocess) + # First unload current model + if backend.active_model_name: + backend.unload_model(backend.active_model_name) + _handle_load(backend, cmd, resp_queue) + + elif cmd_type == "generate_audio": + cancel_event.clear() + _handle_generate_audio(backend, cmd, resp_queue) + + elif cmd_type == "generate_audio_input": + cancel_event.clear() + _handle_generate_audio_input(backend, cmd, resp_queue, cancel_event) + + elif cmd_type == "unload": + _handle_unload(backend, cmd, resp_queue) + + elif cmd_type == "cancel": + # Redundant with mp.Event but handle gracefully + cancel_event.set() + logger.info("Cancel command received") + + elif cmd_type == "reset": + cancel_event.set() + backend.reset_generation_state() + _send_response( + resp_queue, + { + "type": "reset_ack", + "ts": time.time(), + }, + ) + + elif cmd_type == "status": + # Return current status + _send_response( + resp_queue, + { + "type": "status_response", + "active_model": backend.active_model_name, + "models": { + name: { + "is_vision": info.get("is_vision", False), + "is_lora": info.get("is_lora", False), + } + for name, info in backend.models.items() + }, + "loading": list(backend.loading_models), + "ts": time.time(), + }, + ) + + elif cmd_type == "shutdown": + logger.info("Shutdown command received, exiting") + # Unload all models + for model_name in list(backend.models.keys()): + try: + backend.unload_model(model_name) + except Exception: + pass + _send_response( + resp_queue, + { + "type": "shutdown_ack", + "ts": time.time(), + }, + ) + return + + else: + logger.warning("Unknown command type: %s", cmd_type) + _send_response( + resp_queue, + { + "type": "error", + "error": f"Unknown command type: {cmd_type}", + "ts": time.time(), + }, + ) + + except Exception as exc: + logger.error( + "Error handling command '%s': %s", cmd_type, exc, exc_info = True + ) + _send_response( + resp_queue, + { + "type": "error", + "error": f"Command '{cmd_type}' failed: {exc}", + "stack": traceback.format_exc(limit = 20), + "ts": time.time(), + }, + ) diff --git a/studio/backend/core/training/__init__.py b/studio/backend/core/training/__init__.py new file mode 100644 index 0000000000..6e19805eb0 --- /dev/null +++ b/studio/backend/core/training/__init__.py @@ -0,0 +1,14 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Training submodule - Training backends and trainer classes +""" + +from .training import TrainingBackend, TrainingProgress, get_training_backend + +__all__ = [ + "TrainingProgress", + "TrainingBackend", + "get_training_backend", +] diff --git a/studio/backend/core/training/trainer.py b/studio/backend/core/training/trainer.py new file mode 100644 index 0000000000..cee84a8809 --- /dev/null +++ b/studio/backend/core/training/trainer.py @@ -0,0 +1,3444 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Unsloth Training Backend +Integrates Unsloth training capabilities with the FastAPI backend +""" + +import os +import sys + +# Prevent tokenizer parallelism deadlocks when datasets uses multiprocessing fork +os.environ["TOKENIZERS_PARALLELISM"] = "false" + +import torch +from utils.hardware import clear_gpu_cache, safe_num_proc + +torch._dynamo.config.recompile_limit = 64 +from unsloth import FastLanguageModel, FastVisionModel, is_bfloat16_supported +from unsloth.chat_templates import get_chat_template + +import json +import threading +import math +import structlog +from loggers import get_logger +import time +from pathlib import Path +from typing import Optional, Callable +from dataclasses import dataclass +import pandas as pd +from datasets import Dataset, load_dataset + +from utils.models import is_vision_model, detect_audio_type +from utils.datasets import format_and_template_dataset +from utils.datasets import MODEL_TO_TEMPLATE_MAPPER, TEMPLATE_TO_RESPONSES_MAPPER +from utils.paths import ( + ensure_dir, + resolve_dataset_path, + resolve_output_dir, + resolve_tensorboard_dir, +) +from trl import SFTTrainer, SFTConfig + +logger = get_logger(__name__) + + +def _build_report_targets(training_args) -> list[str] | str: + report_to: list[str] = [] + if training_args.get("enable_wandb", False): + report_to.append("wandb") + if training_args.get("enable_tensorboard", False): + report_to.append("tensorboard") + return report_to or "none" + + +@dataclass +class TrainingProgress: + """Training progress tracking""" + + epoch: float = 0 + step: int = 0 + total_steps: int = 0 + loss: float = 0.0 + learning_rate: float = 0.0 + is_training: bool = False + is_completed: bool = False + error: Optional[str] = None + status_message: str = "Ready to train" # Current stage message + elapsed_seconds: Optional[float] = None + eta_seconds: Optional[float] = None + grad_norm: Optional[float] = None + num_tokens: Optional[int] = None + eval_loss: Optional[float] = None + + +class UnslothTrainer: + """ + Unsloth Training Backend + """ + + def __init__(self): + self.model = None + self.tokenizer = None + self.trainer = None + self.training_thread = None + self.training_progress = TrainingProgress() + self.progress_callbacks = [] + self.is_training = False + self.should_stop = False + self.save_on_stop = True + self.load_in_4bit = True # Track quantization mode for metadata + + # Model state tracking + self.is_vlm = False + self.is_audio = False + self.is_audio_vlm = ( + False # Multimodal model (e.g. Gemma 3N) trained on audio data + ) + self._audio_type = None # 'csm', 'whisper', 'snac', 'bicodec', 'dac' + self._cuda_audio_used = ( + False # Set once after audio CUDA preprocessing; never cleared + ) + self._spark_tts_repo_dir = ( + None # Path to downloaded Spark-TTS repo (for BiCodecTokenizer) + ) + self.model_name = None + + # Training metrics tracking + self.training_start_time: Optional[float] = None + self.batch_size: Optional[int] = None + self.max_seq_length: Optional[int] = None + self.gradient_accumulation_steps: Optional[int] = None + + # Thread safety + self._lock = threading.Lock() + + # Store training context for later transfer + self.training_context = { + "base_model_name": None, + "output_dir": None, + "is_lora": True, # Default to LoRA + } + + def pre_detect_and_load_tokenizer( + self, + model_name: str, + max_seq_length: int = 2048, + hf_token: Optional[str] = None, + is_dataset_image: bool = False, + is_dataset_audio: bool = False, + trust_remote_code: bool = False, + ) -> None: + """Lightweight detection and tokenizer load — no model weights, no VRAM. + + Sets is_vlm, _audio_type, is_audio_vlm, model_name and loads a + lightweight tokenizer for dataset formatting. Call this before + load_and_format_dataset() when you want to process the dataset + BEFORE loading the training model (avoids VRAM contention with + the LLM-assisted detection helper). + + load_model() may be called afterwards — it will re-detect and load + the full model + tokenizer, overwriting the lightweight one set here. + """ + self.model_name = model_name + self.max_seq_length = max_seq_length + self.trust_remote_code = trust_remote_code + + if hf_token: + os.environ["HF_TOKEN"] = hf_token + + # --- Detect audio type (reads config.json only, no VRAM) --- + self._audio_type = detect_audio_type(model_name, hf_token) + if self._audio_type == "audio_vlm": + self.is_audio = False + self.is_audio_vlm = is_dataset_audio + self._audio_type = None + else: + self.is_audio = self._audio_type is not None + self.is_audio_vlm = False + + if not self.is_audio and not self.is_audio_vlm: + self._cuda_audio_used = False + + # --- Detect VLM --- + vision = is_vision_model(model_name) if not self.is_audio else False + self.is_vlm = not self.is_audio_vlm and vision and is_dataset_image + + logger.info( + "pre_detect: audio_type=%s, is_audio=%s, is_audio_vlm=%s, is_vlm=%s", + self._audio_type, + self.is_audio, + self.is_audio_vlm, + self.is_vlm, + ) + + # --- Load lightweight tokenizer/processor (CPU only, no VRAM) --- + # Whisper needs AutoProcessor (has feature_extractor + tokenizer). + # All others work with AutoTokenizer (CSM loads its own processor inline). + if self._audio_type == "whisper": + from transformers import AutoProcessor + + self.tokenizer = AutoProcessor.from_pretrained( + model_name, + trust_remote_code = trust_remote_code, + token = hf_token, + ) + else: + from transformers import AutoTokenizer + + self.tokenizer = AutoTokenizer.from_pretrained( + model_name, + trust_remote_code = trust_remote_code, + token = hf_token, + ) + + logger.info("Pre-loaded tokenizer for %s", model_name) + + def add_progress_callback(self, callback: Callable[[TrainingProgress], None]): + """Add callback for training progress updates""" + self.progress_callbacks.append(callback) + + def _update_progress(self, **kwargs): + """Update training progress and notify callbacks""" + with self._lock: + for key, value in kwargs.items(): + if hasattr(self.training_progress, key): + setattr(self.training_progress, key, value) + + # Notify all callbacks + for callback in self.progress_callbacks: + try: + callback(self.training_progress) + except Exception as e: + logger.error(f"Error in progress callback: {e}") + + def _create_progress_callback(self): + """Create a TrainerCallback for progress tracking. Reused by all training branches.""" + from transformers import TrainerCallback + + trainer_ref = self + + class _ProgressCallback(TrainerCallback): + def on_log(self, args, state, control, logs = None, **kwargs): + if not logs: + return + loss_value = logs.get("loss", logs.get("train_loss", 0.0)) + current_step = state.global_step + grad_norm = logs.get("grad_norm", None) + + elapsed_seconds = None + if trainer_ref.training_start_time is not None: + elapsed_seconds = time.time() - trainer_ref.training_start_time + + eta_seconds = None + if elapsed_seconds is not None and current_step > 0: + total_steps = trainer_ref.training_progress.total_steps + if total_steps > 0: + steps_remaining = total_steps - current_step + if steps_remaining > 0: + eta_seconds = ( + elapsed_seconds / current_step + ) * steps_remaining + + num_tokens = getattr(state, "num_input_tokens_seen", None) + + trainer_ref._update_progress( + step = current_step, + epoch = round(state.epoch, 2) if state.epoch else 0, + loss = loss_value, + learning_rate = logs.get("learning_rate", 0.0), + elapsed_seconds = elapsed_seconds, + eta_seconds = eta_seconds, + grad_norm = grad_norm, + num_tokens = num_tokens, + eval_loss = logs.get("eval_loss", None), + status_message = "", + ) + + def on_epoch_end(self, args, state, control, **kwargs): + trainer_ref._update_progress(epoch = state.epoch, step = state.global_step) + + def on_step_end(self, args, state, control, **kwargs): + if trainer_ref.should_stop: + logger.info(f"Stop detected at step {state.global_step}\n") + control.should_training_stop = True + return control + + return _ProgressCallback() + + def _calculate_total_steps( + self, num_samples, batch_size, grad_accum, num_epochs, max_steps + ): + """Calculate total training steps from dataset size and training params.""" + if max_steps and max_steps > 0: + return max_steps + len_dataloader = math.ceil(num_samples / batch_size) + steps_per_epoch = max( + len_dataloader // grad_accum + int(len_dataloader % grad_accum > 0), 1 + ) + return steps_per_epoch * num_epochs + + def _build_audio_training_args(self, training_args, output_dir, *, extra_args = None): + """Build training args dict for audio branches. + + Constructs the common config (batch size, lr, warmup, fp16/bf16, etc.) + and applies per-branch overrides via extra_args. + """ + batch_size = training_args.get("batch_size", 2) + gradient_accumulation_steps = training_args.get( + "gradient_accumulation_steps", 4 + ) + warmup_steps_val = training_args.get("warmup_steps", 5) + max_steps_val = training_args.get("max_steps", 0) + learning_rate = training_args.get("learning_rate", 2e-4) + weight_decay = training_args.get("weight_decay", 0.001) + lr_scheduler_type = training_args.get("lr_scheduler_type", "linear") + random_seed = training_args.get("random_seed", 3407) + optim_value = training_args.get("optim", "adamw_8bit") + + config = { + "per_device_train_batch_size": batch_size, + "gradient_accumulation_steps": gradient_accumulation_steps, + "warmup_steps": warmup_steps_val if warmup_steps_val is not None else 5, + "learning_rate": learning_rate, + "fp16": not is_bfloat16_supported(), + "bf16": is_bfloat16_supported(), + "logging_steps": 1, + "optim": optim_value, + "weight_decay": weight_decay, + "lr_scheduler_type": lr_scheduler_type, + "seed": random_seed, + "output_dir": output_dir, + "report_to": _build_report_targets(training_args), + } + + if training_args.get("enable_tensorboard", False): + config["logging_dir"] = str( + resolve_tensorboard_dir(training_args.get("tensorboard_dir")) + ) + + # max_steps vs epochs + if max_steps_val and max_steps_val > 0: + config["max_steps"] = max_steps_val + else: + config["num_train_epochs"] = training_args.get("num_epochs", 3) + + # save_steps + save_steps_val = training_args.get("save_steps", 0) + if save_steps_val and save_steps_val > 0: + config["save_steps"] = save_steps_val + config["save_strategy"] = "steps" + + # Apply per-branch overrides + if extra_args: + config.update(extra_args) + + return config + + def _finalize_training(self, output_dir, label = ""): + """Save model after training and update progress. Used by all training branches.""" + if self.should_stop and self.save_on_stop: + self.trainer.save_model() + self.tokenizer.save_pretrained(output_dir) + self._patch_adapter_config(output_dir) + msg = f"{label} training stopped" if label else "Training stopped" + logger.info(f"\n{msg}. Model saved to {output_dir}\n") + self._update_progress( + is_training = False, + status_message = f"Training stopped. Model saved to {output_dir}", + ) + elif self.should_stop: + msg = f"{label} training cancelled" if label else "Training cancelled" + logger.info(f"\n{msg}.\n") + self._update_progress( + is_training = False, status_message = "Training cancelled." + ) + else: + self.trainer.save_model() + self.tokenizer.save_pretrained(output_dir) + self._patch_adapter_config(output_dir) + msg = f"{label} training completed" if label else "Training completed" + logger.info(f"\n{msg}! Model saved to {output_dir}\n") + self._update_progress( + is_training = False, + is_completed = True, + status_message = f"Training completed! Model saved to {output_dir}", + ) + + def _cleanup_audio_artifacts(self): + """Remove sys.path entries and sys.modules from previous audio preprocessing. + + After audio training, cloned repo dirs (OuteTTS, Spark-TTS) remain on + sys.path and heavy audio modules (snac, whisper, sparktts, outetts) stay + in sys.modules. When the next training run calls dataset.map(num_proc=N), + forked child processes inherit this stale state and deadlock. + """ + import sys as _sys + + # Remove cloned audio repo paths from sys.path + base_dir = os.path.dirname(os.path.abspath(__file__)) + audio_paths = [ + os.path.join(base_dir, "inference", "OuteTTS"), # DAC/OuteTTS + ] + # Spark-TTS path is relative to the downloaded repo + if self._spark_tts_repo_dir: + spark_code_dir = os.path.join( + os.path.dirname(self._spark_tts_repo_dir), "Spark-TTS" + ) + audio_paths.append(spark_code_dir) + + removed_paths = [] + for path in audio_paths: + if path in _sys.path: + _sys.path.remove(path) + removed_paths.append(path) + + # Remove stale audio modules from sys.modules + prefixes = ("snac", "whisper", "sparktts", "outetts") + removed_modules = [key for key in _sys.modules if key.startswith(prefixes)] + for key in removed_modules: + del _sys.modules[key] + + if removed_paths or removed_modules: + logger.info( + f"Cleaned up audio artifacts: {len(removed_paths)} paths, " + f"{len(removed_modules)} modules\n" + ) + + def _resolve_audio_columns(self, dataset, custom_format_mapping: dict = None): + """Resolve audio, text, and speaker columns from user mapping or hardcoded fallback. + + Returns: + dict with keys: audio_col, text_col, speaker_col (speaker_col may be None) + """ + cols = dataset.column_names + + if custom_format_mapping: + audio_col = None + text_col = None + speaker_col = None + for col, role in custom_format_mapping.items(): + if role == "audio": + audio_col = col + elif role == "text": + text_col = col + elif role == "speaker_id": + speaker_col = col + # Use mapping if both required columns exist in the dataset + if audio_col and audio_col in cols and text_col and text_col in cols: + return { + "audio_col": audio_col, + "text_col": text_col, + "speaker_col": speaker_col, + } + + # Hardcoded fallback (existing behavior) + audio_col = next((c for c in cols if c.lower() in ("audio", "speech")), None) + text_col = next( + ( + c + for c in cols + if c.lower() in ("text", "sentence", "transcript", "transcription") + ), + None, + ) + + speaker_col = None + if "source" in cols: + speaker_col = "source" + elif "speaker_id" in cols: + speaker_col = "speaker_id" + + return { + "audio_col": audio_col, + "text_col": text_col, + "speaker_col": speaker_col, + } + + def load_model( + self, + model_name: str, + max_seq_length: int = 2048, + load_in_4bit: bool = True, + hf_token: Optional[str] = None, + is_dataset_image: bool = False, + is_dataset_audio: bool = False, + trust_remote_code: bool = False, + ) -> bool: + """Load model for training (supports both text and vision models)""" + self.load_in_4bit = load_in_4bit # Store for training_meta.json + self.trust_remote_code = ( + trust_remote_code # For AutoProcessor etc. used during training + ) + try: + if self.model is not None: + del self.model + if self.tokenizer is not None: + del self.tokenizer + + if self.trainer is not None: + del self.trainer + + logger.info("\nClearing GPU memory before training...") + clear_gpu_cache() + + # Clean up sys.path and sys.modules from previous audio preprocessing + # to prevent deadlocks when forking worker processes in dataset.map() + self._cleanup_audio_artifacts() + + # Reload Unsloth-patched transformers modeling modules before clearing + # the compiled cache. unsloth_compile_transformers() sets __UNSLOTH_PATCHED__ + # on each modeling module and replaces methods with exec'd code. + # clear_unsloth_compiled_cache() deletes the disk cache, but the flag + # prevents re-compilation — leaving missing cache files. Reloading + # restores original class definitions so Unsloth can re-compile cleanly. + import sys as _sys + import importlib + + for _key, _mod in list(_sys.modules.items()): + if "transformers.models." in _key and ".modeling_" in _key: + if hasattr(_mod, "__UNSLOTH_PATCHED__"): + try: + importlib.reload(_mod) + except Exception: + pass # Non-critical — Unsloth will handle stale modules + + # Remove stale compiled cache so the new model gets a fresh one + from utils.cache_cleanup import clear_unsloth_compiled_cache + + clear_unsloth_compiled_cache() + # Detect audio model type dynamically (config.json + tokenizer) + self._audio_type = detect_audio_type(model_name, hf_token) + # audio_vlm is detected as an audio_type now, handle it separately + if self._audio_type == "audio_vlm": + self.is_audio = False + self.is_audio_vlm = ( + is_dataset_audio # Only use audio VLM path if dataset has audio + ) + self._audio_type = None + else: + self.is_audio = self._audio_type is not None + self.is_audio_vlm = False + + if not self.is_audio and not self.is_audio_vlm: + self._cuda_audio_used = False + + # VLM: vision model with image dataset (mutually exclusive with audio paths) + vision = is_vision_model(model_name) if not self.is_audio else False + self.is_vlm = not self.is_audio_vlm and vision and is_dataset_image + self.model_name = model_name + self.max_seq_length = max_seq_length + + logger.info( + f"Audio type: {self._audio_type}, is_audio: {self.is_audio}, is_audio_vlm: {self.is_audio_vlm}" + ) + logger.info( + f"Dataset has images: {is_dataset_image}, audio: {is_dataset_audio}" + ) + logger.info(f"Using VLM path: {self.is_vlm}") + + # Reset training state for new run + self._update_progress( + is_training = True, + is_completed = False, + error = None, + step = 0, + loss = 0.0, + epoch = 0, + ) + + # Update UI immediately with loading message + model_display = ( + model_name.split("/")[-1] if "/" in model_name else model_name + ) + model_type_label = ( + "audio" if self.is_audio else ("vision" if self.is_vlm else "text") + ) + self._update_progress( + status_message = f"Loading {model_type_label} model... {model_display}" + ) + + logger.info(f"\nLoading {model_type_label} model: {model_name}") + + # Set HF token if provided + if hf_token: + os.environ["HF_TOKEN"] = hf_token + + # Proactive gated-model check: verify access BEFORE from_pretrained. + # Catches ALL gated/private models (text, vision, audio) globally. + if "/" in model_name: # Only check HF repo IDs, not local paths + try: + from huggingface_hub import model_info as hf_model_info + + info = hf_model_info(model_name, token = hf_token or None) + # model_info succeeds even for gated repos (metadata is public), + # but info.gated tells us if files require acceptance/token. + if info.gated and not hf_token: + friendly = ( + f"Access denied for '{model_name}'. This model is gated. " + f"Please add a Hugging Face token with access and try again." + ) + logger.error( + f"Model '{model_name}' is gated (gated={info.gated}) and no HF token provided" + ) + self._update_progress(error = friendly, is_training = False) + return False + except Exception as gate_err: + from huggingface_hub.utils import ( + GatedRepoError, + RepositoryNotFoundError, + ) + + if isinstance(gate_err, (GatedRepoError, RepositoryNotFoundError)): + friendly = ( + f"Access denied for '{model_name}'. This model is gated or private. " + f"Please add a Hugging Face token with access and try again." + ) + logger.error(f"Gated model check failed: {gate_err}") + self._update_progress(error = friendly, is_training = False) + return False + + # Branch based on model type + if self._audio_type == "csm": + # CSM: FastModel + auto_model=CsmForConditionalGeneration + load_in_4bit=False + from unsloth import FastModel + from transformers import CsmForConditionalGeneration + + self.model, self.tokenizer = FastModel.from_pretrained( + model_name = model_name, + max_seq_length = max_seq_length, + dtype = None, + auto_model = CsmForConditionalGeneration, + load_in_4bit = False, + token = hf_token, + trust_remote_code = trust_remote_code, + ) + logger.info("Loaded CSM audio model") + + elif self._audio_type == "whisper": + # Whisper: FastModel + auto_model=WhisperForConditionalGeneration + load_in_4bit=False + from unsloth import FastModel + from transformers import WhisperForConditionalGeneration + + self.model, self.tokenizer = FastModel.from_pretrained( + model_name = model_name, + dtype = None, + load_in_4bit = False, + auto_model = WhisperForConditionalGeneration, + whisper_language = "English", + whisper_task = "transcribe", + token = hf_token, + trust_remote_code = trust_remote_code, + ) + # Configure generation settings (notebook lines 100-105) + self.model.generation_config.language = "<|en|>" + self.model.generation_config.task = "transcribe" + self.model.config.suppress_tokens = [] + self.model.generation_config.forced_decoder_ids = None + logger.info("Loaded Whisper audio model (FastModel)") + + elif self._audio_type == "snac": + # Orpheus: language model with audio codec tokens + self.model, self.tokenizer = FastLanguageModel.from_pretrained( + model_name = model_name, + max_seq_length = max_seq_length, + dtype = None, + load_in_4bit = load_in_4bit, + token = hf_token, + trust_remote_code = trust_remote_code, + ) + logger.info( + f"Loaded {self._audio_type} audio model (FastLanguageModel)" + ) + + elif self._audio_type == "bicodec": + # Spark-TTS: download full repo (contains sparktts package + BiCodec weights), + # then load only the LLM subfolder with FastModel. + # model_name may be: + # "Spark-TTS-0.5B/LLM" (local-style, from YAML mapping) + # "unsloth/Spark-TTS-0.5B" (HF repo ID) + from unsloth import FastModel + from huggingface_hub import snapshot_download + + if model_name.endswith("/LLM"): + # "Spark-TTS-0.5B/LLM" → parent="Spark-TTS-0.5B" + local_dir = model_name.rsplit("/", 1)[0] + hf_repo = f"unsloth/{local_dir}" + llm_path = model_name + else: + # "unsloth/Spark-TTS-0.5B" → local_dir="Spark-TTS-0.5B" + hf_repo = model_name + local_dir = model_name.split("/")[-1] + llm_path = f"{local_dir}/LLM" + + repo_path = snapshot_download(hf_repo, local_dir = local_dir) + self._spark_tts_repo_dir = os.path.abspath( + repo_path + ) # Absolute path for sys.path + llm_path = os.path.join(self._spark_tts_repo_dir, "LLM") + + self.model, self.tokenizer = FastModel.from_pretrained( + model_name = llm_path, + max_seq_length = max_seq_length, + dtype = torch.float32, # Spark-TTS requires float32 + load_in_4bit = False, + token = hf_token, + trust_remote_code = trust_remote_code, + ) + logger.info("Loaded Spark-TTS (bicodec) model") + + elif self._audio_type == "dac": + # OuteTTS: uses FastModel (not FastLanguageModel) with load_in_4bit=False + from unsloth import FastModel + + self.model, self.tokenizer = FastModel.from_pretrained( + model_name, + max_seq_length = max_seq_length, + load_in_4bit = False, + token = hf_token, + trust_remote_code = trust_remote_code, + ) + logger.info("Loaded OuteTTS (dac) model (FastModel)") + + elif self.is_audio_vlm: + # Audio VLM: multimodal model trained on audio (e.g. Gemma 3N) + # Uses FastModel (general loader) — returns (model, processor) + from unsloth import FastModel + + self.model, self.tokenizer = FastModel.from_pretrained( + model_name = model_name, + max_seq_length = max_seq_length, + dtype = None, + load_in_4bit = load_in_4bit, + token = hf_token, + trust_remote_code = trust_remote_code, + ) + logger.info("Loaded audio VLM model (FastModel)") + + elif self.is_vlm: + # Load vision model - returns (model, tokenizer) + self.model, self.tokenizer = FastVisionModel.from_pretrained( + model_name = model_name, + max_seq_length = max_seq_length, + dtype = None, # Auto-detect + load_in_4bit = load_in_4bit, + token = hf_token, + trust_remote_code = trust_remote_code, + ) + logger.info("Loaded vision model") + + # Diagnostic: check if FastVisionModel returned a real Processor or a raw tokenizer + from transformers import ProcessorMixin + + tok = self.tokenizer + has_image_proc = isinstance(tok, ProcessorMixin) or hasattr( + tok, "image_processor" + ) + logger.info( + f"\n[VLM Diagnostic] FastVisionModel returned: {type(tok).__name__}" + ) + logger.info( + f"[VLM Diagnostic] Is ProcessorMixin: {isinstance(tok, ProcessorMixin)}" + ) + logger.info( + f"[VLM Diagnostic] Has image_processor: {hasattr(tok, 'image_processor')}" + ) + logger.info( + f"[VLM Diagnostic] Usable as vision processor: {has_image_proc}\n" + ) + else: + # Load text model - returns (model, tokenizer) + self.model, self.tokenizer = FastLanguageModel.from_pretrained( + model_name = model_name, + max_seq_length = max_seq_length, + dtype = None, # Auto-detect + load_in_4bit = load_in_4bit, + token = hf_token, + trust_remote_code = trust_remote_code, + ) + logger.info("Loaded text model") + + if self.should_stop: + return False + + self._update_progress(status_message = "Model loaded successfully") + logger.info("Model loaded successfully") + return True + + except OSError as e: + if "could not get source code" in str(e) and not getattr( + self, "_source_code_retried", False + ): + # Unsloth's patching can leave stale state that makes + # inspect.getsource() fail when switching model families + # (e.g. gemma3 → gemma3n). The load always succeeds on a + # second attempt because the failed first call's partial + # imports clean up the stale state as a side effect. + self._source_code_retried = True + logger.info(f"\n'could not get source code' — retrying once...\n") + return self.load_model( + model_name, + max_seq_length, + load_in_4bit, + hf_token, + is_dataset_image, + is_dataset_audio, + trust_remote_code, + ) + error_msg = str(e) + error_lower = error_msg.lower() + if any( + k in error_lower + for k in ( + "gated repo", + "access to it at", + "401", + "403", + "unauthorized", + "forbidden", + ) + ): + error_msg = ( + f"Access denied for '{model_name}'. This model is gated or private. " + f"Please add a Hugging Face token with access and try again." + ) + logger.error(f"Error loading model: {e}") + self._update_progress(error = error_msg, is_training = False) + return False + except Exception as e: + error_msg = str(e) + # Catch gated/auth errors and surface a friendly message + error_lower = error_msg.lower() + if any( + k in error_lower + for k in ( + "gated repo", + "access to it at", + "401", + "403", + "unauthorized", + "forbidden", + ) + ): + error_msg = ( + f"Access denied for '{model_name}'. This model is gated or private. " + f"Please add a Hugging Face token with access and try again." + ) + logger.error(f"Error loading model: {e}") + self._update_progress(error = error_msg, is_training = False) + return False + finally: + self._source_code_retried = False + + def prepare_model_for_training( + self, + use_lora: bool = True, + # Vision-specific LoRA parameters (only used if is_vlm=True) + finetune_vision_layers: bool = True, + finetune_language_layers: bool = True, + finetune_attention_modules: bool = True, + finetune_mlp_modules: bool = True, + # Standard LoRA parameters + target_modules: list = None, + lora_r: int = 16, + lora_alpha: int = 16, + lora_dropout: float = 0.0, + use_gradient_checkpointing: str = "unsloth", + use_rslora: bool = False, + use_loftq: bool = False, + ) -> bool: + """ + Prepare model for training (with optional LoRA). + """ + try: + if self.model is None: + raise ValueError("Model not loaded. Call load_model() first.") + + # Full finetuning mode - skip PEFT entirely + if not use_lora: + self._update_progress( + status_message = "Full finetuning mode - no LoRA adapters" + ) + logger.info("Full finetuning mode - training all parameters\n") + return True + + # LoRA/QLoRA mode - apply PEFT + # "all-linear" is a PEFT keyword that targets every linear layer + if isinstance(target_modules, list) and "all-linear" in target_modules: + if len(target_modules) == 1: + target_modules = "all-linear" + else: + target_modules = [m for m in target_modules if m != "all-linear"] + elif target_modules is None or ( + isinstance(target_modules, list) and len(target_modules) == 0 + ): + target_modules = [ + "q_proj", + "k_proj", + "v_proj", + "o_proj", + "gate_proj", + "up_proj", + "down_proj", + ] + + # Validate and normalize gradient_checkpointing + # Must be one of: True, False, or "unsloth" + if isinstance(use_gradient_checkpointing, str): + use_gradient_checkpointing = use_gradient_checkpointing.strip().lower() + if ( + use_gradient_checkpointing == "" + or use_gradient_checkpointing == "unsloth" + ): + use_gradient_checkpointing = "unsloth" + elif use_gradient_checkpointing in ("true", "1", "yes"): + use_gradient_checkpointing = True + elif use_gradient_checkpointing in ("false", "0", "no"): + use_gradient_checkpointing = False + else: + # Invalid value, default to "unsloth" + logger.warning( + f"Invalid gradient_checkpointing value: {use_gradient_checkpointing}, defaulting to 'unsloth'" + ) + use_gradient_checkpointing = "unsloth" + elif use_gradient_checkpointing not in (True, False, "unsloth"): + # Invalid type or value, default to "unsloth" + logger.warning( + f"Invalid gradient_checkpointing type/value: {use_gradient_checkpointing}, defaulting to 'unsloth'" + ) + use_gradient_checkpointing = "unsloth" + + # Verify model is loaded + if self.model is None: + error_msg = "Model is None - model was not loaded properly" + logger.error(error_msg) + self._update_progress(error = error_msg) + return False + + # Check if model has the expected attributes + if not hasattr(self.model, "config"): + error_msg = "Model does not have config attribute - model may not be loaded correctly" + logger.error(error_msg) + self._update_progress(error = error_msg) + return False + + logger.info( + f"Configuring LoRA adapters (r={lora_r}, alpha={lora_alpha})...\n" + ) + logger.info( + f"Gradient checkpointing: {use_gradient_checkpointing} (type: {type(use_gradient_checkpointing).__name__})\n" + ) + + # Branch based on model type: audio, audio_vlm, vision, or text + if self._audio_type in ("csm", "bicodec", "dac") or self.is_audio_vlm: + # Models using FastModel.get_peft_model (codec audio + audio VLM) + from unsloth import FastModel + + label = self._audio_type or "audio_vlm" + logger.info(f"{label} LoRA configuration:") + logger.info(f" - Target modules: {target_modules}") + if self.is_audio_vlm: + logger.info(f" - Finetune vision layers: {finetune_vision_layers}") + logger.info( + f" - Finetune language layers: {finetune_language_layers}" + ) + logger.info( + f" - Finetune attention modules: {finetune_attention_modules}" + ) + logger.info(f" - Finetune MLP modules: {finetune_mlp_modules}") + logger.info() + + peft_kwargs = dict( + r = lora_r, + target_modules = target_modules, + lora_alpha = lora_alpha, + lora_dropout = lora_dropout, + bias = "none", + use_gradient_checkpointing = use_gradient_checkpointing, + random_state = 3407, + use_rslora = use_rslora, + loftq_config = {"loftq_bits": 4, "loftq_iter": 1} + if use_loftq + else None, + ) + # Audio VLM models support VLM-style layer selection + if self.is_audio_vlm: + peft_kwargs.update( + finetune_vision_layers = finetune_vision_layers, + finetune_language_layers = finetune_language_layers, + finetune_attention_modules = finetune_attention_modules, + finetune_mlp_modules = finetune_mlp_modules, + ) + + self.model = FastModel.get_peft_model(self.model, **peft_kwargs) + + elif self._audio_type == "whisper": + # Phase 2: Whisper uses FastModel.get_peft_model with task_type=None + from unsloth import FastModel + + logger.info(f"Audio model (whisper) LoRA configuration:") + logger.info(f" - Target modules: {target_modules}\n") + + self.model = FastModel.get_peft_model( + self.model, + r = lora_r, + target_modules = target_modules, + lora_alpha = lora_alpha, + lora_dropout = lora_dropout, + bias = "none", + use_gradient_checkpointing = use_gradient_checkpointing, + random_state = 3407, + use_rslora = use_rslora, + loftq_config = {"loftq_bits": 4, "loftq_iter": 1} + if use_loftq + else None, + task_type = None, + ) + + elif self._audio_type == "snac": + # Orpheus uses FastLanguageModel.get_peft_model + logger.info(f"Audio model ({self._audio_type}) LoRA configuration:") + logger.info(f" - Target modules: {target_modules}\n") + + self.model = FastLanguageModel.get_peft_model( + self.model, + r = lora_r, + target_modules = target_modules, + lora_alpha = lora_alpha, + lora_dropout = lora_dropout, + bias = "none", + use_gradient_checkpointing = use_gradient_checkpointing, + random_state = 3407, + use_rslora = use_rslora, + loftq_config = {"loftq_bits": 4, "loftq_iter": 1} + if use_loftq + else None, + ) + + elif self.is_vlm: + # Vision model LoRA + logger.info(f"Vision model LoRA configuration:") + logger.info(f" - Finetune vision layers: {finetune_vision_layers}") + logger.info(f" - Finetune language layers: {finetune_language_layers}") + logger.info( + f" - Finetune attention modules: {finetune_attention_modules}" + ) + logger.info(f" - Finetune MLP modules: {finetune_mlp_modules}\n") + + self.model = FastVisionModel.get_peft_model( + self.model, + finetune_vision_layers = finetune_vision_layers, + finetune_language_layers = finetune_language_layers, + finetune_attention_modules = finetune_attention_modules, + finetune_mlp_modules = finetune_mlp_modules, + r = lora_r, + target_modules = target_modules, + lora_alpha = lora_alpha, + lora_dropout = lora_dropout, + bias = "none", + use_gradient_checkpointing = use_gradient_checkpointing, + random_state = 3407, + use_rslora = use_rslora, + loftq_config = {"loftq_bits": 4, "loftq_iter": 1} + if use_loftq + else None, + ) + else: + # Text model LoRA + logger.info(f"Text model LoRA configuration:") + logger.info(f" - Target modules: {target_modules}\n") + + self.model = FastLanguageModel.get_peft_model( + self.model, + r = lora_r, + target_modules = target_modules, + lora_alpha = lora_alpha, + lora_dropout = lora_dropout, + bias = "none", + use_gradient_checkpointing = use_gradient_checkpointing, + random_state = 3407, + use_rslora = use_rslora, + loftq_config = {"loftq_bits": 4, "loftq_iter": 1} + if use_loftq + else None, + ) + + # Check if stopped during LoRA preparation + if self.should_stop: + logger.info("Stopped during LoRA configuration\n") + return False + + self._update_progress(status_message = "LoRA adapters configured") + logger.info("LoRA adapters configured successfully\n") + return True + + except Exception as e: + import traceback + import sys + + error_details = ( + f"{type(e).__name__}: {str(e)}" + if str(e) + else f"{type(e).__name__} (no message)" + ) + full_traceback = traceback.format_exc() + logger.error(f"Error preparing model: {error_details}") + logger.error(f"Full traceback:\n{full_traceback}") + logger.info(f"\n[ERROR] Error preparing model: {error_details}") + logger.info(f"[ERROR] Full traceback:\n{full_traceback}") + self._update_progress(error = error_details) + return False + + def _apply_csm_forward_fix(self): + """Monkey-patch CsmForConditionalGeneration.forward to fix depth decoder kwargs. + + The original transformers forward passes raw **kwargs (num_items_in_batch, + causal_mask, etc.) from the Trainer/PEFT through to the depth decoder, + causing depth_decoder_loss=None and 'Tensor + NoneType' crash. + + We patch at both instance AND class level for maximum reliability, + and strip non-TransformersKwargs params that Unsloth/PEFT inject. + """ + import types + import torch + import torch.nn as nn + from transformers.models.csm.modeling_csm import ( + CsmForConditionalGeneration, + CsmOutputWithPast, + ) + + base_csm = self.model.base_model.model # CsmForConditionalGeneration + + # Save original forward (the @can_return_tuple wrapped version) + _original_forward = CsmForConditionalGeneration.forward + + # Keys that the depth decoder and its sub-layers actually understand + _TRANSFORMERS_KWARGS = { + "num_items_in_batch", + "output_hidden_states", + "output_attentions", + "output_router_logits", + "cu_seq_lens_q", + "cu_seq_lens_k", + "max_length_q", + "max_length_k", + } + + def _fixed_csm_forward( + self, + input_ids = None, + input_values = None, + attention_mask = None, + input_values_cutoffs = None, + position_ids = None, + past_key_values = None, + inputs_embeds = None, + labels = None, + use_cache = None, + cache_position = None, + logits_to_keep = 0, + **kwargs, + ): + # Strip non-standard kwargs injected by Unsloth/PEFT (causal_mask, + # num_logits_to_keep, task_ids, return_dict, etc.) + output_attentions = kwargs.pop("output_attentions", None) + output_hidden_states = kwargs.pop("output_hidden_states", None) + kwargs.pop("return_dict", None) + kwargs.pop("causal_mask", None) + kwargs.pop("num_logits_to_keep", None) + kwargs.pop("task_ids", None) + + # Only keep recognized TransformersKwargs + clean_kwargs = { + k: v for k, v in kwargs.items() if k in _TRANSFORMERS_KWARGS + } + + if input_ids is not None and input_ids.ndim == 2: + merged = self._merge_input_ids_with_input_values( + input_ids, input_values, input_values_cutoffs, labels + ) + inputs_embeds = merged["inputs_embeds"] + labels = merged["labels"] + input_ids = None + + backbone_outputs = self.backbone_model( + input_ids = input_ids, + attention_mask = attention_mask, + position_ids = position_ids, + past_key_values = past_key_values, + inputs_embeds = inputs_embeds, + use_cache = use_cache, + cache_position = cache_position, + output_attentions = output_attentions, + output_hidden_states = output_hidden_states, + **clean_kwargs, + ) + + backbone_hidden_states = backbone_outputs[0] + slice_indices = ( + slice(-logits_to_keep, None) + if isinstance(logits_to_keep, int) + else logits_to_keep + ) + backbone_logits = self.lm_head(backbone_hidden_states[:, slice_indices, :]) + + loss = None + backbone_loss = None + depth_decoder_loss = None + depth_decoder_outputs = None + if labels is not None: + backbone_labels = labels[:, :, 0] + backbone_loss = self.loss_function( + logits = backbone_logits, + labels = backbone_labels, + vocab_size = self.config.vocab_size, + **clean_kwargs, + ) + + train_mask = ~(labels[:, :, 1:] == -100).all(dim = -1) + depth_decoder_input_ids = labels[train_mask][ + ..., : self.config.num_codebooks - 1 + ] + depth_decoder_input_ids = nn.functional.pad( + depth_decoder_input_ids, (1, 0), value = 0 + ) + + train_idxs = train_mask.nonzero(as_tuple = True) + backbone_last_hidden_states = backbone_hidden_states[ + train_idxs[0], train_idxs[1] - 1, : + ] + depth_decoder_labels = labels[train_mask] + + # Build clean kwargs for depth decoder + dd_kwargs = clean_kwargs.copy() + # Scale num_items_in_batch for depth decoder (31 codebooks) + if "num_items_in_batch" in dd_kwargs: + dd_kwargs["num_items_in_batch"] = dd_kwargs[ + "num_items_in_batch" + ] * (self.config.num_codebooks - 1) + + depth_decoder_outputs = self.depth_decoder( + input_ids = depth_decoder_input_ids, + backbone_last_hidden_state = backbone_last_hidden_states, + use_cache = False, + return_dict = True, + labels = depth_decoder_labels, + output_attentions = output_attentions, + output_hidden_states = output_hidden_states, + **dd_kwargs, + ) + + depth_decoder_loss = depth_decoder_outputs.loss + if depth_decoder_loss is None: + logger.warning( + "CSM depth_decoder_loss is None! " + f"labels shape={depth_decoder_labels.shape}, " + f"train_mask sum={train_mask.sum().item()}" + ) + # Fallback: use only backbone loss instead of crashing + loss = backbone_loss + else: + loss = backbone_loss + depth_decoder_loss + + return CsmOutputWithPast( + loss = loss, + backbone_loss = backbone_loss, + depth_decoder_loss = depth_decoder_loss, + logits = backbone_logits, + past_key_values = backbone_outputs.past_key_values, + hidden_states = backbone_outputs.hidden_states, + attentions = backbone_outputs.attentions, + depth_decoder_logits = ( + depth_decoder_outputs.logits if depth_decoder_outputs else None + ), + depth_decoder_past_key_values = ( + depth_decoder_outputs.past_key_values + if depth_decoder_outputs + else None + ), + depth_decoder_hidden_states = ( + depth_decoder_outputs.hidden_states + if depth_decoder_outputs + else None + ), + depth_decoder_attentions = ( + depth_decoder_outputs.attentions if depth_decoder_outputs else None + ), + ) + + # Patch at BOTH instance and class level for maximum reliability. + # Instance-level: catches calls via BaseTuner.forward -> self.model.forward() + base_csm.forward = types.MethodType(_fixed_csm_forward, base_csm) + # Class-level: catches any path that resolves through the class dict + CsmForConditionalGeneration.forward = _fixed_csm_forward + logger.info("Applied CSM forward fix (class + instance level)\n") + + def _preprocess_csm_dataset(self, dataset, custom_format_mapping = None): + """Preprocess dataset for CSM TTS training (exact notebook copy).""" + from transformers import AutoProcessor + from datasets import Audio + import torch + + processor = AutoProcessor.from_pretrained( + self.model_name, + trust_remote_code = getattr(self, "trust_remote_code", False), + ) + + # Strip pad_to_multiple_of from tokenizer init_kwargs — fine-tuned models + # (e.g. keanteng/sesame-csm-elise) save it in tokenizer_config.json, and + # _merge_kwargs leaks it into audio_kwargs where EncodecFeatureExtractor rejects it. + processor.tokenizer.init_kwargs.pop("pad_to_multiple_of", None) + + # Resolve columns from user mapping or hardcoded fallback + resolved = self._resolve_audio_columns(dataset, custom_format_mapping) + audio_col = resolved["audio_col"] + text_col = resolved["text_col"] + speaker_key = resolved["speaker_col"] + + if audio_col is None: + raise ValueError( + f"No audio column found in dataset. Columns: {dataset.column_names}" + ) + if text_col is None: + raise ValueError( + f"No text column found in dataset. Columns: {dataset.column_names}" + ) + if speaker_key is None: + logger.info( + "No speaker found, adding default 'source' of 0 for all examples\n" + ) + dataset = dataset.add_column("source", ["0"] * len(dataset)) + speaker_key = "source" + + logger.info( + f"CSM preprocessing: audio_col='{audio_col}', text_col='{text_col}', speaker_key='{speaker_key}'\n" + ) + + dataset = dataset.cast_column(audio_col, Audio(sampling_rate = 24000)) + + required_keys = [ + "input_ids", + "attention_mask", + "labels", + "input_values", + "input_values_cutoffs", + ] + + self._update_progress(status_message = "Preprocessing CSM dataset...") + processed_examples = [] + skipped = 0 + for idx in range(len(dataset)): + if self.should_stop: + logger.info("Stopped during CSM preprocessing\n") + break + + example = dataset[idx] + try: + conversation = [ + { + "role": str(example[speaker_key]), + "content": [ + {"type": "text", "text": example.get(text_col, "")}, + {"type": "audio", "path": example[audio_col]["array"]}, + ], + } + ] + # NOTE: pad_to_multiple_of intentionally omitted from text_kwargs — + # CsmProcessor._merge_kwargs leaks it to EncodecFeatureExtractor which rejects it. + model_inputs = processor.apply_chat_template( + conversation, + tokenize = True, + return_dict = True, + output_labels = True, + text_kwargs = { + "padding": "max_length", + "max_length": 256, + "padding_side": "right", + }, + audio_kwargs = { + "sampling_rate": 24_000, + "max_length": 240001, + "padding": "max_length", + }, + common_kwargs = {"return_tensors": "pt"}, + ) + + out = {} + for k in required_keys: + if k not in model_inputs: + raise KeyError(f"Missing required key '{k}' in model outputs") + out[k] = model_inputs[k][0] + + if not all(isinstance(out[k], torch.Tensor) for k in out): + skipped += 1 + continue + + processed_examples.append(out) + + except Exception as e: + logger.warning(f"Error processing CSM example {idx}: {e}") + skipped += 1 + continue + + if (idx + 1) % 100 == 0: + self._update_progress( + status_message = f"Preprocessing CSM... {idx + 1}/{len(dataset)}" + ) + + if not processed_examples: + raise ValueError( + f"No valid examples after CSM preprocessing (skipped {skipped})" + ) + + result_dataset = Dataset.from_list(processed_examples) + logger.info( + f"CSM preprocessing complete: {len(result_dataset)} examples " + f"({skipped} skipped)\n" + ) + return result_dataset + + def _format_audio_vlm_dataset(self, dataset, custom_format_mapping = None): + """Format dataset as audio chat messages for multimodal models (e.g. Gemma 3N). + + Expects columns: audio (Audio), text (str). + Produces: messages column with system/user/assistant chat format. + """ + from datasets import Audio + + resolved = self._resolve_audio_columns(dataset, custom_format_mapping) + audio_col = resolved["audio_col"] + text_col = resolved["text_col"] + if not audio_col or not text_col: + raise ValueError( + f"Audio VLM dataset needs 'audio' and 'text' columns, got: {dataset.column_names}" + ) + + # Store resolved audio column name for the collator closure + self._audio_vlm_audio_col = audio_col + + # Cast audio to 16kHz (standard for speech models) + dataset = dataset.cast_column(audio_col, Audio(sampling_rate = 16000)) + + def format_messages(samples): + formatted = {"messages": []} + for idx in range(len(samples[audio_col])): + audio = samples[audio_col][idx]["array"] + label = str(samples[text_col][idx]) + message = [ + { + "role": "system", + "content": [ + { + "type": "text", + "text": "You are an assistant that transcribes speech accurately.", + } + ], + }, + { + "role": "user", + "content": [ + {"type": "audio", "audio": audio}, + {"type": "text", "text": "Please transcribe this audio."}, + ], + }, + {"role": "assistant", "content": [{"type": "text", "text": label}]}, + ] + formatted["messages"].append(message) + return formatted + + self._update_progress(status_message = "Formatting audio VLM dataset...") + dataset = dataset.map( + format_messages, batched = True, batch_size = 4, num_proc = safe_num_proc(4) + ) + logger.info(f"Audio VLM dataset formatted: {len(dataset)} examples\n") + return dataset + + def _preprocess_snac_dataset(self, dataset, custom_format_mapping = None): + """Preprocess dataset for Orpheus TTS training with SNAC codec. + + Mirrors Orpheus_(3B)-TTS.ipynb: encode audio with SNAC (24kHz, 3 hierarchical + layers), interleave 7 codes per frame, wrap with Orpheus special tokens, + train on full sequence (no label masking). + """ + import torch + import torchaudio.transforms as T + + SNAC_MODEL_NAME = "hubertsiuzdak/snac_24khz" + SNAC_SAMPLE_RATE = 24000 + device = "cuda" if torch.cuda.is_available() else "cpu" + max_length = self.max_seq_length or 2048 + tokenizer = self.tokenizer + + # Orpheus special token IDs (hardcoded in tokenizer vocabulary) + START_OF_HUMAN = 128259 + END_OF_HUMAN = 128260 + START_OF_AI = 128261 + END_OF_AI = 128262 + START_OF_SPEECH = 128257 + END_OF_SPEECH = 128258 + END_OF_TEXT = 128009 + AUDIO_OFFSET = 128266 + + resolved = self._resolve_audio_columns(dataset, custom_format_mapping) + audio_col = resolved["audio_col"] + text_col = resolved["text_col"] + speaker_col = resolved["speaker_col"] + has_source = speaker_col is not None + if not audio_col or not text_col: + raise ValueError( + f"SNAC dataset needs 'audio' and 'text' columns, got: {dataset.column_names}" + ) + + # Cast audio column so datasets 4.x AudioDecoder objects are decoded to dicts + from datasets import Audio + + dataset = dataset.cast_column(audio_col, Audio(sampling_rate = SNAC_SAMPLE_RATE)) + + # Get dataset sample rate from first example (after cast, always SNAC_SAMPLE_RATE) + first_audio = dataset[0][audio_col] + ds_sample_rate = ( + first_audio.get("sampling_rate", SNAC_SAMPLE_RATE) + if isinstance(first_audio, dict) + else SNAC_SAMPLE_RATE + ) + + # Load SNAC codec model + self._update_progress(status_message = "Loading SNAC codec model...") + logger.info("Loading SNAC codec model...\n") + from snac import SNAC + + snac_model = SNAC.from_pretrained(SNAC_MODEL_NAME) + snac_model = snac_model.to(device).eval() + + # Resample transform (created once) + resample_transform = ( + T.Resample(orig_freq = ds_sample_rate, new_freq = SNAC_SAMPLE_RATE) + if ds_sample_rate != SNAC_SAMPLE_RATE + else None + ) + + self._update_progress(status_message = "Encoding audio with SNAC...") + logger.info( + f"SNAC preprocessing: audio_col='{audio_col}', text_col='{text_col}', " + f"has_source={has_source}, ds_sample_rate={ds_sample_rate}\n" + ) + + processed_examples = [] + skipped = 0 + for idx in range(len(dataset)): + if self.should_stop: + logger.info("Stopped during SNAC preprocessing\n") + break + + example = dataset[idx] + try: + text = example.get(text_col) + if not text: + skipped += 1 + continue + + audio_data = example.get(audio_col) + if audio_data is None or audio_data.get("array") is None: + skipped += 1 + continue + + # --- Encode audio with SNAC (notebook lines 122-142) --- + waveform = ( + torch.from_numpy(audio_data["array"]) + .unsqueeze(0) + .to(dtype = torch.float32) + ) + if resample_transform is not None: + waveform = resample_transform(waveform) + + waveform = waveform.unsqueeze(0).to(device) + with torch.inference_mode(): + codes = snac_model.encode(waveform) + + # Interleave 7 codes per frame with layer offsets (notebook lines 134-142) + all_codes = [] + for i in range(codes[0].shape[1]): + all_codes.append(codes[0][0][i].item() + AUDIO_OFFSET) + all_codes.append(codes[1][0][2 * i].item() + AUDIO_OFFSET + 4096) + all_codes.append( + codes[2][0][4 * i].item() + AUDIO_OFFSET + (2 * 4096) + ) + all_codes.append( + codes[2][0][(4 * i) + 1].item() + AUDIO_OFFSET + (3 * 4096) + ) + all_codes.append( + codes[1][0][(2 * i) + 1].item() + AUDIO_OFFSET + (4 * 4096) + ) + all_codes.append( + codes[2][0][(4 * i) + 2].item() + AUDIO_OFFSET + (5 * 4096) + ) + all_codes.append( + codes[2][0][(4 * i) + 3].item() + AUDIO_OFFSET + (6 * 4096) + ) + + if len(all_codes) == 0: + skipped += 1 + continue + + # Deduplicate consecutive frames with same first code (notebook lines 185-207) + deduped = all_codes[:7] + for i in range(7, len(all_codes), 7): + if all_codes[i] != deduped[-7]: + deduped.extend(all_codes[i : i + 7]) + all_codes = deduped + + # --- Build text tokens (notebook lines 217-224) --- + text_prompt = ( + f"{example[speaker_col]}: {text}" + if has_source and example.get(speaker_col) + else text + ) + text_ids = tokenizer.encode(text_prompt, add_special_tokens = True) + text_ids.append(END_OF_TEXT) + + # --- Build full input_ids (notebook lines 225-234) --- + input_ids = ( + [START_OF_HUMAN] + + text_ids + + [END_OF_HUMAN] + + [START_OF_AI] + + [START_OF_SPEECH] + + all_codes + + [END_OF_SPEECH] + + [END_OF_AI] + ) + + # Truncate to max_length + input_ids = input_ids[:max_length] + + # Labels = input_ids (no masking — Orpheus trains on full sequence) + labels = list(input_ids) + attention_mask = [1] * len(input_ids) + + processed_examples.append( + { + "input_ids": input_ids, + "labels": labels, + "attention_mask": attention_mask, + } + ) + + except Exception as e: + logger.warning(f"Error processing SNAC example {idx}: {e}") + skipped += 1 + continue + + # Progress update every 100 examples + if (idx + 1) % 100 == 0: + self._update_progress( + status_message = f"Encoding audio... {idx + 1}/{len(dataset)}" + ) + + # Free SNAC model from GPU + logger.info("Freeing SNAC codec model from GPU...\n") + snac_model.to("cpu") + del snac_model + import gc + + gc.collect() + torch.cuda.empty_cache() + self._cuda_audio_used = True + + if not processed_examples: + raise ValueError( + f"No valid examples after SNAC preprocessing (skipped {skipped})" + ) + + result_dataset = Dataset.from_list(processed_examples) + logger.info( + f"SNAC preprocessing complete: {len(result_dataset)} examples " + f"({skipped} skipped)\n" + ) + return result_dataset + + def _preprocess_bicodec_dataset(self, dataset, custom_format_mapping = None): + """Preprocess dataset for Spark-TTS training with BiCodec tokenizer. + + Mirrors Spark_TTS_(0_5B).ipynb: encode audio with BiCodec (semantic + global tokens), + format as special-token text strings for SFTTrainer with dataset_text_field="text". + """ + import sys + import torch + import numpy as np + import torchaudio.transforms as T + + import subprocess + + device = "cuda" if torch.cuda.is_available() else "cpu" + + # The sparktts Python package lives in the SparkAudio/Spark-TTS GitHub repo, + # NOT in the unsloth/Spark-TTS-0.5B HF model repo. Clone it if needed. + spark_code_dir = os.path.join( + os.path.dirname(self._spark_tts_repo_dir), "Spark-TTS" + ) + sparktts_pkg = os.path.join(spark_code_dir, "sparktts") + if not os.path.isdir(sparktts_pkg): + self._update_progress(status_message = "Cloning Spark-TTS code repo...") + logger.info(f"Cloning SparkAudio/Spark-TTS to {spark_code_dir}...\n") + subprocess.run( + [ + "git", + "clone", + "--depth", + "1", + "https://github.com/SparkAudio/Spark-TTS", + spark_code_dir, + ], + check = True, + ) + + if spark_code_dir not in sys.path: + sys.path.insert(0, spark_code_dir) + + from sparktts.models.audio_tokenizer import BiCodecTokenizer + from sparktts.utils.audio import audio_volume_normalize + + # Resolve audio and text columns + resolved = self._resolve_audio_columns(dataset, custom_format_mapping) + audio_col = resolved["audio_col"] + text_col = resolved["text_col"] + speaker_col = resolved["speaker_col"] + has_source = speaker_col is not None + if not audio_col or not text_col: + raise ValueError( + f"BiCodec dataset needs 'audio' and 'text' columns, got: {dataset.column_names}" + ) + + # Cast audio column so datasets 4.x AudioDecoder objects are decoded to dicts. + # Don't resample here — BiCodec's target_sr may differ; the loop handles resampling. + from datasets import Audio + + dataset = dataset.cast_column(audio_col, Audio()) + + # Load BiCodec tokenizer + self._update_progress(status_message = "Loading BiCodec tokenizer...") + logger.info("Loading BiCodec tokenizer...\n") + audio_tokenizer = BiCodecTokenizer(self._spark_tts_repo_dir, device) + + target_sr = audio_tokenizer.config["sample_rate"] + + self._update_progress(status_message = "Encoding audio with BiCodec...") + logger.info( + f"BiCodec preprocessing: audio_col='{audio_col}', text_col='{text_col}', " + f"has_source={has_source}, target_sr={target_sr}\n" + ) + + def extract_wav2vec2_features(wavs: torch.Tensor) -> torch.Tensor: + """Extract wav2vec2 features (average of layers 11, 14, 16).""" + if wavs.shape[0] != 1: + raise ValueError(f"Expected batch size 1, but got shape {wavs.shape}") + wav_np = wavs.squeeze(0).cpu().numpy() + + processed = audio_tokenizer.processor( + wav_np, + sampling_rate = 16000, + return_tensors = "pt", + padding = True, + ) + input_values = processed.input_values.to( + audio_tokenizer.feature_extractor.device + ) + model_output = audio_tokenizer.feature_extractor(input_values) + + if model_output.hidden_states is None: + raise ValueError("Wav2Vec2Model did not return hidden states.") + + feats_mix = ( + model_output.hidden_states[11] + + model_output.hidden_states[14] + + model_output.hidden_states[16] + ) / 3 + return feats_mix + + processed_examples = [] + skipped = 0 + for idx in range(len(dataset)): + if self.should_stop: + logger.info("Stopped during BiCodec preprocessing\n") + break + + example = dataset[idx] + try: + text = example.get(text_col) + if not text: + skipped += 1 + continue + + audio_data = example.get(audio_col) + if audio_data is None or audio_data.get("array") is None: + skipped += 1 + continue + + audio_array = audio_data["array"] + sampling_rate = audio_data.get("sampling_rate", target_sr) + + # Resample if needed + if sampling_rate != target_sr: + resampler = T.Resample(orig_freq = sampling_rate, new_freq = target_sr) + audio_tensor_temp = torch.from_numpy(audio_array).float() + audio_array = resampler(audio_tensor_temp).numpy() + + # Volume normalize if configured + if audio_tokenizer.config.get("volume_normalize", False): + audio_array = audio_volume_normalize(audio_array) + + # Get reference clip + ref_wav_np = audio_tokenizer.get_ref_clip(audio_array) + + # Prepare tensors + audio_tensor = ( + torch.from_numpy(audio_array).unsqueeze(0).float().to(device) + ) + ref_wav_tensor = ( + torch.from_numpy(ref_wav_np).unsqueeze(0).float().to(device) + ) + + # Extract wav2vec2 features + feat = extract_wav2vec2_features(audio_tensor) + + batch = { + "wav": audio_tensor, + "ref_wav": ref_wav_tensor, + "feat": feat.to(device), + } + + # BiCodec tokenize + semantic_token_ids, global_token_ids = audio_tokenizer.model.tokenize( + batch + ) + + global_tokens = "".join( + [ + f"<|bicodec_global_{i}|>" + for i in global_token_ids.squeeze().cpu().numpy() + ] + ) + semantic_tokens = "".join( + [ + f"<|bicodec_semantic_{i}|>" + for i in semantic_token_ids.squeeze().cpu().numpy() + ] + ) + + # Format text with source prefix if available + text_content = ( + f"{example[speaker_col]}: {text}" + if has_source and example.get(speaker_col) + else text + ) + + formatted = "".join( + [ + "<|task_tts|>", + "<|start_content|>", + text_content, + "<|end_content|>", + "<|start_global_token|>", + global_tokens, + "<|end_global_token|>", + "<|start_semantic_token|>", + semantic_tokens, + "<|end_semantic_token|>", + "<|im_end|>", + ] + ) + + processed_examples.append({"text": formatted}) + + except Exception as e: + logger.warning(f"Error processing BiCodec example {idx}: {e}") + skipped += 1 + continue + + # Progress update every 100 examples + if (idx + 1) % 100 == 0: + self._update_progress( + status_message = f"Encoding audio with BiCodec... {idx + 1}/{len(dataset)}" + ) + + # Free BiCodec model from GPU + logger.info("Freeing BiCodec tokenizer from GPU...\n") + audio_tokenizer.model.cpu() + audio_tokenizer.feature_extractor.cpu() + del audio_tokenizer + import gc + + gc.collect() + torch.cuda.empty_cache() + self._cuda_audio_used = True + + if not processed_examples: + raise ValueError( + f"No valid examples after BiCodec preprocessing (skipped {skipped})" + ) + + result_dataset = Dataset.from_list(processed_examples) + logger.info( + f"BiCodec preprocessing complete: {len(result_dataset)} examples " + f"({skipped} skipped)\n" + ) + # Debug: show first example text (truncated) + sample = result_dataset[0]["text"] + logger.info(f"Sample text (first 200 chars): {sample[:200]}...\n") + logger.info(f"Sample text length: {len(sample)} chars\n") + return result_dataset + + def _preprocess_dac_dataset(self, dataset, custom_format_mapping = None): + """Preprocess dataset for OuteTTS training with DAC codec. + + Mirrors Oute_TTS_(1B).ipynb DataCreationV3: uses Whisper for word timings, + OuteTTS AudioProcessor for speaker representations, PromptProcessor for + training prompts. Outputs text strings for SFTTrainer with dataset_text_field="text". + """ + import sys + import io + import tempfile + import torch + import numpy as np + import soundfile as sf + from datasets import Dataset as HFDataset + from utils.paths import ensure_dir, tmp_root + + device = "cuda" if torch.cuda.is_available() else "cpu" + + # Clone OuteTTS repo (same as audio_codecs._load_dac) + import subprocess + + base_dir = os.path.dirname(os.path.abspath(__file__)) + outetts_code_dir = os.path.join(base_dir, "inference", "OuteTTS") + outetts_pkg = os.path.join(outetts_code_dir, "outetts") + if not os.path.isdir(outetts_pkg): + self._update_progress(status_message = "Cloning OuteTTS code repo...") + logger.info(f"Cloning edwko/OuteTTS to {outetts_code_dir}...\n") + subprocess.run( + [ + "git", + "clone", + "--depth", + "1", + "https://github.com/edwko/OuteTTS", + outetts_code_dir, + ], + check = True, + ) + for fpath in [ + os.path.join(outetts_pkg, "models", "gguf_model.py"), + os.path.join(outetts_pkg, "interface.py"), + os.path.join(outetts_pkg, "__init__.py"), + ]: + if os.path.exists(fpath): + os.remove(fpath) + logger.info(f"Removed {fpath}\n") + + if outetts_code_dir not in sys.path: + sys.path.insert(0, outetts_code_dir) + + from outetts.version.v3.audio_processor import AudioProcessor + from outetts.version.v3.prompt_processor import PromptProcessor + from outetts.models.config import ModelConfig as OuteTTSModelConfig + from outetts.utils.preprocessing import text_normalizations + + # Resolve audio and text columns + resolved = self._resolve_audio_columns(dataset, custom_format_mapping) + audio_col = resolved["audio_col"] + text_col = resolved["text_col"] + if not audio_col or not text_col: + raise ValueError( + f"DAC dataset needs 'audio' and 'text' columns, got: {dataset.column_names}" + ) + + # Cast audio to 24kHz (notebook: dataset.cast_column("audio", Audio(sampling_rate=24000))) + from datasets import Audio + + dataset = dataset.cast_column(audio_col, Audio(sampling_rate = 24000)) + logger.info("Cast audio column to 24kHz\n") + + # Load Whisper for word timings + self._update_progress( + status_message = "Loading Whisper model for word timings..." + ) + logger.info("Loading Whisper model for word timings...\n") + import whisper + + whisper_model = whisper.load_model("turbo", device = device) + + # Load OuteTTS AudioProcessor + PromptProcessor + self._update_progress(status_message = "Loading OuteTTS AudioProcessor...") + logger.info("Loading OuteTTS AudioProcessor...\n") + model_tokenizer_path = "OuteAI/Llama-OuteTTS-1.0-1B" + dummy_config = OuteTTSModelConfig( + tokenizer_path = model_tokenizer_path, + device = device, + audio_codec_path = None, + ) + audio_processor = AudioProcessor(config = dummy_config) + prompt_processor = PromptProcessor(model_tokenizer_path) + + self._update_progress(status_message = "Preprocessing audio with OuteTTS...") + logger.info( + f"DAC preprocessing: audio_col='{audio_col}', text_col='{text_col}'\n" + ) + + processed_examples = [] + skipped = 0 + for idx in range(len(dataset)): + if self.should_stop: + logger.info("Stopped during DAC preprocessing\n") + break + + example = dataset[idx] + try: + text = example.get(text_col) + if not text or not isinstance(text, str): + skipped += 1 + continue + + audio_data = example.get(audio_col) + if audio_data is None or audio_data.get("array") is None: + skipped += 1 + continue + + audio_array = np.array(audio_data["array"], dtype = np.float32) + sampling_rate = audio_data.get("sampling_rate", 24000) + + # Convert to WAV bytes (Whisper needs a file path) + buf = io.BytesIO() + sf.write(buf, audio_array, sampling_rate, format = "WAV", subtype = "FLOAT") + buf.seek(0) + audio_bytes = buf.getvalue() + + # 1. Get word timings from Whisper + with tempfile.NamedTemporaryFile( + suffix = ".wav", + delete = False, + dir = str(ensure_dir(tmp_root())), + ) as tmp: + tmp.write(audio_bytes) + tmp.flush() + tmp_path = tmp.name + try: + whisper_result = whisper_model.transcribe( + tmp_path, word_timestamps = True + ) + finally: + Path(tmp_path).unlink(missing_ok = True) + + normalized_transcript = text_normalizations(text) + words_with_timings = [] + if whisper_result and "segments" in whisper_result: + for segment in whisper_result["segments"]: + for word_info in segment.get("words", []): + cleaned = word_info["word"].strip() + if cleaned: + words_with_timings.append( + { + "word": cleaned, + "start": float(word_info["start"]), + "end": float(word_info["end"]), + } + ) + + if not words_with_timings: + skipped += 1 + continue + + # 2. Create speaker representation with AudioProcessor + speaker_data_dict = { + "audio": {"bytes": audio_bytes}, + "text": normalized_transcript, + "words": words_with_timings, + } + speaker = audio_processor.create_speaker_from_dict(speaker_data_dict) + if speaker is None: + skipped += 1 + continue + + # 3. Get training prompt from PromptProcessor + prompt = prompt_processor.get_training_prompt(speaker) + if prompt: + processed_examples.append({"text": prompt}) + + except Exception as e: + logger.warning(f"Error processing DAC example {idx}: {e}") + skipped += 1 + continue + + if (idx + 1) % 100 == 0: + self._update_progress( + status_message = f"Preprocessing audio with OuteTTS... {idx + 1}/{len(dataset)}" + ) + + # Free Whisper from GPU (notebook: data_processor.whisper_model.to('cpu')) + logger.info("Moving Whisper model to CPU...\n") + whisper_model.to("cpu") + del whisper_model + del audio_processor + del prompt_processor + import gc + + gc.collect() + torch.cuda.empty_cache() + self._cuda_audio_used = True + + if not processed_examples: + raise ValueError( + f"No valid examples after DAC preprocessing (skipped {skipped})" + ) + + result_dataset = HFDataset.from_list(processed_examples) + logger.info( + f"DAC preprocessing complete: {len(result_dataset)} examples " + f"({skipped} skipped)\n" + ) + sample = result_dataset[0]["text"] + logger.info(f"Sample text (first 200 chars): {sample[:200]}...\n") + return result_dataset + + def _preprocess_whisper_dataset( + self, dataset, eval_split = None, custom_format_mapping = None + ): + """Preprocess dataset for Whisper speech-to-text training. + + Mirrors Whisper.ipynb: extract audio features with Whisper's feature + extractor, tokenize text labels. Returns (train_data, eval_data) where + each is a list of dicts with 'input_features' and 'labels'. + """ + from datasets import Audio + + WHISPER_SAMPLE_RATE = 16000 + + resolved = self._resolve_audio_columns(dataset, custom_format_mapping) + audio_col = resolved["audio_col"] + text_col = resolved["text_col"] + if not audio_col or not text_col: + raise ValueError( + f"Whisper dataset needs 'audio' and 'text' columns, got: {dataset.column_names}" + ) + + # Cast audio to 16kHz (Whisper's expected sample rate) + dataset = dataset.cast_column( + audio_col, Audio(sampling_rate = WHISPER_SAMPLE_RATE) + ) + + # Train/eval split (notebook does dataset.train_test_split) + eval_dataset_raw = None + if eval_split: + splits = dataset.train_test_split(test_size = 0.06, seed = 42) + dataset = splits["train"] + eval_dataset_raw = splits["test"] + + self._update_progress(status_message = "Processing audio for Whisper...") + logger.info( + f"Whisper preprocessing: audio_col='{audio_col}', text_col='{text_col}', " + f"samples={len(dataset)}\n" + ) + + def process_split(ds, split_name = "train"): + processed = [] + skipped = 0 + for idx in range(len(ds)): + if self.should_stop: + logger.info(f"Stopped during Whisper {split_name} preprocessing\n") + break + + example = ds[idx] + try: + audio_data = example.get(audio_col) + text = example.get(text_col) + if ( + audio_data is None + or audio_data.get("array") is None + or not text + ): + skipped += 1 + continue + + # Extract audio features (notebook line 112-115) + features = self.tokenizer.feature_extractor( + audio_data["array"], sampling_rate = audio_data["sampling_rate"] + ) + # Tokenize text (notebook line 116) + tokenized_text = self.tokenizer.tokenizer(text) + + processed.append( + { + "input_features": features.input_features[0], + "labels": tokenized_text.input_ids, + } + ) + except Exception as e: + logger.warning( + f"Error processing Whisper {split_name} example {idx}: {e}" + ) + skipped += 1 + continue + + if (idx + 1) % 100 == 0: + self._update_progress( + status_message = f"Processing {split_name} audio... {idx + 1}/{len(ds)}" + ) + + logger.info( + f"Whisper {split_name} preprocessing: {len(processed)} examples ({skipped} skipped)\n" + ) + return processed + + train_data = process_split(dataset, "train") + eval_data = ( + process_split(eval_dataset_raw, "eval") if eval_dataset_raw else None + ) + + if not train_data: + raise ValueError("No valid examples after Whisper preprocessing") + + return (train_data, eval_data) + + def load_and_format_dataset( + self, + dataset_source: str, + format_type: str = "auto", + local_datasets: list = None, + custom_format_mapping: dict = None, + subset: str = None, + train_split: str = "train", + eval_split: str = None, + eval_steps: float = 0.00, + dataset_slice_start: int = None, + dataset_slice_end: int = None, + ) -> Optional[tuple]: + """ + Load and prepare dataset for training. + + Strategy: format first, then split — ensures both train and eval + portions are properly formatted and templated. + + Returns: + Tuple of (dataset_info, eval_dataset) or None on error. + eval_dataset may be None if no eval split is available. + """ + try: + dataset = None + eval_dataset = None + has_separate_eval_source = ( + False # True if eval comes from a separate HF split + ) + eval_enabled = eval_steps is not None and eval_steps > 0 + + if local_datasets: + # Load local datasets using load_dataset() so the result is + # Arrow-backed (has cache files). Dataset.from_list() creates + # an in-memory dataset with no cache, which forces num_proc=1 + # during tokenization/map because sharding requires Arrow files. + all_files: list[str] = [] + for dataset_file in local_datasets: + # dataset_file may already be an absolute path from routes/training.py + if os.path.isabs(dataset_file): + file_path = dataset_file + else: + # Fallback: try relative to assets/datasets + file_path = str(resolve_dataset_path(dataset_file)) + + file_path_obj = Path(file_path) + + if file_path_obj.is_dir(): + parquet_dir = ( + file_path_obj / "parquet-files" + if (file_path_obj / "parquet-files").exists() + else file_path_obj + ) + parquet_files = sorted(parquet_dir.glob("*.parquet")) + if parquet_files: + all_files.extend(str(p) for p in parquet_files) + continue + # Fall through to single-file detection for dirs with json/csv + candidates: list[Path] = [] + for ext in (".json", ".jsonl", ".csv", ".parquet"): + candidates.extend(sorted(file_path_obj.glob(f"*{ext}"))) + if candidates: + all_files.extend(str(c) for c in candidates) + continue + raise ValueError( + f"No supported data files in directory: {file_path_obj}" + ) + else: + all_files.append(str(file_path_obj)) + + if all_files: + # Determine loader type from the first file extension + first_ext = Path(all_files[0]).suffix.lower() + if first_ext in (".json", ".jsonl"): + loader = "json" + elif first_ext == ".csv": + loader = "csv" + elif first_ext == ".parquet": + loader = "parquet" + else: + raise ValueError( + f"Unsupported local dataset format: {all_files[0]}" + ) + + dataset = load_dataset(loader, data_files = all_files, split = "train") + + # Check if stopped during dataset loading + if self.should_stop: + logger.info("Stopped during dataset loading\n") + return None + + self._update_progress( + status_message = f"Loaded {len(dataset)} samples from local files" + ) + logger.info(f"Loaded {len(dataset)} samples from local files\n") + logger.info(f"[DEBUG] Dataset cache_files: {dataset.cache_files}\n") + + elif dataset_source: + # Load from Hugging Face + split_name = train_split or "train" + load_kwargs = {"path": dataset_source, "split": split_name} + if subset: + load_kwargs["name"] = subset + + _slice_start = dataset_slice_start or 0 + if ( + dataset_slice_end is not None + and dataset_slice_end >= 0 + and dataset_slice_end >= _slice_start + ): + # Manual slice — stream only the rows we need instead of + # downloading the entire dataset. + rows_to_stream = dataset_slice_end + 1 + logger.info( + f"[dataset-slice] Manual slice specified " + f"(start={dataset_slice_start}, end={dataset_slice_end}), " + f"streaming {rows_to_stream} rows\n" + ) + stream = load_dataset(**load_kwargs, streaming = True) + dataset = Dataset.from_list(list(stream.take(rows_to_stream))) + logger.info( + f"[dataset-slice] Downloaded {len(dataset)} rows " + f"(requested {rows_to_stream})\n" + ) + self._update_progress( + status_message = f"Streamed {len(dataset)} rows from HuggingFace" + ) + else: + dataset = load_dataset(**load_kwargs) + + # Check if stopped during dataset loading + if self.should_stop: + logger.info("Stopped during dataset loading\n") + return None + + self._update_progress( + status_message = f"Loaded dataset from HuggingFace: {dataset_source}" + ) + logger.info( + f"Loaded dataset from Hugging Face: {dataset_source} ({len(dataset)} rows)\n" + ) + + # Resolve eval split from a separate HF split (explicit or auto-detected) + if eval_enabled: + effective_train = train_split or "train" + if eval_split and eval_split != effective_train: + # Explicit eval split provided - load it directly + logger.info(f"Loading explicit eval split: '{eval_split}'\n") + eval_load_kwargs = {"path": dataset_source, "split": eval_split} + if subset: + eval_load_kwargs["name"] = subset + eval_dataset = load_dataset(**eval_load_kwargs) + has_separate_eval_source = True + logger.info( + f"Loaded eval split '{eval_split}' with {len(eval_dataset)} rows\n" + ) + elif eval_split and eval_split == effective_train: + # Same split as training — will do 80/20 split after formatting + logger.info( + f"Eval split '{eval_split}' is the same as train split — will split 80/20\n" + ) + else: + # Auto-detect eval split from HF (returns a separate dataset, or None) + eval_dataset = self._auto_detect_eval_split_from_hf( + dataset_source = dataset_source, + subset = subset, + ) + if eval_dataset is not None: + has_separate_eval_source = True + else: + logger.info( + "Eval disabled (eval_steps <= 0), skipping eval split detection\n" + ) + + if dataset is None: + raise ValueError("No dataset provided") + + # Apply index range slicing if requested (inclusive on both ends) + if dataset_slice_start is not None or dataset_slice_end is not None: + total_rows = len(dataset) + start = dataset_slice_start if dataset_slice_start is not None else 0 + end = ( + dataset_slice_end + if dataset_slice_end is not None + else total_rows - 1 + ) + # Clamp to valid range + start = max(0, min(start, total_rows - 1)) + end = max(start, min(end, total_rows - 1)) + dataset = dataset.select(range(start, end + 1)) + logger.info( + f"Sliced dataset to rows [{start}, {end}]: {len(dataset)} of {total_rows} rows\n" + ) + self._update_progress( + status_message = f"Sliced dataset to {len(dataset)} rows (indices {start}-{end})" + ) + + # Check if stopped before applying template + if self.should_stop: + logger.info("Stopped before applying chat template\n") + return None + + # ========== AUDIO MODELS: custom preprocessing ========== + if self._audio_type == "csm": + processed = self._preprocess_csm_dataset(dataset, custom_format_mapping) + return (processed, None) + + elif self._audio_type == "whisper": + train_data, eval_data = self._preprocess_whisper_dataset( + dataset, + eval_split = eval_split, + custom_format_mapping = custom_format_mapping, + ) + return (train_data, eval_data) + + elif self._audio_type == "snac": + processed = self._preprocess_snac_dataset( + dataset, custom_format_mapping + ) + return (processed, None) + + elif self._audio_type == "bicodec": + processed = self._preprocess_bicodec_dataset( + dataset, custom_format_mapping + ) + return ({"dataset": processed, "final_format": "audio_bicodec"}, None) + + elif self._audio_type == "dac": + processed = self._preprocess_dac_dataset(dataset, custom_format_mapping) + return ({"dataset": processed, "final_format": "audio_dac"}, None) + + elif self.is_audio_vlm: + formatted = self._format_audio_vlm_dataset( + dataset, custom_format_mapping + ) + return (formatted, None) + + # ========== FORMAT FIRST ========== + logger.info(f"Formatting dataset with format_type='{format_type}'...\n") + + dataset_info = format_and_template_dataset( + dataset, + model_name = self.model_name, + tokenizer = self.tokenizer, + is_vlm = self.is_vlm, + format_type = format_type, + dataset_name = dataset_source, + custom_format_mapping = custom_format_mapping, + progress_callback = self._update_progress, + ) + + # Check if stopped during formatting + if self.should_stop: + logger.info("Stopped during dataset formatting\n") + return None + + # Abort if dataset formatting/conversion failed + if not dataset_info.get("success", True): + errors = dataset_info.get("errors", []) + error_msg = "; ".join(errors) if errors else "Dataset formatting failed" + logger.error(f"Dataset conversion failed: {error_msg}") + self._update_progress(error = error_msg) + return None + + self._update_progress( + status_message = f"Dataset formatted and ready for training" + ) + logger.info(f"Dataset formatted successfully\n") + + # ========== THEN SPLIT ========== + if has_separate_eval_source and eval_dataset is not None: + # Eval came from a separate HF split — format it too + logger.info(f"Formatting eval dataset ({len(eval_dataset)} rows)...\n") + eval_info = format_and_template_dataset( + eval_dataset, + model_name = self.model_name, + tokenizer = self.tokenizer, + is_vlm = self.is_vlm, + format_type = format_type, + dataset_name = dataset_source, + custom_format_mapping = custom_format_mapping, + ) + eval_dataset = eval_info["dataset"] + logger.info(f"Eval dataset formatted successfully\n") + elif eval_enabled and not has_separate_eval_source: + # No separate eval source — split the already-formatted dataset + formatted_dataset = dataset_info["dataset"] + split_result = self._resolve_eval_split_from_dataset(formatted_dataset) + if split_result is not None: + train_portion, eval_dataset = split_result + dataset_info["dataset"] = train_portion + + return (dataset_info, eval_dataset) + + except Exception as e: + logger.error(f"Error loading dataset: {e}") + self._update_progress(error = str(e)) + return None + + def _auto_detect_eval_split_from_hf( + self, dataset_source: str, subset: str + ) -> Optional[Dataset]: + """Auto-detect an eval split from HF dataset (separate named split only).""" + try: + from datasets import get_dataset_split_names + + load_kwargs = {"path": dataset_source} + if subset: + load_kwargs["config_name"] = subset + available_splits = get_dataset_split_names(**load_kwargs) + logger.info(f"Available splits: {available_splits}\n") + + # Check for common eval split names + for candidate in ["eval", "validation", "valid", "val", "test"]: + if candidate in available_splits: + eval_load_kwargs = {"path": dataset_source, "split": candidate} + if subset: + eval_load_kwargs["name"] = subset + candidate_ds = load_dataset(**eval_load_kwargs) + if len(candidate_ds) >= 16: + logger.info( + f"Auto-detected eval split '{candidate}' with {len(candidate_ds)} rows\n" + ) + return candidate_ds + else: + logger.info( + f"Found eval split '{candidate}' but only {len(candidate_ds)} rows (< 16), skipping\n" + ) + + except Exception as e: + logger.warning(f"Could not check dataset splits: {e}") + + # No separate HF eval split found — caller will handle programmatic splitting + return None + + def _resolve_eval_split_from_dataset(self, dataset) -> Optional[tuple]: + """Split a dataset into train and eval portions. + + Returns: + Tuple of (train_dataset, eval_dataset), or None if dataset too small. + """ + MIN_EVAL_ROWS = 16 + MIN_TOTAL_ROWS = 32 # Need at least 16 train + 16 eval + + n = len(dataset) + if n < MIN_TOTAL_ROWS: + logger.info(f"Dataset too small ({n} rows) for eval split, skipping eval\n") + return None + + eval_size = max(MIN_EVAL_ROWS, min(128, int(0.05 * n))) + # Ensure we don't take more than half the dataset + eval_size = min(eval_size, n // 2) + + logger.info(f"Auto-splitting: {eval_size} rows for eval from {n} total\n") + split_result = dataset.train_test_split(test_size = eval_size, seed = 3407) + logger.info( + f"Split complete: {len(split_result['train'])} train, {len(split_result['test'])} eval\n" + ) + return (split_result["train"], split_result["test"]) + + def start_training( + self, + dataset: Dataset, + eval_dataset: Dataset = None, + eval_steps: float = 0.00, + output_dir: str | None = None, + num_epochs: int = 3, + learning_rate: float = 5e-5, + batch_size: int = 2, + gradient_accumulation_steps: int = 4, + warmup_steps: int = None, + warmup_ratio: float = None, + max_steps: int = 0, + save_steps: int = 0, + weight_decay: float = 0.01, + random_seed: int = 3407, + packing: bool = False, + train_on_completions: bool = False, + enable_wandb: bool = False, + wandb_project: str = "unsloth-training", + wandb_token: str = None, + enable_tensorboard: bool = False, + tensorboard_dir: str | None = None, + **kwargs, + ) -> bool: + """Start training in a separate thread""" + + if self.is_training: + logger.warning("Training already in progress") + return False + + if self.model is None or self.tokenizer is None: + self._update_progress(error = "Model not loaded") + return False + + # Pre-import heavy transformers modules on the main thread. + # Unsloth's patched_import hook (deepseek_v3_moe.py) is not thread-safe + # with Python's importlib cache, causing KeyError: 'size' if these are + # first imported inside the worker thread. + import transformers # noqa: F401 – ensures submodules are cached + from transformers import ( # noqa: F401 + Trainer as _HFTrainer, + TrainingArguments as _TrainingArguments, + TrainerCallback as _TrainerCallback, + ) + + if self._audio_type == "whisper": + from transformers import ( # noqa: F401 + Seq2SeqTrainer as _Seq2SeqTrainer, + Seq2SeqTrainingArguments as _Seq2SeqTrainingArguments, + ) + + # Start training in separate thread + self.training_thread = threading.Thread( + target = self._train_worker, + args = (dataset,), + kwargs = { + "output_dir": output_dir, + "num_epochs": num_epochs, + "learning_rate": learning_rate, + "batch_size": batch_size, + "gradient_accumulation_steps": gradient_accumulation_steps, + "warmup_steps": warmup_steps, + "warmup_ratio": warmup_ratio, + "max_steps": max_steps, + "save_steps": save_steps, + "weight_decay": weight_decay, + "random_seed": random_seed, + "packing": packing, + "train_on_completions": train_on_completions, + "enable_wandb": enable_wandb, + "wandb_project": wandb_project, + "wandb_token": wandb_token, + "enable_tensorboard": enable_tensorboard, + "tensorboard_dir": tensorboard_dir, + "eval_dataset": eval_dataset, + "eval_steps": eval_steps, + **kwargs, + }, + ) + + self.should_stop = False + self.is_training = True + try: + self.training_thread.start() + return True + except Exception as e: + self.is_training = False + logger.error(f"Failed to start training thread: {e}") + return False + + def _train_worker(self, dataset: Dataset, **training_args): + """Worker function for training (runs in separate thread)""" + try: + # Store training parameters for metrics calculation + self.batch_size = training_args.get("batch_size", 2) + self.max_seq_length = training_args.get("max_seq_length", 2048) + self.gradient_accumulation_steps = training_args.get( + "gradient_accumulation_steps", 4 + ) + + # Set training start time + self.training_start_time = time.time() + + self._update_progress(is_training = True, error = None) + + # Setup logging + if training_args.get("enable_wandb", False) and training_args.get( + "wandb_token" + ): + os.environ["WANDB_API_KEY"] = training_args["wandb_token"] + import wandb + + wandb.init( + project = training_args.get("wandb_project", "unsloth-training") + ) + + # Create output directory + output_dir = str(resolve_output_dir(training_args.get("output_dir"))) + ensure_dir(Path(output_dir)) + + # ========== AUDIO TRAINER BRANCH ========== + if self._audio_type == "csm": + # CSM uses plain HF Trainer (NOT SFTTrainer) + # Needs remove_unused_columns=False for depth decoder (input_values + cutoffs) + from transformers import Trainer as HFTrainer, TrainingArguments + + self._apply_csm_forward_fix() + + config = self._build_audio_training_args( + training_args, + output_dir, + extra_args = { + "remove_unused_columns": False, + }, + ) + self.trainer = HFTrainer( + model = self.model, + train_dataset = dataset, + args = TrainingArguments(**config), + ) + self.trainer.add_callback(self._create_progress_callback()) + + batch_size = training_args.get("batch_size", 2) + total = self._calculate_total_steps( + len(dataset), + batch_size, + training_args.get("gradient_accumulation_steps", 4), + training_args.get("num_epochs", 3), + training_args.get("max_steps", 0), + ) + self._update_progress( + total_steps = total, status_message = "Starting CSM training..." + ) + logger.info(f"CSM training config: {config}\n") + self.trainer.train() + self._finalize_training(output_dir, "CSM") + return + + elif self._audio_type == "snac": + # Orpheus: language model with SNAC codec tokens — plain HF Trainer + # DataCollatorForSeq2Seq dynamically pads variable-length sequences per batch + # (text + audio codes vary in length) and pads labels with -100. + from transformers import ( + Trainer as HFTrainer, + TrainingArguments, + DataCollatorForSeq2Seq, + ) + + config = self._build_audio_training_args(training_args, output_dir) + self.trainer = HFTrainer( + model = self.model, + train_dataset = dataset, + args = TrainingArguments(**config), + data_collator = DataCollatorForSeq2Seq( + tokenizer = self.tokenizer, + padding = True, + pad_to_multiple_of = 8, + ), + ) + self.trainer.add_callback(self._create_progress_callback()) + + batch_size = training_args.get("batch_size", 2) + total = self._calculate_total_steps( + len(dataset), + batch_size, + training_args.get("gradient_accumulation_steps", 4), + training_args.get("num_epochs", 3), + training_args.get("max_steps", 0), + ) + self._update_progress( + total_steps = total, status_message = "Starting SNAC training..." + ) + logger.info(f"SNAC training config: {config}\n") + self.trainer.train() + self._finalize_training(output_dir, "SNAC") + return + + elif self._audio_type == "whisper": + # Whisper: Seq2SeqTrainer with custom speech collator + from transformers import Seq2SeqTrainer, Seq2SeqTrainingArguments + from utils.datasets import DataCollatorSpeechSeq2SeqWithPadding + + eval_dataset = training_args.get("eval_dataset", None) + extra = {"remove_unused_columns": False, "label_names": ["labels"]} + if eval_dataset: + extra["eval_strategy"] = "steps" + extra["eval_steps"] = training_args.get("eval_steps", 5) + + config = self._build_audio_training_args( + training_args, output_dir, extra_args = extra + ) + + trainer_kwargs = { + "model": self.model, + "train_dataset": dataset, + "data_collator": DataCollatorSpeechSeq2SeqWithPadding( + processor = self.tokenizer + ), + "processing_class": self.tokenizer.feature_extractor, + "args": Seq2SeqTrainingArguments(**config), + } + if eval_dataset: + trainer_kwargs["eval_dataset"] = eval_dataset + + self.trainer = Seq2SeqTrainer(**trainer_kwargs) + self.trainer.add_callback(self._create_progress_callback()) + + batch_size = training_args.get("batch_size", 2) + total = self._calculate_total_steps( + len(dataset), + batch_size, + training_args.get("gradient_accumulation_steps", 4), + training_args.get("num_epochs", 3), + training_args.get("max_steps", 0), + ) + self._update_progress( + total_steps = total, status_message = "Starting Whisper training..." + ) + logger.info(f"Whisper training config: {config}\n") + self.trainer.train() + self._finalize_training(output_dir, "Whisper") + return + + elif self._audio_type is not None and self._audio_type not in ( + "bicodec", + "dac", + ): + # bicodec/dac use the standard SFTTrainer text path below + raise NotImplementedError( + f"Audio training for '{self._audio_type}' not yet implemented" + ) + + # ========== DATA COLLATOR SELECTION ========== + # Detect special model types + model_name_lower = self.model_name.lower() + is_deepseek_ocr = ( + "deepseek" in model_name_lower and "ocr" in model_name_lower + ) + + logger.info("Configuring data collator...\n") + + data_collator = None # Default to built-in data collator + if is_deepseek_ocr: + # Special DeepSeek OCR collator - auto-install if needed + logger.info("Detected DeepSeek OCR model\n") + # Ensure DeepSeek OCR module is installed + if not _ensure_deepseek_ocr_installed(): + error_msg = ( + "Failed to install DeepSeek OCR module. " + "Please install manually: " + "from huggingface_hub import snapshot_download; " + "snapshot_download('unsloth/DeepSeek-OCR', local_dir='deepseek_ocr')" + ) + logger.error(error_msg) + self._update_progress(error = error_msg, is_training = False) + return + + try: + from backend.data_utils import DeepSeekOCRDataCollator + + logger.info("Configuring DeepSeek OCR data collator...\n") + FastVisionModel.for_training(self.model) + data_collator = DeepSeekOCRDataCollator( + tokenizer = self.tokenizer, + model = self.model, + image_size = 640, + base_size = 1024, + crop_mode = True, + train_on_responses_only = training_args.get( + "train_on_completions", False + ), + ) + logger.info("DeepSeek OCR data collator configured successfully\n") + + except Exception as e: + logger.error(f"Failed to configure DeepSeek OCR collator: {e}") + error_msg = f"Error configuring DeepSeek OCR: {str(e)}" + self._update_progress(error = error_msg, is_training = False) + return + + elif self.is_audio_vlm: + # Audio VLM collator (e.g. Gemma 3N with audio data) + # Mirrors the collate_fn from Gemma3N_(4B)-Audio notebook + logger.info("Configuring audio VLM data collator...\n") + processor = self.tokenizer # FastModel returns processor as tokenizer + + audio_col_name = getattr(self, "_audio_vlm_audio_col", "audio") + + def audio_vlm_collate_fn(examples): + texts = [] + audios = [] + for example in examples: + text = processor.apply_chat_template( + example["messages"], + tokenize = False, + add_generation_prompt = False, + ).strip() + texts.append(text) + audios.append(example[audio_col_name]["array"]) + + batch = processor( + text = texts, audio = audios, return_tensors = "pt", padding = True + ) + + # Labels = input_ids with special tokens masked + labels = batch["input_ids"].clone() + labels[labels == processor.tokenizer.pad_token_id] = -100 + for attr in ( + "audio_token_id", + "image_token_id", + "boi_token_id", + "eoi_token_id", + ): + token_id = getattr(processor.tokenizer, attr, None) + if token_id is not None: + labels[labels == token_id] = -100 + batch["labels"] = labels + return batch + + data_collator = audio_vlm_collate_fn + logger.info("Audio VLM data collator configured\n") + + elif self.is_vlm: + # Standard VLM collator (images) + logger.info("Using UnslothVisionDataCollator for vision model\n") + from unsloth.trainer import UnslothVisionDataCollator + + FastVisionModel.for_training(self.model) + data_collator = UnslothVisionDataCollator(self.model, self.tokenizer) + logger.info("Vision data collator configured\n") + + # ========== TRAINING CONFIGURATION ========== + # Handle warmup_steps vs warmup_ratio + warmup_steps_val = training_args.get("warmup_steps", None) + warmup_ratio_val = training_args.get("warmup_ratio", None) + + lr_value = training_args.get("learning_rate", 2e-4) + logger.info( + f"[DEBUG] learning_rate from training_args: {lr_value} (type: {type(lr_value).__name__})\n" + ) + + config_args = { + "per_device_train_batch_size": training_args.get("batch_size", 2), + "gradient_accumulation_steps": training_args.get( + "gradient_accumulation_steps", 4 + ), + "num_train_epochs": training_args.get( + "num_epochs", 3 + ), # Default to epochs + "learning_rate": lr_value, + "fp16": not is_bfloat16_supported(), + "bf16": is_bfloat16_supported(), + "logging_steps": 1, + "weight_decay": training_args.get("weight_decay", 0.01), + "seed": training_args.get("random_seed", 3407), + "output_dir": output_dir, + "report_to": _build_report_targets(training_args), + "include_num_input_tokens_seen": True, # Enable token counting + "dataset_num_proc": 1 + if (self.is_audio or self.is_audio_vlm or self._cuda_audio_used) + else safe_num_proc(max(1, os.cpu_count() // 4)), + "max_seq_length": training_args.get("max_seq_length", 2048), + } + if training_args.get("enable_tensorboard", False): + config_args["logging_dir"] = str( + resolve_tensorboard_dir(training_args.get("tensorboard_dir")) + ) + logger.info( + f"[DEBUG] dataset_num_proc={config_args['dataset_num_proc']} (is_audio={self.is_audio}, is_audio_vlm={self.is_audio_vlm}, _cuda_audio_used={self._cuda_audio_used})" + ) + + # On Windows with transformers 5.x, disable DataLoader multiprocessing + # to avoid issues with modified sys.path (.venv_t5) in spawned workers. + if sys.platform == "win32": + import transformers as _tf + + if _tf.__version__.startswith("5."): + config_args["dataloader_num_workers"] = 0 + + # Add warmup parameter - use warmup_ratio if provided, otherwise warmup_steps + if warmup_ratio_val is not None: + config_args["warmup_ratio"] = warmup_ratio_val + logger.info(f"Using warmup_ratio: {warmup_ratio_val}\n") + elif warmup_steps_val is not None: + config_args["warmup_steps"] = warmup_steps_val + logger.info(f"Using warmup_steps: {warmup_steps_val}\n") + else: + # Default to warmup_steps if neither provided + config_args["warmup_steps"] = 5 + logger.info(f"Using default warmup_steps: 5\n") + + # Add save_steps if specified + save_steps_val = training_args.get("save_steps", 0) + if save_steps_val and save_steps_val > 0: + config_args["save_steps"] = save_steps_val + config_args["save_strategy"] = "steps" + + # If max_steps is specified, use it instead of epochs + max_steps_val = training_args.get("max_steps", 0) + if max_steps_val and max_steps_val > 0: + del config_args["num_train_epochs"] # Remove epochs + config_args["max_steps"] = max_steps_val # Use steps instead + logger.info(f"Training for {max_steps_val} steps\n") + else: + logger.info(f"Training for {config_args['num_train_epochs']} epochs\n") + + # ========== EVAL CONFIGURATION ========== + eval_dataset = training_args.get("eval_dataset", None) + eval_steps_val = training_args.get("eval_steps", 0.00) + if eval_dataset is not None: + if eval_steps_val > 0: + config_args["eval_strategy"] = "steps" + config_args["eval_steps"] = eval_steps_val + logger.info( + f"✅ Evaluation enabled: eval_steps={eval_steps_val} (fraction of total steps)\n" + ) + logger.info(f"Eval dataset: {len(eval_dataset)} rows\n") + else: + logger.info( + f"⚠️ Eval dataset provided but eval_steps={eval_steps_val} (disabled)\n" + ) + logger.info("To enable evaluation, set eval_steps > 0.0\n") + else: + logger.info("No eval dataset — evaluation disabled\n") + + # Add model-specific parameters + # Use optim and lr_scheduler_type from training_args if provided, otherwise use defaults + optim_value = training_args.get("optim", "adamw_8bit") + lr_scheduler_type_value = training_args.get("lr_scheduler_type", "linear") + + if self.is_vlm or self.is_audio_vlm: + # Vision / audio VLM config (both need skip_prepare_dataset + remove_unused_columns) + label = "audio VLM" if self.is_audio_vlm else "vision" + logger.info(f"Configuring {label} model training parameters\n") + # Use provided values or defaults for vision models + optim_value = training_args.get("optim", "adamw_torch_fused") + lr_scheduler_type_value = training_args.get( + "lr_scheduler_type", "cosine" + ) + config_args.update( + { + "optim": optim_value, + "lr_scheduler_type": lr_scheduler_type_value, + "gradient_checkpointing": True, + "gradient_checkpointing_kwargs": {"use_reentrant": False}, + "max_grad_norm": 0.3, + "remove_unused_columns": False, + "dataset_text_field": "", + "dataset_kwargs": {"skip_prepare_dataset": True}, + "max_length": training_args.get("max_seq_length", 2048), + } + ) + else: + logger.info("Configuring text model training parameters\n") + config_args.update( + { + "optim": optim_value, + "lr_scheduler_type": lr_scheduler_type_value, + "dataset_text_field": "text", + } + ) + + # Only add packing for text models (not DeepSeek OCR which is VLM) + if not is_deepseek_ocr: + packing_enabled = training_args.get("packing", False) + config_args["packing"] = packing_enabled + logger.info( + f"Sequence packing: {'enabled' if packing_enabled else 'disabled'}\n" + ) + + # Audio codec overrides — BiCodec/DAC use the text SFTTrainer path + if self._audio_type == "bicodec": + config_args["packing"] = False + logger.info("Applied BiCodec overrides: packing=False\n") + elif self._audio_type == "dac": + config_args["packing"] = False + logger.info("Applied DAC overrides: packing=False\n") + + logger.info(f"The configuration is: {config_args}") + + logger.info("Training configuration prepared\n") + # ========== TRAINER INITIALIZATION ========== + if self.is_audio_vlm: + # Audio VLM (e.g. Gemma 3N + audio): raw Dataset from _format_audio_vlm_dataset + # Notebook uses processing_class=processor.tokenizer (text tokenizer only) + train_dataset = ( + dataset if isinstance(dataset, Dataset) else dataset["dataset"] + ) + processing_class = ( + self.tokenizer.tokenizer + if hasattr(self.tokenizer, "tokenizer") + else self.tokenizer + ) + trainer_kwargs = { + "model": self.model, + "train_dataset": train_dataset, + "processing_class": processing_class, + "data_collator": data_collator, + "args": SFTConfig(**config_args), + } + if eval_dataset is not None: + trainer_kwargs["eval_dataset"] = eval_dataset + self.trainer = SFTTrainer(**trainer_kwargs) + elif self.is_vlm: + # Image VLM: dataset is dict wrapper from format_and_template_dataset + train_dataset = ( + dataset["dataset"] if isinstance(dataset, dict) else dataset + ) + trainer_kwargs = { + "model": self.model, + "train_dataset": train_dataset, + "processing_class": self.tokenizer, + "data_collator": data_collator, + "args": SFTConfig(**config_args), + } + if eval_dataset is not None: + trainer_kwargs["eval_dataset"] = eval_dataset + self.trainer = SFTTrainer(**trainer_kwargs) + else: + # For text-only training, if the tokenizer is actually a Processor + # (e.g., Gemma-3 returns a ProcessorMixin even for text), we must + # unwrap to the raw tokenizer. Otherwise Unsloth's SFTTrainer detects + # ProcessorMixin → sets _is_vlm=True → skips _prepare_dataset entirely, + # and the 'text' column never gets tokenized to 'input_ids'. + from transformers import ProcessorMixin + + sft_tokenizer = self.tokenizer + if isinstance(self.tokenizer, ProcessorMixin) and hasattr( + self.tokenizer, "tokenizer" + ): + logger.info( + f" ⚠️ Unwrapping Processor → raw tokenizer for text-only SFTTrainer" + ) + sft_tokenizer = self.tokenizer.tokenizer + + trainer_kwargs = { + "model": self.model, + "tokenizer": sft_tokenizer, + "train_dataset": dataset["dataset"], + "data_collator": data_collator, + "args": SFTConfig(**config_args), + } + if eval_dataset is not None: + trainer_kwargs["eval_dataset"] = eval_dataset + self.trainer = SFTTrainer(**trainer_kwargs) + # Restore the full processor as processing_class so checkpoint + # saves include preprocessor_config.json (needed for GGUF export). + if sft_tokenizer is not self.tokenizer: + self.trainer.processing_class = self.tokenizer + logger.info("Trainer initialized\n") + + # ========== TRAIN ON RESPONSES ONLY ========== + # Determine if we should train on responses only + instruction_part = None + response_part = None + train_on_responses_enabled = training_args.get( + "train_on_completions", False + ) + + # DeepSeek OCR handles this internally in its collator, so skip + # Audio VLM handles label masking in its collator, so skip + if ( + train_on_responses_enabled + and not self.is_audio_vlm + and not self.is_audio + and not (is_deepseek_ocr or dataset["final_format"].lower() == "alpaca") + ): + try: + logger.info("Configuring train on responses only...\n") + + # Get the template mapping for this model + model_name_lower = self.model_name.lower() + + if model_name_lower in MODEL_TO_TEMPLATE_MAPPER: + template_name = MODEL_TO_TEMPLATE_MAPPER[model_name_lower] + logger.info(f"Detected template: {template_name}\n") + + if template_name in TEMPLATE_TO_RESPONSES_MAPPER: + instruction_part = TEMPLATE_TO_RESPONSES_MAPPER[ + template_name + ]["instruction"] + response_part = TEMPLATE_TO_RESPONSES_MAPPER[template_name][ + "response" + ] + + logger.info( + f"Instruction marker: {instruction_part[:50]}...\n" + ) + logger.info(f"Response marker: {response_part[:50]}...\n") + else: + logger.info( + f"No response mapping found for template: {template_name}\n" + ) + train_on_responses_enabled = False + else: + logger.info( + f"No template mapping found for model: {self.model_name}\n" + ) + train_on_responses_enabled = False + + except Exception as e: + logger.warning(f"Could not configure train on responses: {e}") + train_on_responses_enabled = False + + # Apply train on responses only if we have valid parts + if ( + train_on_responses_enabled + and instruction_part + and response_part + and not self.is_audio_vlm + and not self.is_audio + and not (is_deepseek_ocr or dataset["final_format"].lower() == "alpaca") + ): + try: + from unsloth.chat_templates import train_on_responses_only + + self.trainer = train_on_responses_only( + self.trainer, + instruction_part = instruction_part, + response_part = response_part, + num_proc = config_args["dataset_num_proc"], + ) + logger.info("Train on responses only configured successfully\n") + + # ── Safety net: check if all samples were filtered out ── + # Unsloth's train_on_responses_only masks non-response + # tokens with -100. If max_seq_length is too short and the + # response portion gets truncated away, EVERY sample ends + # up with all labels == -100 and Unsloth removes them, + # leaving 0 usable training samples. + filtered_len = len(self.trainer.train_dataset) + original_len = len(dataset["dataset"]) + dropped = original_len - filtered_len + drop_pct = ( + round(100 * dropped / original_len, 1) + if original_len > 0 + else 0 + ) + + if filtered_len == 0 or drop_pct > 30: + max_seq = training_args.get("max_seq_length", 2048) + error_msg = ( + f"{dropped}/{original_len} samples ({drop_pct}%) " + f"were dropped after applying 'train on responses " + f"only' — only {filtered_len} remain. This usually " + f"means max_seq_length ({max_seq}) is too short " + f"and the response portion is being truncated " + f"away. Try increasing max_seq_length (e.g. 8192) " + f"or disabling 'Train on completions'." + ) + logger.error(error_msg) + self._update_progress(error = error_msg, is_training = False) + return + + if dropped > 0: + logger.info( + f"⚠️ {dropped}/{original_len} samples " + f"({drop_pct}%) were dropped (all labels " + f"masked). {filtered_len} samples remain.\n" + ) + logger.info(f"Post-filter dataset size: {filtered_len} samples\n") + + # [DEBUG] Decode first sample AFTER train_on_completions applied + try: + _row = self.trainer.train_dataset[0] + _space = self.tokenizer( + " ", add_special_tokens = False + ).input_ids[0] + print("[DEBUG] === After train_on_completions ===", flush = True) + print( + f"[DEBUG] input_ids decoded:\n{self.tokenizer.decode(_row['input_ids'])}\n", + flush = True, + ) + print( + f"[DEBUG] labels decoded (-100 → space):\n{self.tokenizer.decode([_space if x == -100 else x for x in _row['labels']])}\n", + flush = True, + ) + except Exception as _dbg_e: + print( + f"[DEBUG] Could not decode post-completions sample: {_dbg_e}", + flush = True, + ) + + except Exception as e: + logger.warning(f"Failed to apply train on responses only: {e}") + train_on_responses_enabled = False + else: + if train_on_responses_enabled and is_deepseek_ocr: + logger.info("Train on responses handled by DeepSeek OCR collator\n") + else: + logger.info("Training on full sequences (including prompts)\n") + + # ========== PROGRESS TRACKING ========== + self.trainer.add_callback(self._create_progress_callback()) + + num_samples = len( + dataset["dataset"] if isinstance(dataset, dict) else dataset + ) + batch_size = training_args.get("batch_size", 2) + total_steps = self._calculate_total_steps( + num_samples, + batch_size, + training_args.get("gradient_accumulation_steps", 4), + training_args.get("num_epochs", 3), + training_args.get("max_steps", 0), + ) + self._update_progress(total_steps = total_steps) + + # ========== START TRAINING ========== + self._update_progress(status_message = "Starting training...") + logger.info("Starting training...\n") + self.trainer.train() + + # ========== SAVE MODEL ========== + self._finalize_training(output_dir) + + except Exception as e: + import traceback + + logger.error(f"Training error: {e}") + logger.error(f"Full traceback:\n{traceback.format_exc()}") + self._update_progress(is_training = False, error = str(e)) + + finally: + self.is_training = False + + def _patch_adapter_config(self, output_dir: str) -> None: + """Patch adapter_config.json with unsloth_training_method. + + Values: 'qlora', 'lora', 'FT', 'CPT', 'DPO', 'GRPO', etc. + For LoRA/QLoRA, the distinction comes from load_in_4bit. + """ + config_path = os.path.join(output_dir, "adapter_config.json") + if not os.path.exists(config_path): + logger.info("No adapter_config.json found — skipping training method patch") + return + + try: + with open(config_path, "r") as f: + config = json.load(f) + + # Determine the training method + if self.load_in_4bit: + method = "qlora" + else: + method = "lora" + + config["unsloth_training_method"] = method + logger.info( + f"Patching adapter_config.json with unsloth_training_method='{method}'" + ) + + with open(config_path, "w") as f: + json.dump(config, f, indent = 2) + + except Exception as e: + logger.warning(f"Failed to patch adapter_config.json: {e}") + + def stop_training(self, save: bool = True): + """Stop ongoing training""" + logger.info(f"\nStopping training (save={save})...") + self.should_stop = True + self.save_on_stop = save + stop_msg = ( + "Stopping training and saving checkpoint..." + if save + else "Cancelling training..." + ) + self._update_progress(status_message = stop_msg) + + # If trainer exists, try to stop it gracefully + if self.trainer: + try: + # The callback will catch should_stop flag and stop the training loop + logger.info("Training will stop at next step...\n") + except Exception as e: + logger.error(f"Error stopping trainer: {e}") + + def get_training_progress(self) -> TrainingProgress: + """Get current training progress""" + with self._lock: + return self.training_progress + + def cleanup(self): + """Cleanup resources""" + if self.trainer: + self.trainer = None + if self.model: + self.model = None + if self.tokenizer: + self.tokenizer = None + + # Clear GPU memory + clear_gpu_cache() + + +def _ensure_deepseek_ocr_installed(): + """ + Auto-install DeepSeek OCR module if not available. + Downloads from HuggingFace hub as a local module. + + Returns: + bool: True if available (either already installed or just installed) + """ + try: + # Try importing to see if already available + from deepseek_ocr.modeling_deepseekocr import format_messages + + logger.info("DeepSeek OCR module already available") + return True + except ImportError: + pass + + try: + logger.info( + "DeepSeek OCR module not found. Auto-installing from HuggingFace..." + ) + logger.info("\n Downloading DeepSeek OCR module from HuggingFace...\n") + + from huggingface_hub import snapshot_download + import sys + import os + + # Get the script directory to install locally + script_dir = os.path.dirname(os.path.abspath(__file__)) + parent_dir = os.path.dirname(script_dir) # Go up to project root + + # Download to project root as 'deepseek_ocr' folder + local_dir = os.path.join(parent_dir, "deepseek_ocr") + + snapshot_download( + "unsloth/DeepSeek-OCR", local_dir = local_dir, local_dir_use_symlinks = False + ) + + # Add to sys.path if not already there + if parent_dir not in sys.path: + sys.path.insert(0, parent_dir) + + # Try importing again + from deepseek_ocr.modeling_deepseekocr import format_messages + + logger.info("DeepSeek OCR module installed successfully") + logger.info("DeepSeek OCR module installed successfully!\n") + return True + + except Exception as e: + logger.error(f"Failed to install DeepSeek OCR module: {e}") + logger.info(f"\n❌ Failed to install DeepSeek OCR module: {e}\n") + return False + + +# Global trainer instance +_trainer_instance = None + + +def get_trainer() -> UnslothTrainer: + """Get global trainer instance""" + global _trainer_instance + if _trainer_instance is None: + _trainer_instance = UnslothTrainer() + return _trainer_instance diff --git a/studio/backend/core/training/training.py b/studio/backend/core/training/training.py new file mode 100644 index 0000000000..96a92af942 --- /dev/null +++ b/studio/backend/core/training/training.py @@ -0,0 +1,628 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Training backend — subprocess orchestrator. + +Each training job runs in a fresh subprocess (mp.get_context("spawn")), +solving the transformers version-switching problem. The old in-process +UnslothTrainer singleton is only used inside the subprocess (worker.py). + +This file orchestrates the subprocess lifecycle, pumps events from the +worker's mp.Queue, and exposes the same API surface to routes/training.py. + +Pattern follows core/data_recipe/jobs/manager.py. +""" + +import math +import multiprocessing as mp +import queue +import threading +import time +import structlog +from loggers import get_logger +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional, Tuple, Any + +import matplotlib.pyplot as plt + +logger = get_logger(__name__) + +_CTX = mp.get_context("spawn") + +# Plot styling constants +PLOT_WIDTH = 8 +PLOT_HEIGHT = 3.5 + + +@dataclass +class TrainingProgress: + """Mirror of trainer.TrainingProgress — kept here so the parent process + never needs to import the heavy ML modules.""" + + epoch: float = 0 + step: int = 0 + total_steps: int = 0 + loss: float = 0.0 + learning_rate: float = 0.0 + is_training: bool = False + is_completed: bool = False + error: Optional[str] = None + status_message: str = "Ready to train" + elapsed_seconds: Optional[float] = None + eta_seconds: Optional[float] = None + grad_norm: Optional[float] = None + num_tokens: Optional[int] = None + eval_loss: Optional[float] = None + + +class TrainingBackend: + """ + Training orchestration backend — subprocess-based. + Launches a fresh subprocess per training job, communicates via mp.Queue. + """ + + def __init__(self): + # Subprocess state + self._proc: Optional[mp.Process] = None + self._event_queue: Any = None + self._stop_queue: Any = None + self._pump_thread: Optional[threading.Thread] = None + self._lock = threading.Lock() + + # Progress state (updated by pump thread from subprocess events) + self._progress = TrainingProgress() + self._should_stop = False + self._cancel_requested = False # True only for stop(save=False) + + # Training Metrics (consumed by routes for SSE and /metrics) + self.loss_history: list = [] + self.lr_history: list = [] + self.step_history: list = [] + self.grad_norm_history: list = [] + self.grad_norm_step_history: list = [] + self.eval_loss_history: list = [] + self.eval_step_history: list = [] + self.eval_enabled: bool = False + self.current_theme: str = "light" + + # Job metadata + self.current_job_id: Optional[str] = None + self._output_dir: Optional[str] = None + + logger.info("TrainingBackend initialized (subprocess mode)") + + # ------------------------------------------------------------------ + # Public API (called by routes/training.py) + # ------------------------------------------------------------------ + + def start_training(self, **kwargs) -> bool: + """Spawn a subprocess to run the full training pipeline. + + All kwargs are serialized into a config dict and sent to the worker. + Returns True if the subprocess was started successfully. + """ + with self._lock: + if self._proc is not None and self._proc.is_alive(): + logger.warning("Training subprocess already running") + return False + + # Join prior pump thread to prevent it from consuming events + # from the new job's queue (it reads self._event_queue dynamically). + if self._pump_thread is not None and self._pump_thread.is_alive(): + self._pump_thread.join(timeout = 5.0) + if self._pump_thread.is_alive(): + logger.warning("Previous pump thread did not exit within 5s") + self._pump_thread = None + + # Reset state + self._should_stop = False + self._cancel_requested = False + self._progress = TrainingProgress( + is_training = True, status_message = "Initializing training..." + ) + self.loss_history.clear() + self.lr_history.clear() + self.step_history.clear() + self.grad_norm_history.clear() + self.grad_norm_step_history.clear() + self.eval_loss_history.clear() + self.eval_step_history.clear() + self.eval_enabled = False + self._output_dir = None + + # Build config dict for the subprocess + config = { + "model_name": kwargs["model_name"], + "training_type": kwargs.get("training_type", "LoRA/QLoRA"), + "hf_token": kwargs.get("hf_token", ""), + "load_in_4bit": kwargs.get("load_in_4bit", True), + "max_seq_length": kwargs.get("max_seq_length", 2048), + "hf_dataset": kwargs.get("hf_dataset", ""), + "local_datasets": kwargs.get("local_datasets"), + "format_type": kwargs.get("format_type", ""), + "subset": kwargs.get("subset"), + "train_split": kwargs.get("train_split", "train"), + "eval_split": kwargs.get("eval_split"), + "eval_steps": kwargs.get("eval_steps", 0.00), + "dataset_slice_start": kwargs.get("dataset_slice_start"), + "dataset_slice_end": kwargs.get("dataset_slice_end"), + "custom_format_mapping": kwargs.get("custom_format_mapping"), + "is_dataset_image": kwargs.get("is_dataset_image", False), + "is_dataset_audio": kwargs.get("is_dataset_audio", False), + "is_embedding": kwargs.get("is_embedding", False), + "num_epochs": kwargs.get("num_epochs", 3), + "learning_rate": kwargs.get("learning_rate", "2e-4"), + "batch_size": kwargs.get("batch_size", 2), + "gradient_accumulation_steps": kwargs.get("gradient_accumulation_steps", 4), + "warmup_steps": kwargs.get("warmup_steps"), + "warmup_ratio": kwargs.get("warmup_ratio"), + "max_steps": kwargs.get("max_steps", 0), + "save_steps": kwargs.get("save_steps", 0), + "weight_decay": kwargs.get("weight_decay", 0.01), + "random_seed": kwargs.get("random_seed", 3407), + "packing": kwargs.get("packing", False), + "optim": kwargs.get("optim", "adamw_8bit"), + "lr_scheduler_type": kwargs.get("lr_scheduler_type", "linear"), + "use_lora": kwargs.get("use_lora", True), + "lora_r": kwargs.get("lora_r", 16), + "lora_alpha": kwargs.get("lora_alpha", 16), + "lora_dropout": kwargs.get("lora_dropout", 0.0), + "target_modules": kwargs.get("target_modules"), + "gradient_checkpointing": kwargs.get("gradient_checkpointing", "unsloth"), + "use_rslora": kwargs.get("use_rslora", False), + "use_loftq": kwargs.get("use_loftq", False), + "train_on_completions": kwargs.get("train_on_completions", False), + "finetune_vision_layers": kwargs.get("finetune_vision_layers", True), + "finetune_language_layers": kwargs.get("finetune_language_layers", True), + "finetune_attention_modules": kwargs.get( + "finetune_attention_modules", True + ), + "finetune_mlp_modules": kwargs.get("finetune_mlp_modules", True), + "enable_wandb": kwargs.get("enable_wandb", False), + "wandb_token": kwargs.get("wandb_token"), + "wandb_project": kwargs.get("wandb_project", "unsloth-training"), + "enable_tensorboard": kwargs.get("enable_tensorboard", False), + "tensorboard_dir": kwargs.get("tensorboard_dir", "runs"), + "trust_remote_code": kwargs.get("trust_remote_code", False), + } + + # Derive load_in_4bit from training_type + if config["training_type"] != "LoRA/QLoRA": + config["load_in_4bit"] = False + + # Spawn subprocess + from .worker import run_training_process + + self._event_queue = _CTX.Queue() + self._stop_queue = _CTX.Queue() + + self._proc = _CTX.Process( + target = run_training_process, + kwargs = { + "event_queue": self._event_queue, + "stop_queue": self._stop_queue, + "config": config, + }, + daemon = True, + ) + self._proc.start() + logger.info("Training subprocess started (pid=%s)", self._proc.pid) + + # Start event pump thread + self._pump_thread = threading.Thread(target = self._pump_loop, daemon = True) + self._pump_thread.start() + + return True + + def stop_training(self, save: bool = True) -> bool: + """Send stop signal to the training subprocess.""" + self._should_stop = True + if not save: + self._cancel_requested = True + with self._lock: + if self._stop_queue is not None: + try: + self._stop_queue.put({"type": "stop", "save": save}) + except (OSError, ValueError): + pass + # Update progress immediately for responsive UI + self._progress.status_message = ( + "Stopping training and saving checkpoint..." + if save + else "Cancelling training..." + ) + return True + + def force_terminate(self) -> None: + """Force-kill the training subprocess so state can be reset immediately.""" + with self._lock: + if self._proc is not None and self._proc.is_alive(): + logger.info( + "Force-terminating training subprocess (pid=%s)", self._proc.pid + ) + self._proc.terminate() + proc = self._proc + + if proc is not None: + proc.join(timeout = 5.0) + if proc.is_alive(): + proc.kill() + proc.join(timeout = 2.0) + + def is_training_active(self) -> bool: + """Check if training is currently active.""" + with self._lock: + # Subprocess alive = active + if self._proc is not None and self._proc.is_alive(): + return True + + # Stop was requested and process exited → inactive + if self._should_stop: + return False + + # Check progress state + p = self._progress + if p.is_training: + return True + if p.is_completed or p.error: + return False + + # Check status message for activity indicators + status_lower = (p.status_message or "").lower() + if any( + k in status_lower + for k in [ + "cancelled", + "canceled", + "stopped", + "completed", + "ready to train", + ] + ): + return False + if any( + k in status_lower + for k in [ + "loading", + "preparing", + "training", + "configuring", + "tokenizing", + "starting", + "importing", + ] + ): + return True + + return False + + def get_training_status(self, theme: str = "light") -> Tuple: + """Get current training status and loss plot.""" + with self._lock: + progress = self._progress + + if not (progress.is_training or progress.is_completed or progress.error): + return (None, progress) + + plot = self._create_loss_plot(progress, theme) + return (plot, progress) + + def refresh_plot_for_theme(self, theme: str) -> Optional[plt.Figure]: + """Refresh plot with new theme.""" + if theme and isinstance(theme, str) and theme in ["light", "dark"]: + self.current_theme = theme + if self.loss_history: + with self._lock: + progress = self._progress + return self._create_loss_plot(progress, self.current_theme) + return None + + # ------------------------------------------------------------------ + # Compatibility shims — routes/training.py accesses these + # ------------------------------------------------------------------ + + class _TrainerShim: + """Minimal shim so routes that access backend.trainer.* still work.""" + + def __init__(self, backend: "TrainingBackend"): + self._backend = backend + self.should_stop = False + + @property + def training_progress(self): + return self._backend._progress + + @training_progress.setter + def training_progress(self, value): + self._backend._progress = value + + def get_training_progress(self): + return self._backend._progress + + def _update_progress(self, **kwargs): + with self._backend._lock: + for key, value in kwargs.items(): + if hasattr(self._backend._progress, key): + setattr(self._backend._progress, key, value) + + @property + def trainer(self): + """Compatibility shim for routes that access backend.trainer.*""" + return self._TrainerShim(self) + + # ------------------------------------------------------------------ + # Event pump (background thread) + # ------------------------------------------------------------------ + + def _pump_loop(self) -> None: + """Background thread: consume events from subprocess → update state.""" + while True: + if self._proc is None or self._event_queue is None: + return + + # Try to read an event + event = self._read_queue(self._event_queue, timeout_sec = 0.25) + if event is not None: + self._handle_event(event) + continue + + # No event — check if process is still alive + if self._proc.is_alive(): + continue + + # Process exited — drain remaining events + for e in self._drain_queue(self._event_queue): + self._handle_event(e) + + # Mark as done if no explicit complete/error was received + with self._lock: + if self._progress.is_training: + if self._should_stop: + self._progress.is_training = False + self._progress.status_message = "Training stopped." + else: + self._progress.is_training = False + self._progress.error = ( + self._progress.error + or "Training process exited unexpectedly" + ) + return + + def _handle_event(self, event: dict) -> None: + """Apply a subprocess event to local state.""" + etype = event.get("type") + + with self._lock: + if etype == "progress": + self._progress.step = event.get("step", self._progress.step) + self._progress.epoch = event.get("epoch", self._progress.epoch) + self._progress.loss = event.get("loss", self._progress.loss) + self._progress.learning_rate = event.get( + "learning_rate", self._progress.learning_rate + ) + self._progress.total_steps = event.get( + "total_steps", self._progress.total_steps + ) + self._progress.elapsed_seconds = event.get("elapsed_seconds") + self._progress.eta_seconds = event.get("eta_seconds") + self._progress.grad_norm = event.get("grad_norm") + self._progress.num_tokens = event.get("num_tokens") + self._progress.eval_loss = event.get("eval_loss") + self._progress.is_training = True + status = event.get("status_message", "") + if status: + self._progress.status_message = status + + # Update metric histories + step = event.get("step", 0) + loss = event.get("loss", 0.0) + lr = event.get("learning_rate", 0.0) + if step >= 0 and loss > 0: + self.loss_history.append(loss) + self.lr_history.append(lr) + self.step_history.append(step) + + grad_norm = event.get("grad_norm") + if grad_norm is not None: + try: + gn = float(grad_norm) + except (TypeError, ValueError): + gn = None + if gn is not None and math.isfinite(gn): + self.grad_norm_history.append(gn) + self.grad_norm_step_history.append(step) + + eval_loss = event.get("eval_loss") + if eval_loss is not None: + self.eval_loss_history.append(eval_loss) + self.eval_step_history.append(step) + self.eval_enabled = True + + elif etype == "eval_configured": + self.eval_enabled = True + + elif etype == "status": + self._progress.status_message = event.get("message", "") + self._progress.is_training = True + + elif etype == "complete": + self._progress.is_training = False + self._progress.is_completed = True + self._output_dir = event.get("output_dir") + msg = event.get("status_message", "Training completed") + self._progress.status_message = msg + + elif etype == "error": + self._progress.is_training = False + self._progress.error = event.get("error", "Unknown error") + logger.error("Training error: %s", event.get("error")) + stack = event.get("stack", "") + if stack: + logger.error("Stack trace:\n%s", stack) + + @staticmethod + def _read_queue(q: Any, timeout_sec: float) -> Optional[dict]: + try: + return q.get(timeout = timeout_sec) + except queue.Empty: + return None + except (EOFError, OSError, ValueError): + return None + + @staticmethod + def _drain_queue(q: Any) -> list: + events = [] + while True: + try: + events.append(q.get_nowait()) + except queue.Empty: + return events + except (EOFError, OSError, ValueError): + return events + + # ------------------------------------------------------------------ + # Plot generation (unchanged from original) + # ------------------------------------------------------------------ + + def _create_loss_plot( + self, progress: TrainingProgress, theme: str = "light" + ) -> plt.Figure: + """Create training loss plot with theme-aware styling.""" + plt.close("all") + + LIGHT_STYLE = { + "facecolor": "#ffffff", + "grid_color": "#d1d5db", + "line": "#16b88a", + "text": "#1f2937", + "empty_text": "#6b7280", + } + DARK_STYLE = { + "facecolor": "#292929", + "grid_color": "#404040", + "line": "#4ade80", + "text": "#e5e7eb", + "empty_text": "#9ca3af", + } + + style = LIGHT_STYLE if theme == "light" else DARK_STYLE + + fig, ax = plt.subplots(figsize = (PLOT_WIDTH, PLOT_HEIGHT)) + fig.patch.set_facecolor(style["facecolor"]) + ax.set_facecolor(style["facecolor"]) + + if self.loss_history: + steps = self.step_history + losses = self.loss_history + scatter_color = "#60a5fa" + ax.scatter( + steps, + losses, + s = 16, + alpha = 0.6, + color = scatter_color, + linewidths = 0, + label = "Training Loss (raw)", + ) + + MA_WINDOW = 20 + window = min(MA_WINDOW, len(losses)) + + if window >= 2: + cumsum = [0.0] + for v in losses: + cumsum.append(cumsum[-1] + float(v)) + + ma = [] + for i in range(len(losses)): + start = max(0, i - window + 1) + denom = i - start + 1 + ma.append((cumsum[i + 1] - cumsum[start]) / denom) + + ax.plot( + steps, + ma, + color = style["line"], + linewidth = 2.5, + alpha = 0.95, + label = f"Moving Avg ({ma[-1]:.4f})", + ) + + leg = ax.legend(frameon = False, fontsize = 9) + for t in leg.get_texts(): + t.set_color(style["text"]) + + ax.set_xlabel("Steps", fontsize = 10, color = style["text"]) + ax.set_ylabel("Loss", fontsize = 10, color = style["text"]) + + if progress.error: + title = f"Error: {progress.error}" + elif progress.is_completed: + title = f"Training completed! Final loss: {progress.loss:.4f}" + elif progress.status_message: + title = progress.status_message + elif progress.step > 0: + title = f"Epoch: {progress.epoch} | Step: {progress.step}/{progress.total_steps} | Loss: {progress.loss:.4f}" + else: + title = "Training Loss" + + ax.set_title( + title, fontsize = 11, fontweight = "bold", pad = 10, color = style["text"] + ) + ax.grid(True, alpha = 0.4, linestyle = "--", color = style["grid_color"]) + ax.tick_params(colors = style["text"], which = "both") + ax.spines["top"].set_visible(False) + ax.spines["right"].set_visible(False) + ax.spines["bottom"].set_color(style["text"]) + ax.spines["left"].set_color(style["text"]) + else: + display_msg = ( + progress.status_message + if progress.status_message + else "Waiting for training data..." + ) + ax.text( + 0.5, + 0.5, + display_msg, + ha = "center", + va = "center", + fontsize = 16, + color = style["empty_text"], + transform = ax.transAxes, + ) + ax.set_xticks([]) + ax.set_yticks([]) + for spine in ax.spines.values(): + spine.set_visible(False) + + fig.tight_layout() + return fig + + def _transfer_to_inference_backend(self) -> bool: + """Transfer model to inference backend. + + With subprocess-based training, the model lives in the subprocess + and is freed when it exits. Inference must load from the saved + checkpoint on disk. This is a no-op placeholder. + """ + logger.info( + "_transfer_to_inference_backend: subprocess training — " + "model must be loaded from disk (output_dir=%s)", + self._output_dir, + ) + return False + + +# ========== GLOBAL INSTANCE ========== +_training_backend = None + + +def get_training_backend() -> TrainingBackend: + """Get global training backend instance""" + global _training_backend + if _training_backend is None: + _training_backend = TrainingBackend() + return _training_backend diff --git a/studio/backend/core/training/worker.py b/studio/backend/core/training/worker.py new file mode 100644 index 0000000000..b5cf984bf9 --- /dev/null +++ b/studio/backend/core/training/worker.py @@ -0,0 +1,940 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Training subprocess entry point. + +Each training job runs in a fresh subprocess (mp.get_context("spawn")). +This gives us a clean Python interpreter with no stale module state — +solving the transformers version-switching problem completely. + +Pattern follows core/data_recipe/jobs/worker.py. +""" + +from __future__ import annotations + +import structlog +from loggers import get_logger +import os +import sys +import time +import traceback +from pathlib import Path +from typing import Any + +logger = get_logger(__name__) + + +def _activate_transformers_version(model_name: str) -> None: + """Activate the correct transformers version BEFORE any ML imports. + + If the model needs transformers 5.x, prepend the pre-installed .venv_t5/ + directory to sys.path. Otherwise do nothing (default 4.57.x in .venv/). + """ + # Ensure backend is on path for utils imports + backend_path = str(Path(__file__).resolve().parent.parent.parent) + if backend_path not in sys.path: + sys.path.insert(0, backend_path) + + from utils.transformers_version import needs_transformers_5, _resolve_base_model + + resolved = _resolve_base_model(model_name) + if needs_transformers_5(resolved): + venv_t5 = os.path.join( + os.path.expanduser("~"), ".unsloth", "studio", ".venv_t5" + ) + if os.path.isdir(venv_t5): + sys.path.insert(0, venv_t5) + logger.info("Activated transformers 5.x from %s", venv_t5) + else: + # Fallback: pip install at runtime (slower, ~10-15s) + logger.warning(".venv_t5 not found at %s — installing at runtime", venv_t5) + import subprocess as sp + + os.makedirs(venv_t5, exist_ok = True) + r1 = sp.run( + [ + sys.executable, + "-m", + "pip", + "install", + "--target", + venv_t5, + "--no-deps", + "transformers==5.2.0", + ], + stdout = sp.PIPE, + stderr = sp.STDOUT, + ) + r2 = sp.run( + [ + sys.executable, + "-m", + "pip", + "install", + "--target", + venv_t5, + "--no-deps", + "huggingface_hub==1.3.0", + ], + stdout = sp.PIPE, + stderr = sp.STDOUT, + ) + if r1.returncode != 0 or r2.returncode != 0: + raise RuntimeError( + f"Failed to install transformers 5.x into {venv_t5}. " + f"pip returncode: transformers={r1.returncode}, huggingface_hub={r2.returncode}" + ) + sys.path.insert(0, venv_t5) + # Propagate to child subprocesses (e.g. GGUF converter) + _pp = os.environ.get("PYTHONPATH", "") + os.environ["PYTHONPATH"] = venv_t5 + (os.pathsep + _pp if _pp else "") + else: + logger.info("Using default transformers (4.57.x) for %s", model_name) + + +def run_training_process( + *, + event_queue: Any, + stop_queue: Any, + config: dict, +) -> None: + """Subprocess entrypoint. Fresh Python — no stale module state. + + Args: + event_queue: mp.Queue for sending progress/status/error events to parent. + stop_queue: mp.Queue for receiving stop commands from parent. + config: Training configuration dict with all parameters. + """ + os.environ["TOKENIZERS_PARALLELISM"] = "false" + os.environ["PYTHONWARNINGS"] = ( + "ignore" # Suppress warnings at C-level before imports + ) + + import warnings + from loggers.config import LogConfig + + if os.getenv("ENVIRONMENT_TYPE", "production") == "production": + warnings.filterwarnings("ignore") + + LogConfig.setup_logging( + service_name = "unsloth-studio-training-worker", + env = os.getenv("ENVIRONMENT_TYPE", "production"), + ) + + model_name = config["model_name"] + + # ── 1. Activate correct transformers version BEFORE any ML imports ── + try: + _activate_transformers_version(model_name) + except Exception as exc: + event_queue.put( + { + "type": "error", + "error": f"Failed to activate transformers version: {exc}", + "stack": traceback.format_exc(limit = 20), + "ts": time.time(), + } + ) + return + + # ── 1b. On Windows, check Triton availability (must be before import torch) ── + if sys.platform == "win32": + try: + import triton # noqa: F401 + + logger.info("Triton available — torch.compile enabled") + except ImportError: + os.environ["TORCHDYNAMO_DISABLE"] = "1" + logger.warning( + "Triton not found on Windows — torch.compile disabled. " + 'Install for better performance: pip install "triton-windows<3.7"' + ) + + # ── 2. Now import ML libraries (fresh in this clean process) ── + try: + _send_status(event_queue, "Importing ML libraries...") + + backend_path = str(Path(__file__).resolve().parent.parent.parent) + if backend_path not in sys.path: + sys.path.insert(0, backend_path) + + from core.training.trainer import UnslothTrainer, TrainingProgress + from utils.paths import ( + ensure_dir, + resolve_output_dir, + resolve_tensorboard_dir, + datasets_root, + ) + + import transformers + + logger.info("Subprocess loaded transformers %s", transformers.__version__) + except Exception as exc: + event_queue.put( + { + "type": "error", + "error": f"Failed to import ML libraries: {exc}", + "stack": traceback.format_exc(limit = 20), + "ts": time.time(), + } + ) + return + + # ── 2b. EMBEDDING MODEL FAST-PATH ── + # Embedding models use a completely different pipeline (FastSentenceTransformer + # + SentenceTransformerTrainer + MultipleNegativesRankingLoss) so we branch + # early and handle the entire flow in a self-contained function. + if config.get("is_embedding", False): + try: + _run_embedding_training(event_queue, stop_queue, config) + except Exception as exc: + event_queue.put( + { + "type": "error", + "error": str(exc), + "stack": traceback.format_exc(limit = 20), + "ts": time.time(), + } + ) + return + + # ── 3. Create a fresh trainer instance ── + trainer = UnslothTrainer() + + # Wire up progress callback → event_queue + def _on_progress(progress: TrainingProgress): + has_train_loss = progress.step >= 0 and progress.loss > 0 + has_eval_loss = progress.eval_loss is not None + if has_train_loss or has_eval_loss: + event_queue.put( + { + "type": "progress", + "step": progress.step, + "epoch": progress.epoch, + "loss": progress.loss, + "learning_rate": progress.learning_rate, + "total_steps": progress.total_steps, + "elapsed_seconds": progress.elapsed_seconds, + "eta_seconds": progress.eta_seconds, + "grad_norm": progress.grad_norm, + "num_tokens": progress.num_tokens, + "eval_loss": progress.eval_loss, + "status_message": progress.status_message, + "ts": time.time(), + } + ) + if progress.status_message: + _send_status(event_queue, progress.status_message) + + trainer.add_progress_callback(_on_progress) + + # Wire up stop_queue polling to trainer.should_stop + import threading + import queue as _queue + + def _poll_stop(): + while True: + try: + msg = stop_queue.get(timeout = 1.0) + if msg and msg.get("type") == "stop": + save = msg.get("save", True) + trainer.should_stop = True + trainer.save_on_stop = save + logger.info("Stop signal received (save=%s)", save) + return + except _queue.Empty: + continue + except (EOFError, OSError): + return + + stop_thread = threading.Thread(target = _poll_stop, daemon = True) + stop_thread.start() + + # ── 4. Execute the training pipeline ── + # Order: detect → dataset → model → prepare → train + # Dataset processing (including LLM-assisted detection) runs BEFORE model + # loading so both never occupy VRAM at the same time. + try: + hf_token = config.get("hf_token", "") + hf_token = hf_token if hf_token and hf_token.strip() else None + + # ── 4a. Lightweight detection + tokenizer (no VRAM) ── + _send_status(event_queue, "Detecting model type...") + trainer.pre_detect_and_load_tokenizer( + model_name = model_name, + max_seq_length = config["max_seq_length"], + hf_token = hf_token, + is_dataset_image = config.get("is_dataset_image", False), + is_dataset_audio = config.get("is_dataset_audio", False), + trust_remote_code = config.get("trust_remote_code", False), + ) + if trainer.should_stop: + event_queue.put({"type": "complete", "output_dir": None, "ts": time.time()}) + return + + # ── 4b. Load and format dataset (LLM helper may use VRAM briefly) ── + _send_status(event_queue, "Loading and formatting dataset...") + hf_dataset = config.get("hf_dataset", "") + dataset_result = trainer.load_and_format_dataset( + dataset_source = hf_dataset if hf_dataset and hf_dataset.strip() else None, + format_type = config.get("format_type", ""), + local_datasets = config.get("local_datasets") or None, + custom_format_mapping = config.get("custom_format_mapping"), + subset = config.get("subset"), + train_split = config.get("train_split", "train"), + eval_split = config.get("eval_split"), + eval_steps = config.get("eval_steps", 0.00), + dataset_slice_start = config.get("dataset_slice_start"), + dataset_slice_end = config.get("dataset_slice_end"), + ) + + if isinstance(dataset_result, tuple): + dataset, eval_dataset = dataset_result + else: + dataset = dataset_result + eval_dataset = None + + # [DEBUG] Print first sample before model is loaded + # dataset is a dict {"dataset": , "detected_format": ..., ...} + # or a raw Dataset for audio paths + try: + ds = dataset["dataset"] if isinstance(dataset, dict) else dataset + print( + f"\n[DEBUG] Dataset loaded BEFORE model. type={type(ds).__name__}, len={len(ds)}", + flush = True, + ) + print(f"[DEBUG] Columns: {ds.column_names}", flush = True) + sample = ds[0] + preview = {k: str(v)[:300] for k, v in sample.items()} + print(f"[DEBUG] First sample: {preview}\n", flush = True) + except Exception as e: + print( + f"[DEBUG] Could not preview first sample: {type(e).__name__}: {e}", + flush = True, + ) + + # Disable eval if eval_steps <= 0 + eval_steps = config.get("eval_steps", 0.00) + if eval_steps is not None and float(eval_steps) <= 0: + eval_dataset = None + + # Tell the parent process that eval is configured so the frontend + # shows "Waiting for first evaluation step..." instead of "not configured" + if eval_dataset is not None: + event_queue.put( + { + "type": "eval_configured", + "ts": time.time(), + } + ) + + if dataset is None or trainer.should_stop: + if trainer.should_stop: + event_queue.put( + {"type": "complete", "output_dir": None, "ts": time.time()} + ) + else: + event_queue.put( + { + "type": "error", + "error": trainer.training_progress.error + or "Failed to load dataset", + "stack": "", + "ts": time.time(), + } + ) + return + + # ── 4c. Load training model (uses VRAM — dataset already formatted) ── + _send_status(event_queue, "Loading model...") + success = trainer.load_model( + model_name = model_name, + max_seq_length = config["max_seq_length"], + load_in_4bit = config["load_in_4bit"], + hf_token = hf_token, + is_dataset_image = config.get("is_dataset_image", False), + is_dataset_audio = config.get("is_dataset_audio", False), + trust_remote_code = config.get("trust_remote_code", False), + ) + if not success or trainer.should_stop: + if trainer.should_stop: + event_queue.put( + {"type": "complete", "output_dir": None, "ts": time.time()} + ) + else: + error_msg = trainer.training_progress.error or "Failed to load model" + event_queue.put( + { + "type": "error", + "error": error_msg, + "stack": "", + "ts": time.time(), + } + ) + return + + # ── 4d. Prepare model (LoRA or full finetuning) ── + training_type = config.get("training_type", "LoRA/QLoRA") + use_lora = training_type == "LoRA/QLoRA" + if use_lora: + _send_status(event_queue, "Configuring LoRA adapters...") + success = trainer.prepare_model_for_training( + use_lora = True, + finetune_vision_layers = config.get("finetune_vision_layers", True), + finetune_language_layers = config.get("finetune_language_layers", True), + finetune_attention_modules = config.get( + "finetune_attention_modules", True + ), + finetune_mlp_modules = config.get("finetune_mlp_modules", True), + target_modules = config.get("target_modules"), + lora_r = config.get("lora_r", 16), + lora_alpha = config.get("lora_alpha", 16), + lora_dropout = config.get("lora_dropout", 0.0), + use_gradient_checkpointing = config.get( + "gradient_checkpointing", "unsloth" + ), + use_rslora = config.get("use_rslora", False), + use_loftq = config.get("use_loftq", False), + ) + else: + _send_status(event_queue, "Preparing model for full finetuning...") + success = trainer.prepare_model_for_training(use_lora = False) + + if not success or trainer.should_stop: + if trainer.should_stop: + event_queue.put( + {"type": "complete", "output_dir": None, "ts": time.time()} + ) + else: + event_queue.put( + { + "type": "error", + "error": trainer.training_progress.error + or "Failed to prepare model", + "stack": "", + "ts": time.time(), + } + ) + return + + # Convert learning rate + try: + lr_value = float(config.get("learning_rate", "2e-4")) + except ValueError: + event_queue.put( + { + "type": "error", + "error": f"Invalid learning rate: {config.get('learning_rate')}", + "stack": "", + "ts": time.time(), + } + ) + return + + # Generate output dir + output_dir = config.get("output_dir") + if not output_dir: + output_dir = f"{model_name.replace('/', '_')}_{int(time.time())}" + output_dir = str(resolve_output_dir(output_dir)) + ensure_dir(Path(output_dir)) + + tensorboard_dir = config.get("tensorboard_dir") + if config.get("enable_tensorboard", False): + tensorboard_dir = str(resolve_tensorboard_dir(tensorboard_dir)) + ensure_dir(Path(tensorboard_dir)) + + # Start training (directly — no inner thread, we ARE the subprocess) + _send_status(event_queue, "Starting training...") + max_steps = config.get("max_steps", 0) + save_steps = config.get("save_steps", 0) + + trainer._train_worker( + dataset, + output_dir = output_dir, + num_epochs = config.get("num_epochs", 3), + learning_rate = lr_value, + batch_size = config.get("batch_size", 2), + gradient_accumulation_steps = config.get("gradient_accumulation_steps", 4), + warmup_steps = config.get("warmup_steps"), + warmup_ratio = config.get("warmup_ratio"), + max_steps = max_steps if max_steps and max_steps > 0 else 0, + save_steps = save_steps if save_steps and save_steps > 0 else 0, + weight_decay = config.get("weight_decay", 0.01), + random_seed = config.get("random_seed", 3407), + packing = config.get("packing", False), + train_on_completions = config.get("train_on_completions", False), + enable_wandb = config.get("enable_wandb", False), + wandb_project = config.get("wandb_project", "unsloth-training"), + wandb_token = config.get("wandb_token"), + enable_tensorboard = config.get("enable_tensorboard", False), + tensorboard_dir = tensorboard_dir, + eval_dataset = eval_dataset, + eval_steps = eval_steps, + max_seq_length = config.get("max_seq_length", 2048), + optim = config.get("optim", "adamw_8bit"), + lr_scheduler_type = config.get("lr_scheduler_type", "linear"), + ) + + # Check final state + progress = trainer.get_training_progress() + if progress.error: + event_queue.put( + { + "type": "error", + "error": progress.error, + "stack": "", + "ts": time.time(), + } + ) + else: + event_queue.put( + { + "type": "complete", + "output_dir": output_dir, + "status_message": progress.status_message or "Training completed", + "ts": time.time(), + } + ) + + except Exception as exc: + event_queue.put( + { + "type": "error", + "error": str(exc), + "stack": traceback.format_exc(limit = 20), + "ts": time.time(), + } + ) + + +def _send_status(event_queue: Any, message: str) -> None: + """Send a status update to the parent process.""" + event_queue.put( + { + "type": "status", + "message": message, + "ts": time.time(), + } + ) + + +def _run_embedding_training(event_queue: Any, stop_queue: Any, config: dict) -> None: + """Self-contained embedding model training pipeline. + + Uses FastSentenceTransformer + SentenceTransformerTrainer + + MultipleNegativesRankingLoss — completely separate from the + LLM/VLM/audio paths in UnslothTrainer. + + Mirrors the pattern from the reference embedding notebooks: + All_MiniLM_L6_v2.py, BGE_M3.py, EmbeddingGemma_300M.py, + ModernBert.py, Qwen3_Embedding_0_6B.py + """ + import math + import queue as _queue + import threading + + model_name = config["model_name"] + training_start_time = time.time() + + # ── 1. Import embedding-specific libraries ── + _send_status(event_queue, "Importing embedding libraries...") + try: + from unsloth import FastSentenceTransformer, is_bfloat16_supported + from sentence_transformers import ( + SentenceTransformerTrainer, + SentenceTransformerTrainingArguments, + ) + from sentence_transformers.losses import MultipleNegativesRankingLoss + from sentence_transformers.training_args import BatchSamplers + from datasets import load_dataset, Dataset + from transformers import TrainerCallback + from utils.paths import datasets_root, resolve_output_dir + except ImportError as e: + event_queue.put( + { + "type": "error", + "error": f"Failed to import embedding libraries: {e}. " + "Ensure 'sentence_transformers' and 'unsloth' are installed.", + "stack": traceback.format_exc(limit = 20), + "ts": time.time(), + } + ) + return + + # ── Stop signal handling ── + _should_stop = False + _save_on_stop = True + + def _poll_stop(): + nonlocal _should_stop, _save_on_stop + while True: + try: + msg = stop_queue.get(timeout = 1.0) + if msg and msg.get("type") == "stop": + _save_on_stop = msg.get("save", True) + _should_stop = True + logger.info( + "Embedding training: stop signal received (save=%s)", + _save_on_stop, + ) + return + except _queue.Empty: + continue + except (EOFError, OSError): + return + + stop_thread = threading.Thread(target = _poll_stop, daemon = True) + stop_thread.start() + + # ── 2. Load model ── + _send_status(event_queue, "Loading embedding model...") + try: + hf_token = config.get("hf_token", "") + hf_token = hf_token if hf_token and hf_token.strip() else None + max_seq_length = config.get("max_seq_length", 512) + training_type = config.get("training_type", "LoRA/QLoRA") + use_lora = training_type == "LoRA/QLoRA" + + model = FastSentenceTransformer.from_pretrained( + model_name = model_name, + max_seq_length = max_seq_length, + full_finetuning = not use_lora, + token = hf_token, + ) + except Exception as e: + event_queue.put( + { + "type": "error", + "error": f"Failed to load embedding model '{model_name}': {e}", + "stack": traceback.format_exc(limit = 20), + "ts": time.time(), + } + ) + return + + if _should_stop: + event_queue.put({"type": "complete", "output_dir": None, "ts": time.time()}) + return + + # ── 3. Apply LoRA ── + if use_lora: + _send_status(event_queue, "Configuring LoRA adapters (FEATURE_EXTRACTION)...") + try: + gradient_checkpointing = config.get("gradient_checkpointing", False) + # Normalize: "none" or empty → False + if gradient_checkpointing in ("none", "", None): + gradient_checkpointing = False + + model = FastSentenceTransformer.get_peft_model( + model, + r = config.get("lora_r", 32), + target_modules = config.get("target_modules") + or ["q_proj", "k_proj", "v_proj", "o_proj"], + lora_alpha = config.get("lora_alpha", 64), + lora_dropout = config.get("lora_dropout", 0.0), + bias = "none", + use_gradient_checkpointing = gradient_checkpointing, + random_state = config.get("random_seed", 3407), + use_rslora = config.get("use_rslora", False), + loftq_config = {"loftq_bits": 4, "loftq_iter": 1} + if config.get("use_loftq") + else None, + task_type = "FEATURE_EXTRACTION", + ) + except Exception as e: + event_queue.put( + { + "type": "error", + "error": f"Failed to configure LoRA for embedding model: {e}", + "stack": traceback.format_exc(limit = 20), + "ts": time.time(), + } + ) + return + + if _should_stop: + event_queue.put({"type": "complete", "output_dir": None, "ts": time.time()}) + return + + # ── 4. Load dataset ── + _send_status(event_queue, "Loading dataset...") + try: + hf_dataset = config.get("hf_dataset", "") + local_datasets = config.get("local_datasets") or [] + subset = config.get("subset") or None + train_split = config.get("train_split", "train") or "train" + + if hf_dataset and hf_dataset.strip(): + hf_token = config.get("hf_token", "") + hf_token = hf_token if hf_token and hf_token.strip() else None + dataset = load_dataset( + hf_dataset.strip(), + subset, + split = train_split, + token = hf_token, + ) + elif local_datasets: + # Load from local file(s) — mirrors the non-embedding pipeline's + # directory handling so recipe outputs (parquet-files/) work. + all_files: list[str] = [] + for dataset_file in local_datasets: + file_path = ( + dataset_file + if os.path.isabs(dataset_file) + else os.path.join( + str(datasets_root()), + dataset_file, + ) + ) + if os.path.isdir(file_path): + file_path_obj = Path(file_path) + parquet_dir = ( + file_path_obj / "parquet-files" + if (file_path_obj / "parquet-files").exists() + else file_path_obj + ) + parquet_files = sorted(parquet_dir.glob("*.parquet")) + if parquet_files: + all_files.extend(str(p) for p in parquet_files) + continue + candidates: list[Path] = [] + for ext in (".json", ".jsonl", ".csv", ".parquet"): + candidates.extend(sorted(file_path_obj.glob(f"*{ext}"))) + if candidates: + all_files.extend(str(c) for c in candidates) + continue + raise ValueError( + f"No supported data files in directory: {file_path_obj}" + ) + else: + all_files.append(file_path) + + if all_files: + first_ext = Path(all_files[0]).suffix.lower() + if first_ext in (".json", ".jsonl"): + loader = "json" + elif first_ext == ".csv": + loader = "csv" + elif first_ext == ".parquet": + loader = "parquet" + else: + raise ValueError( + f"Unsupported local dataset format: {all_files[0]}" + ) + dataset = load_dataset(loader, data_files = all_files, split = "train") + else: + event_queue.put( + { + "type": "error", + "error": "No dataset specified for embedding training.", + "stack": "", + "ts": time.time(), + } + ) + return + + # Apply dataset slicing if specified + slice_start = config.get("dataset_slice_start") + slice_end = config.get("dataset_slice_end") + if slice_start is not None or slice_end is not None: + start = slice_start if slice_start is not None else 0 + end = slice_end if slice_end is not None else len(dataset) + dataset = dataset.select(range(start, min(end + 1, len(dataset)))) + + logger.info(f"Embedding dataset loaded: {len(dataset)} samples") + except Exception as e: + event_queue.put( + { + "type": "error", + "error": f"Failed to load dataset: {e}", + "stack": traceback.format_exc(limit = 20), + "ts": time.time(), + } + ) + return + + if _should_stop: + event_queue.put({"type": "complete", "output_dir": None, "ts": time.time()}) + return + + # ── 5. Create loss function ── + loss = MultipleNegativesRankingLoss(model) + + # ── 6. Build training arguments ── + _send_status(event_queue, "Configuring training...") + try: + lr_value = float(config.get("learning_rate", "2e-4")) + except ValueError: + event_queue.put( + { + "type": "error", + "error": f"Invalid learning rate: {config.get('learning_rate')}", + "stack": "", + "ts": time.time(), + } + ) + return + + output_dir = config.get("output_dir") + if not output_dir: + output_dir = str( + resolve_output_dir(f"{model_name.replace('/', '_')}_{int(time.time())}") + ) + + num_epochs = config.get("num_epochs", 2) + batch_size = config.get("batch_size", 256) + gradient_accumulation_steps = config.get("gradient_accumulation_steps", 1) + max_steps_val = config.get("max_steps", 0) + save_steps_val = config.get("save_steps", 0) + warmup_ratio = config.get("warmup_ratio", 0.03) + warmup_steps_val = config.get("warmup_steps") + log_frequency = config.get("log_frequency", 50) + + # Build args dict + training_args_kwargs = { + "output_dir": output_dir, + "per_device_train_batch_size": batch_size, + "gradient_accumulation_steps": gradient_accumulation_steps, + "learning_rate": lr_value, + "fp16": not is_bfloat16_supported(), + "bf16": is_bfloat16_supported(), + "logging_steps": 1, + "report_to": ["wandb"] if config.get("enable_wandb") else "none", + "lr_scheduler_type": config.get("lr_scheduler_type", "linear"), + "batch_sampler": BatchSamplers.NO_DUPLICATES, + "optim": config.get("optim", "adamw_8bit"), + "weight_decay": config.get("weight_decay", 0.01), + "seed": config.get("random_seed", 3407), + } + + # max_steps vs epochs + if max_steps_val and max_steps_val > 0: + training_args_kwargs["max_steps"] = max_steps_val + else: + training_args_kwargs["num_train_epochs"] = num_epochs if num_epochs > 0 else 2 + + # warmup: prefer warmup_ratio (standard for embedding scripts), fallback to steps + if warmup_ratio is not None and warmup_ratio > 0: + training_args_kwargs["warmup_ratio"] = warmup_ratio + elif warmup_steps_val is not None and warmup_steps_val > 0: + training_args_kwargs["warmup_steps"] = warmup_steps_val + + # save_steps + if save_steps_val and save_steps_val > 0: + training_args_kwargs["save_steps"] = save_steps_val + training_args_kwargs["save_strategy"] = "steps" + + args = SentenceTransformerTrainingArguments(**training_args_kwargs) + + # ── 7. Calculate total steps for progress tracking ── + if max_steps_val and max_steps_val > 0: + total_steps = max_steps_val + else: + effective_epochs = num_epochs if num_epochs > 0 else 2 + len_dataloader = math.ceil(len(dataset) / batch_size) + steps_per_epoch = max(len_dataloader // gradient_accumulation_steps, 1) + total_steps = steps_per_epoch * effective_epochs + + # ── 8. Create progress callback ── + class _EmbeddingProgressCallback(TrainerCallback): + """Sends training progress events to the parent process via event_queue.""" + + def on_log(self, args, state, control, logs = None, **kwargs): + if not logs: + return + loss_value = logs.get("loss", logs.get("train_loss", 0.0)) + current_step = state.global_step + + elapsed = time.time() - training_start_time + eta = None + if current_step > 0 and total_steps > 0: + remaining = total_steps - current_step + if remaining > 0: + eta = (elapsed / current_step) * remaining + + event_queue.put( + { + "type": "progress", + "step": current_step, + "epoch": round(state.epoch, 2) if state.epoch else 0, + "loss": loss_value, + "learning_rate": logs.get("learning_rate", 0.0), + "total_steps": total_steps, + "elapsed_seconds": elapsed, + "eta_seconds": eta, + "grad_norm": logs.get("grad_norm"), + "num_tokens": getattr(state, "num_input_tokens_seen", None), + "eval_loss": logs.get("eval_loss"), + "status_message": "", + "ts": time.time(), + } + ) + + def on_step_end(self, args, state, control, **kwargs): + if _should_stop: + logger.info("Embedding training: stop at step %d", state.global_step) + control.should_training_stop = True + return control + + # ── 9. Create trainer and train ── + _send_status(event_queue, "Starting embedding training...") + try: + trainer = SentenceTransformerTrainer( + model = model, + train_dataset = dataset, + loss = loss, + args = args, + callbacks = [_EmbeddingProgressCallback()], + ) + + trainer.train() + except Exception as e: + event_queue.put( + { + "type": "error", + "error": f"Embedding training failed: {e}", + "stack": traceback.format_exc(limit = 20), + "ts": time.time(), + } + ) + return + + # ── 10. Save model ── + if _should_stop and not _save_on_stop: + event_queue.put( + { + "type": "complete", + "output_dir": None, + "status_message": "Training cancelled", + "ts": time.time(), + } + ) + return + + _send_status(event_queue, "Saving model...") + try: + model.save_pretrained(output_dir) + model.tokenizer.save_pretrained(output_dir) + logger.info("Embedding model saved to %s", output_dir) + except Exception as e: + logger.error("Failed to save embedding model: %s", e) + event_queue.put( + { + "type": "error", + "error": f"Training completed but failed to save: {e}", + "stack": traceback.format_exc(limit = 20), + "ts": time.time(), + } + ) + return + + # ── 11. Done ── + event_queue.put( + { + "type": "complete", + "output_dir": output_dir, + "status_message": "Embedding training completed", + "ts": time.time(), + } + ) diff --git a/studio/backend/loggers/.gitkeep b/studio/backend/loggers/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/studio/backend/loggers/__init__.py b/studio/backend/loggers/__init__.py new file mode 100644 index 0000000000..721dde4688 --- /dev/null +++ b/studio/backend/loggers/__init__.py @@ -0,0 +1,6 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +from .handlers import get_logger + +__all__ = ["get_logger"] diff --git a/studio/backend/loggers/config.py b/studio/backend/loggers/config.py new file mode 100644 index 0000000000..0d32a64657 --- /dev/null +++ b/studio/backend/loggers/config.py @@ -0,0 +1,76 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Logging configuration for structured logging with structlog. + +This module provides centralized logging configuration with environment-specific +formats and processors. Supports both development and production environments +with consistent structured logging. + +Key Features: +- Environment-specific formatting (JSON for production, console for development) +- Timestamp standardization (ISO format) +- Context variable integration +- Log level filtering +- Logger caching for performance +""" + +import logging +import os +import sys +from typing import Optional + +import structlog + + +class LogConfig: + """Structured logging configuration for the application. + + Provides static method to configure structlog with environment-specific + formatting and processors for consistent structured logging. + """ + + @staticmethod + def setup_logging( + service_name: str = "unsloth-studio-backend", env: Optional[str] = None + ) -> structlog.BoundLogger: + """Configure structured logging for the application. + Args: + service_name: Name of the service for logging identification + env: Environment (development/production), affects logging format + """ + # Determine log level from environment + log_level_name = os.getenv("LOG_LEVEL", "INFO").upper() + # Fallback to INFO if an invalid level is provided + log_level = getattr(logging, log_level_name, logging.INFO) + + structlog.configure( + processors = [ + # Reorder processors to control field order + structlog.processors.TimeStamper(fmt = "iso"), # timestamp first + structlog.processors.add_log_level, # level second + structlog.contextvars.merge_contextvars, + # Custom processor to flatten the extra field + lambda logger, method_name, event_dict: { + "timestamp": event_dict.get("timestamp"), + "level": event_dict.get("level"), + "event": event_dict.get("event"), + **(event_dict.get("extra", {})), # Flatten extra into main dict + **{ + k: v + for k, v in event_dict.items() + if k not in ["timestamp", "level", "event", "extra"] + }, + }, + ( + structlog.processors.JSONRenderer(sort_keys = False) # Preserve order + if env == "production" + else structlog.dev.ConsoleRenderer() + ), + ], + wrapper_class = structlog.make_filtering_bound_logger(log_level), + logger_factory = structlog.PrintLoggerFactory(file = sys.stdout), + cache_logger_on_first_use = True, + ) + + return structlog.get_logger(service_name) diff --git a/studio/backend/loggers/handlers.py b/studio/backend/loggers/handlers.py new file mode 100644 index 0000000000..3add92ea1e --- /dev/null +++ b/studio/backend/loggers/handlers.py @@ -0,0 +1,101 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Logging handlers and middleware for structured logging. + +This module provides FastAPI middleware and structlog processors for: +- Request/response logging with timing +- Sensitive data filtering in logs +- Structured logging configuration +- Error handling with detailed context + +Key Components: +- LoggingMiddleware: FastAPI middleware for request/response logging +- filter_sensitive_data: Structlog processor for data sanitization +- get_logger: Factory function for structured loggers +""" + +import time +from typing import Callable + +import structlog +from fastapi import Request, Response +from starlette.middleware.base import BaseHTTPMiddleware + +logger = structlog.get_logger(__name__) + + +class LoggingMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next: Callable) -> Response: + start_time = time.time() + + try: + response = await call_next(request) + + # Log response + process_time = (time.time() - start_time) * 1000 + + EXCLUDED_PATHS = { + "/api/train/status", + "/api/train/metrics", + "/api/train/hardware", + "/api/system", + } + is_excluded = ( + request.url.path in EXCLUDED_PATHS + or request.url.path.startswith("/assets/") + or request.url.path.endswith( + (".png", ".jpg", ".jpeg", ".ico", ".woff", ".woff2", ".ttf") + ) + ) + + if not is_excluded: + logger.info( + "request_completed", + method = request.method, + path = request.url.path, + status_code = response.status_code, + process_time_ms = round(process_time, 2), + ) + + return response + + except Exception as e: + logger.error( + "request_failed", + path = request.url.path, + method = request.method, + error = str(e), + exc_info = True, + ) + raise + + +def filter_sensitive_data(logger, method_name, event_dict): + """Structlog processor to filter out base64 data from logs.""" + + def filter_value(value): + if ( + isinstance(value, str) + and len(value) > 100 + and ("," in value or "/" in value) + ): + # Likely base64 data, truncate it + return value[:20] + "..." + elif isinstance(value, dict): + return {k: filter_value(v) for k, v in value.items()} + elif isinstance(value, list): + return [filter_value(item) for item in value] + return value + + return {k: filter_value(v) for k, v in event_dict.items()} + + +def get_logger(name: str) -> structlog.BoundLogger: + """Get a logger instance for a specific module. + Args: + name: Usually __name__ of the module + Returns: + A bound structured logger + """ + return structlog.get_logger(name) diff --git a/studio/backend/main.py b/studio/backend/main.py new file mode 100644 index 0000000000..46615bb5f0 --- /dev/null +++ b/studio/backend/main.py @@ -0,0 +1,258 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Main FastAPI application for Unsloth UI Backend +""" + +import os + +# Suppress annoying C-level dependency warnings globally +os.environ["PYTHONWARNINGS"] = "ignore" + +import secrets +import shutil +import warnings +from contextlib import asynccontextmanager + +# Suppress annoying dependency warnings in production +if os.getenv("ENVIRONMENT_TYPE", "production") == "production": + warnings.filterwarnings("ignore") + # Alternatively, you can be more specific: + # warnings.filterwarnings("ignore", category=DeprecationWarning) + # warnings.filterwarnings("ignore", module="triton.*") + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse, HTMLResponse, Response +from pathlib import Path +from datetime import datetime + +# Import routers +from routes import ( + auth_router, + data_recipe_router, + datasets_router, + export_router, + inference_router, + models_router, + training_router, +) +from auth import storage +from utils.hardware import detect_hardware, get_device, DeviceType +import utils.hardware.hardware as _hw_module + +from utils.cache_cleanup import clear_unsloth_compiled_cache + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Startup: detect hardware, print setup token if needed. Shutdown: clean up compiled cache.""" + # Clean up any stale compiled cache from previous runs + clear_unsloth_compiled_cache() + + # Remove stale .venv_overlay from previous versions — no longer used. + # Version switching now uses .venv_t5/ (pre-installed by setup.sh). + overlay_dir = Path(__file__).resolve().parent.parent.parent / ".venv_overlay" + if overlay_dir.is_dir(): + shutil.rmtree(overlay_dir, ignore_errors = True) + + # Detect hardware first — sets DEVICE global used everywhere + detect_hardware() + + # Disable flex attention on Blackwell+ GPUs (sm_120 and above) + if get_device() == DeviceType.CUDA: + import torch + + props = torch.cuda.get_device_properties(0) + sm_version = props.major * 10 + props.minor + if sm_version >= 120: + os.environ["UNSLOTH_ENABLE_FLEX_ATTENTION"] = "0" + import structlog + from loggers import get_logger + + get_logger(__name__).info( + f"GPU sm_{sm_version} detected — setting UNSLOTH_FLEX_ATTENTION=0" + ) + + # Pre-cache the helper GGUF model for LLM-assisted dataset detection. + # Runs in a background thread so it doesn't block server startup. + import threading + + def _precache(): + try: + from utils.datasets.llm_assist import precache_helper_gguf + + precache_helper_gguf() + except Exception: + pass # non-critical + + threading.Thread(target = _precache, daemon = True).start() + + if not storage.is_initialized(): + setup_token = secrets.token_urlsafe(32) + storage.save_setup_token(setup_token) + print("\n" + "=" * 60) + print("FIRST-TIME SETUP REQUIRED") + print("Use this one-time setup token to create your admin account:\n") + print(f" {setup_token}\n") + print("This token can only be used once.") + print("=" * 60 + "\n") + yield + # Cleanup + _hw_module.DEVICE = None + clear_unsloth_compiled_cache() + + +# Create FastAPI app +app = FastAPI( + title = "Unsloth UI Backend", + version = "1.0.0", + description = "Backend API for Unsloth UI - Training and Model Management", + lifespan = lifespan, +) + +# Initialize structured logging +from loggers.config import LogConfig +from loggers.handlers import LoggingMiddleware + +logger = LogConfig.setup_logging( + service_name = "unsloth-studio-backend", + env = os.getenv("ENVIRONMENT_TYPE", "production"), +) + +app.add_middleware(LoggingMiddleware) + +# CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins = ["*"], # In production, specify allowed origins + allow_credentials = True, + allow_methods = ["*"], + allow_headers = ["*"], +) + +# ============ Register API Routes ============ + +# Register routers +app.include_router(auth_router, prefix = "/api/auth", tags = ["auth"]) +app.include_router(training_router, prefix = "/api/train", tags = ["training"]) +app.include_router(models_router, prefix = "/api/models", tags = ["models"]) +app.include_router(inference_router, prefix = "/api/inference", tags = ["inference"]) + +# OpenAI-compatible endpoints: mount the same inference router at /v1 +# so external tools (Open WebUI, SillyTavern, etc.) can use the +# standard /v1/chat/completions path. +app.include_router(inference_router, prefix = "/v1", tags = ["openai-compat"]) +app.include_router(datasets_router, prefix = "/api/datasets", tags = ["datasets"]) +app.include_router(data_recipe_router, prefix = "/api/data-recipe", tags = ["data-recipe"]) +app.include_router(export_router, prefix = "/api/export", tags = ["export"]) + + +# ============ Health and System Endpoints ============ + + +@app.get("/api/health") +async def health_check(): + """Health check endpoint""" + return { + "status": "healthy", + "timestamp": datetime.now().isoformat(), + "service": "Unsloth UI Backend", + } + + +@app.get("/api/system") +async def get_system_info(): + """Get system information""" + import platform + import psutil + from utils.hardware import get_device, get_gpu_memory_info, DeviceType + + # GPU Info — uses the hardware module (works on CUDA, MPS, CPU) + mem_info = get_gpu_memory_info() + gpu_info = {"available": mem_info.get("available", False), "devices": []} + + if mem_info.get("available"): + gpu_info["devices"].append( + { + "index": mem_info.get("device", 0), + "name": mem_info.get("device_name", "Unknown"), + "memory_total_gb": round(mem_info.get("total_gb", 0), 2), + } + ) + + # CPU & Memory + memory = psutil.virtual_memory() + + return { + "platform": platform.platform(), + "python_version": platform.python_version(), + "device_backend": get_device().value, + "cpu_count": psutil.cpu_count(), + "memory": { + "total_gb": round(memory.total / 1e9, 2), + "available_gb": round(memory.available / 1e9, 2), + "percent_used": memory.percent, + }, + "gpu": gpu_info, + } + + +@app.get("/api/system/hardware") +async def get_hardware_info(): + """Return GPU name, total VRAM, and key ML package versions.""" + from utils.hardware import get_gpu_summary, get_package_versions + + return { + "gpu": get_gpu_summary(), + "versions": get_package_versions(), + } + + +# ============ Serve Frontend (Optional) ============ + + +def setup_frontend(app: FastAPI, build_path: Path): + """Mount frontend static files (optional)""" + if not build_path.exists(): + return False + + # Mount assets + assets_dir = build_path / "assets" + if assets_dir.exists(): + app.mount("/assets", StaticFiles(directory = assets_dir), name = "assets") + + @app.get("/") + async def serve_root(): + content = (build_path / "index.html").read_bytes() + return Response( + content = content, + media_type = "text/html", + headers = {"Cache-Control": "no-cache, no-store, must-revalidate"}, + ) + + @app.get("/{full_path:path}") + async def serve_frontend(full_path: str): + if full_path.startswith("api"): + return {"error": "API endpoint not found"} + + file_path = (build_path / full_path).resolve() + + # Block path traversal — ensure resolved path stays inside build_path + if not file_path.is_relative_to(build_path.resolve()): + return Response(status_code = 403) + + if file_path.is_file(): + return FileResponse(file_path) + + # Serve index.html as bytes — avoids Content-Length mismatch + content = (build_path / "index.html").read_bytes() + return Response( + content = content, + media_type = "text/html", + headers = {"Cache-Control": "no-cache, no-store, must-revalidate"}, + ) + + return True diff --git a/studio/backend/models/.gitkeep b/studio/backend/models/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/studio/backend/models/__init__.py b/studio/backend/models/__init__.py new file mode 100644 index 0000000000..4a53418439 --- /dev/null +++ b/studio/backend/models/__init__.py @@ -0,0 +1,120 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Pydantic models for API request/response schemas +""" + +from .training import ( + TrainingStartRequest, + TrainingJobResponse, + TrainingStatus, + TrainingProgress, +) +from .models import ( + CheckpointInfo, + ModelCheckpoints, + CheckpointListResponse, + ModelDetails, + LocalModelInfo, + LocalModelListResponse, + LoRAInfo, + LoRAScanResponse, + ModelListResponse, +) +from .auth import ( + AuthSetupRequest, + AuthLoginRequest, + RefreshTokenRequest, + AuthStatusResponse, +) +from .export import ( + LoadCheckpointRequest, + ExportStatusResponse, + ExportOperationResponse, + ExportMergedModelRequest, + ExportBaseModelRequest, + ExportGGUFRequest, + ExportLoRAAdapterRequest, +) +from .users import Token +from .datasets import ( + CheckFormatRequest, + CheckFormatResponse, +) +from .inference import ( + LoadRequest, + UnloadRequest, + GenerateRequest, + LoadResponse, + UnloadResponse, + InferenceStatusResponse, +) +from .responses import ( + TrainingStopResponse, + TrainingMetricsResponse, + LoRABaseModelResponse, + VisionCheckResponse, + EmbeddingCheckResponse, +) +from .data_recipe import ( + RecipePayload, + PreviewResponse, + ValidateError, + ValidateResponse, + JobCreateResponse, +) + +__all__ = [ + # Training schemas + "TrainingStartRequest", + "TrainingJobResponse", + "TrainingStatus", + "TrainingProgress", + # Model management schemas + "ModelDetails", + "LocalModelInfo", + "LocalModelListResponse", + "LoRAInfo", + "LoRAScanResponse", + "ModelListResponse", + # Auth schemas + "AuthSetupRequest", + "AuthLoginRequest", + "RefreshTokenRequest", + "AuthStatusResponse", + # Export schemas + "CheckpointInfo", + "ModelCheckpoints", + "CheckpointListResponse", + "LoadCheckpointRequest", + "ExportStatusResponse", + "ExportOperationResponse", + "ExportMergedModelRequest", + "ExportBaseModelRequest", + "ExportGGUFRequest", + "ExportLoRAAdapterRequest", + "Token", + # Dataset schemas + "CheckFormatRequest", + "CheckFormatResponse", + # Inference schemas + "LoadRequest", + "UnloadRequest", + "GenerateRequest", + "LoadResponse", + "UnloadResponse", + "InferenceStatusResponse", + # Response schemas + "TrainingStopResponse", + "TrainingMetricsResponse", + "LoRABaseModelResponse", + "VisionCheckResponse", + "EmbeddingCheckResponse", + # Data recipe + "RecipePayload", + "PreviewResponse", + "ValidateError", + "ValidateResponse", + "JobCreateResponse", +] diff --git a/studio/backend/models/auth.py b/studio/backend/models/auth.py new file mode 100644 index 0000000000..c12d15617e --- /dev/null +++ b/studio/backend/models/auth.py @@ -0,0 +1,41 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Pydantic schemas for Authentication API +""" + +from pydantic import BaseModel, Field + + +class AuthSetupRequest(BaseModel): + """First-time setup: create the initial admin user + password.""" + + setup_token: str = Field( + ..., description = "One-time setup token printed to the server console" + ) + username: str = Field(..., description = "Admin username") + password: str = Field( + ..., min_length = 8, description = "Admin password (minimum 8 characters)" + ) + + +class AuthLoginRequest(BaseModel): + """Login payload: username/password to obtain a JWT.""" + + username: str = Field(..., description = "Username") + password: str = Field(..., description = "Password") + + +class RefreshTokenRequest(BaseModel): + """Refresh token payload to obtain new access + refresh tokens.""" + + refresh_token: str = Field( + ..., description = "Refresh token from a previous login or refresh" + ) + + +class AuthStatusResponse(BaseModel): + """Indicate whether auth has been initialized.""" + + initialized: bool = Field(..., description = "True if auth setup has been completed") diff --git a/studio/backend/models/data_recipe.py b/studio/backend/models/data_recipe.py new file mode 100644 index 0000000000..6992572b00 --- /dev/null +++ b/studio/backend/models/data_recipe.py @@ -0,0 +1,82 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Pydantic schemas for Data Recipe (DataDesigner) API. +""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, Field + + +class RecipePayload(BaseModel): + recipe: dict[str, Any] = Field(default_factory = dict) + run: dict[str, Any] | None = None + ui: dict[str, Any] | None = None + + +class PreviewResponse(BaseModel): + dataset: list[dict[str, Any]] = Field(default_factory = list) + processor_artifacts: dict[str, Any] | None = None + analysis: dict[str, Any] | None = None + + +class ValidateError(BaseModel): + message: str + path: str | None = None + code: str | None = None + + +class ValidateResponse(BaseModel): + valid: bool + errors: list[ValidateError] = Field(default_factory = list) + raw_detail: str | None = None + + +class JobCreateResponse(BaseModel): + job_id: str + + +class SeedInspectRequest(BaseModel): + dataset_name: str = Field(min_length = 1) + hf_token: str | None = None + subset: str | None = None + split: str | None = "train" + preview_size: int = Field(default = 10, ge = 1, le = 50) + + +class SeedInspectUploadRequest(BaseModel): + filename: str = Field(min_length = 1) + content_base64: str = Field(min_length = 1) + preview_size: int = Field(default = 10, ge = 1, le = 50) + seed_source_type: str | None = None + unstructured_chunk_size: int | None = Field(default = None, ge = 1, le = 20000) + unstructured_chunk_overlap: int | None = Field(default = None, ge = 0, le = 20000) + + +class SeedInspectResponse(BaseModel): + dataset_name: str + resolved_path: str + columns: list[str] = Field(default_factory = list) + preview_rows: list[dict[str, Any]] = Field(default_factory = list) + split: str | None = None + subset: str | None = None + + +class McpToolsListRequest(BaseModel): + mcp_providers: list[dict[str, Any]] = Field(default_factory = list) + timeout_sec: float | None = Field(default = None, gt = 0) + + +class McpToolsProviderResult(BaseModel): + name: str + tools: list[str] = Field(default_factory = list) + error: str | None = None + + +class McpToolsListResponse(BaseModel): + providers: list[McpToolsProviderResult] = Field(default_factory = list) + duplicate_tools: dict[str, list[str]] = Field(default_factory = dict) diff --git a/studio/backend/models/datasets.py b/studio/backend/models/datasets.py new file mode 100644 index 0000000000..f20d6f2d15 --- /dev/null +++ b/studio/backend/models/datasets.py @@ -0,0 +1,99 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Dataset-related Pydantic models for API requests and responses. +""" + +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field, model_validator + + +class CheckFormatRequest(BaseModel): + """Request for dataset format check""" + + dataset_name: str # HuggingFace dataset name or local path + is_vlm: bool = False + hf_token: Optional[str] = None + subset: Optional[str] = None + train_split: Optional[str] = "train" + + @model_validator(mode = "before") + @classmethod + def _compat_split(cls, values: Any) -> Any: + """Accept legacy 'split' field as alias for 'train_split'.""" + if isinstance(values, dict) and "split" in values: + values.setdefault("train_split", values.pop("split")) + return values + + +class CheckFormatResponse(BaseModel): + """Response for dataset format check""" + + requires_manual_mapping: bool + detected_format: str + columns: List[str] + is_image: bool = False + is_audio: bool = False + multimodal_columns: Optional[List[str]] = None + suggested_mapping: Optional[Dict[str, str]] = None + detected_image_column: Optional[str] = None + detected_audio_column: Optional[str] = None + detected_text_column: Optional[str] = None + detected_speaker_column: Optional[str] = None + preview_samples: Optional[List[Dict]] = None + total_rows: Optional[int] = None + warning: Optional[str] = None + + +class AiAssistMappingRequest(BaseModel): + """Request for LLM-assisted column classification (user-triggered).""" + + columns: List[str] + samples: List[Dict[str, Any]] # Preview rows already loaded in the dialog + dataset_name: Optional[str] = None # For LLM context + hf_token: Optional[str] = None # For fetching dataset card + model_name: Optional[str] = None + model_type: Optional[str] = None + + +class AiAssistMappingResponse(BaseModel): + """Response from LLM-assisted column classification and conversion advice.""" + + success: bool + suggested_mapping: Optional[Dict[str, str]] = None + warning: Optional[str] = None + # Conversion advisor fields + system_prompt: Optional[str] = None + label_mapping: Optional[Dict[str, Dict[str, str]]] = None + dataset_type: Optional[str] = None + is_conversational: Optional[bool] = None + user_notification: Optional[str] = None + + +class UploadDatasetResponse(BaseModel): + """Response with stored dataset path for training.""" + + filename: str = Field(..., description = "Original filename") + stored_path: str = Field(..., description = "Absolute path stored on backend") + + +class LocalDatasetItem(BaseModel): + class Metadata(BaseModel): + actual_num_records: Optional[int] = None + target_num_records: Optional[int] = None + total_num_batches: Optional[int] = None + num_completed_batches: Optional[int] = None + columns: Optional[List[str]] = None + + id: str + label: str + path: str + rows: Optional[int] = None + updated_at: Optional[float] = None + metadata: Optional[Metadata] = None + + +class LocalDatasetsResponse(BaseModel): + datasets: List[LocalDatasetItem] = Field(default_factory = list) diff --git a/studio/backend/models/export.py b/studio/backend/models/export.py new file mode 100644 index 0000000000..a86596f199 --- /dev/null +++ b/studio/backend/models/export.py @@ -0,0 +1,132 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Pydantic schemas for Export API. +""" + +from pydantic import BaseModel, Field +from typing import List, Optional, Literal, Dict, Any + + +class LoadCheckpointRequest(BaseModel): + """Request for loading a checkpoint into the export backend.""" + + checkpoint_path: str = Field(..., description = "Path to the checkpoint directory") + max_seq_length: int = Field( + 2048, + ge = 128, + le = 32768, + description = "Maximum sequence length for loading the model", + ) + load_in_4bit: bool = Field( + True, + description = "Whether to load the model in 4-bit quantization", + ) + trust_remote_code: bool = Field( + False, + description = "Allow loading models with custom code. Only enable for checkpoints/base models you trust.", + ) + + +class ExportStatusResponse(BaseModel): + """Current export backend status.""" + + current_checkpoint: Optional[str] = Field( + None, + description = "Path to the currently loaded checkpoint, if any", + ) + is_vision: bool = Field( + False, + description = "True if the loaded checkpoint is a vision model", + ) + is_peft: bool = Field( + False, + description = "True if the loaded checkpoint is a PEFT (LoRA) model", + ) + + +class ExportOperationResponse(BaseModel): + """Generic response for export operations.""" + + success: bool = Field(..., description = "True if the operation succeeded") + message: str = Field(..., description = "Human-readable status or error message") + details: Optional[Dict[str, Any]] = Field( + default = None, + description = "Optional extra details about the operation", + ) + + +class ExportCommonOptions(BaseModel): + """Common options for export operations that save locally and/or push to Hub.""" + + save_directory: str = Field( + ..., + description = "Local directory where the exported artifacts will be written", + ) + push_to_hub: bool = Field( + False, + description = "If True, also push the exported model to the Hugging Face Hub", + ) + repo_id: Optional[str] = Field( + None, + description = "Hugging Face Hub repository ID (username/model-name)", + ) + hf_token: Optional[str] = Field( + None, + description = "Hugging Face access token used for Hub operations", + ) + private: bool = Field( + False, + description = "If True, create a private repository on the Hub (where applicable)", + ) + base_model_id: Optional[str] = Field( + None, + description = "HuggingFace model ID of the base model (for model card metadata)", + ) + + +class ExportMergedModelRequest(ExportCommonOptions): + """Request for exporting a merged PEFT model.""" + + format_type: Literal["16-bit (FP16)", "4-bit (FP4)"] = Field( + "16-bit (FP16)", + description = "Export precision / format for the merged model", + ) + + +class ExportBaseModelRequest(ExportCommonOptions): + """Request for exporting a non-PEFT (base) model.""" + + # Uses fields from ExportCommonOptions only + + +class ExportGGUFRequest(BaseModel): + """Request for exporting the current model to GGUF format.""" + + save_directory: str = Field( + ..., + description = "Directory where GGUF files will be saved", + ) + quantization_method: str = Field( + "Q4_K_M", + description = 'GGUF quantization method (e.g. "Q4_K_M")', + ) + push_to_hub: bool = Field( + False, + description = "If True, also push GGUF artifacts to the Hugging Face Hub", + ) + repo_id: Optional[str] = Field( + None, + description = "Hugging Face Hub repository ID for GGUF upload", + ) + hf_token: Optional[str] = Field( + None, + description = "Hugging Face token for GGUF upload", + ) + + +class ExportLoRAAdapterRequest(ExportCommonOptions): + """Request for exporting only the LoRA adapter (not merged).""" + + # Uses fields from ExportCommonOptions only diff --git a/studio/backend/models/inference.py b/studio/backend/models/inference.py new file mode 100644 index 0000000000..46925fb3db --- /dev/null +++ b/studio/backend/models/inference.py @@ -0,0 +1,334 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Pydantic schemas for Inference API +""" + +from __future__ import annotations + +import time +import uuid +from typing import Annotated, Literal, Optional, List, Union + +from pydantic import BaseModel, Discriminator, Field, Tag + + +class LoadRequest(BaseModel): + """Request to load a model for inference""" + + model_path: str = Field(..., description = "Model identifier or local path") + hf_token: Optional[str] = Field( + None, description = "HuggingFace token for gated models" + ) + max_seq_length: int = Field( + 4096, ge = 128, le = 32768, description = "Maximum sequence length" + ) + load_in_4bit: bool = Field(True, description = "Load model in 4-bit quantization") + is_lora: bool = Field(False, description = "Whether this is a LoRA adapter") + gguf_variant: Optional[str] = Field( + None, description = "GGUF quantization variant (e.g. 'Q4_K_M')" + ) + trust_remote_code: bool = Field( + False, + description = "Allow loading models with custom code (e.g. NVIDIA Nemotron). Only enable for repos you trust.", + ) + + +class UnloadRequest(BaseModel): + """Request to unload a model""" + + model_path: str = Field(..., description = "Model identifier to unload") + + +class ValidateModelRequest(BaseModel): + """ + Lightweight validation request to check whether a model identifier + *can be resolved* into a ModelConfig. + + This does NOT actually load weights into GPU memory. + """ + + model_path: str = Field(..., description = "Model identifier or local path") + hf_token: Optional[str] = Field( + None, description = "HuggingFace token for gated models" + ) + gguf_variant: Optional[str] = Field( + None, description = "GGUF quantization variant (e.g. 'Q4_K_M')" + ) + + +class ValidateModelResponse(BaseModel): + """ + Result of model validation. + + valid == True means ModelConfig.from_identifier() succeeded and basic + introspection (GGUF / LoRA / vision flags) is available. + """ + + valid: bool = Field(..., description = "Whether the model identifier looks valid") + message: str = Field(..., description = "Human-readable validation message") + identifier: Optional[str] = Field(None, description = "Resolved model identifier") + display_name: Optional[str] = Field( + None, description = "Display name derived from identifier" + ) + is_gguf: bool = Field(False, description = "Whether this is a GGUF model (llama.cpp)") + is_lora: bool = Field(False, description = "Whether this is a LoRA adapter") + is_vision: bool = Field(False, description = "Whether this is a vision-capable model") + + +class GenerateRequest(BaseModel): + """Request for text generation (legacy /generate/stream endpoint)""" + + messages: List[dict] = Field(..., description = "Chat messages in OpenAI format") + system_prompt: str = Field( + "You are a helpful AI assistant.", description = "System prompt" + ) + temperature: float = Field(0.7, ge = 0.0, le = 2.0, description = "Sampling temperature") + top_p: float = Field(0.9, ge = 0.0, le = 1.0, description = "Top-p sampling") + top_k: int = Field(40, ge = -1, le = 100, description = "Top-k sampling") + max_new_tokens: int = Field( + 2048, ge = 1, le = 4096, description = "Maximum tokens to generate" + ) + repetition_penalty: float = Field( + 1.1, ge = 1.0, le = 2.0, description = "Repetition penalty" + ) + image_base64: Optional[str] = Field( + None, description = "Base64 encoded image for vision models" + ) + + +class LoadResponse(BaseModel): + """Response after loading a model""" + + status: str = Field(..., description = "Load status") + model: str = Field(..., description = "Model identifier") + display_name: str = Field(..., description = "Display name of the model") + is_vision: bool = Field(False, description = "Whether model is a vision model") + is_lora: bool = Field(False, description = "Whether model is a LoRA adapter") + is_gguf: bool = Field( + False, description = "Whether model is a GGUF model (llama.cpp)" + ) + is_audio: bool = Field(False, description = "Whether model is a TTS audio model") + audio_type: Optional[str] = Field( + None, description = "Audio codec type: snac, csm, bicodec, dac" + ) + has_audio_input: bool = Field( + False, description = "Whether model accepts audio input (ASR)" + ) + inference: dict = Field( + ..., description = "Inference parameters (temperature, top_p, top_k, min_p)" + ) + + +class UnloadResponse(BaseModel): + """Response after unloading a model""" + + status: str = Field(..., description = "Unload status") + model: str = Field(..., description = "Model identifier that was unloaded") + + +class InferenceStatusResponse(BaseModel): + """Current inference backend status""" + + active_model: Optional[str] = Field( + None, description = "Currently active model identifier" + ) + is_vision: bool = Field( + False, description = "Whether the active model is a vision model" + ) + is_gguf: bool = Field( + False, description = "Whether the active model is a GGUF model (llama.cpp)" + ) + gguf_variant: Optional[str] = Field( + None, description = "GGUF quantization variant (e.g. Q4_K_M)" + ) + is_audio: bool = Field( + False, description = "Whether the active model is a TTS audio model" + ) + audio_type: Optional[str] = Field( + None, description = "Audio codec type: snac, csm, bicodec, dac" + ) + has_audio_input: bool = Field( + False, description = "Whether model accepts audio input (ASR)" + ) + loading: List[str] = Field( + default_factory = list, description = "Models currently being loaded" + ) + loaded: List[str] = Field( + default_factory = list, description = "Models currently loaded" + ) + + +# ===================================================================== +# OpenAI-Compatible Chat Completions Models +# ===================================================================== + + +# ── Multimodal content parts (OpenAI vision format) ────────────── + + +class TextContentPart(BaseModel): + """Text content part in a multimodal message.""" + + type: Literal["text"] + text: str + + +class ImageUrl(BaseModel): + """Image URL object — supports data URIs and remote URLs.""" + + url: str = Field(..., description = "data:image/png;base64,... or https://...") + detail: Optional[Literal["auto", "low", "high"]] = "auto" + + +class ImageContentPart(BaseModel): + """Image content part in a multimodal message.""" + + type: Literal["image_url"] + image_url: ImageUrl + + +def _content_part_discriminator(v): + if isinstance(v, dict): + return v.get("type") + return getattr(v, "type", None) + + +ContentPart = Annotated[ + Union[ + Annotated[TextContentPart, Tag("text")], + Annotated[ImageContentPart, Tag("image_url")], + ], + Discriminator(_content_part_discriminator), +] +"""Union type for multimodal content parts, discriminated by the 'type' field.""" + + +# ── Messages ───────────────────────────────────────────────────── + + +class ChatMessage(BaseModel): + """ + A single message in the conversation. + + ``content`` may be a plain string (text-only) or a list of + content parts for multimodal messages (OpenAI vision format). + """ + + role: Literal["system", "user", "assistant"] = Field( + ..., description = "Message role" + ) + content: Union[str, list[ContentPart]] = Field( + ..., description = "Message content (string or multimodal parts)" + ) + + +class ChatCompletionRequest(BaseModel): + """ + OpenAI-compatible chat completion request. + + Extensions (non-OpenAI fields) are marked with 'x-unsloth'. + """ + + model: str = Field( + "default", + description = "Model identifier (informational; the active model is used)", + ) + messages: list[ChatMessage] = Field(..., description = "Conversation messages") + stream: bool = Field(True, description = "Whether to stream the response via SSE") + temperature: float = Field(0.7, ge = 0.0, le = 2.0) + top_p: float = Field(0.9, ge = 0.0, le = 1.0) + max_tokens: Optional[int] = Field( + 2048, ge = 1, le = 4096, description = "Maximum tokens to generate" + ) + + # ── Unsloth extensions (ignored by standard OpenAI clients) ── + top_k: int = Field(40, ge = -1, le = 100, description = "[x-unsloth] Top-k sampling") + min_p: float = Field( + 0.0, ge = 0.0, le = 1.0, description = "[x-unsloth] Min-p sampling threshold" + ) + repetition_penalty: float = Field( + 1.1, ge = 1.0, le = 2.0, description = "[x-unsloth] Repetition penalty" + ) + image_base64: Optional[str] = Field( + None, description = "[x-unsloth] Base64-encoded image for vision models" + ) + audio_base64: Optional[str] = Field( + None, description = "[x-unsloth] Base64-encoded WAV for audio-input models (ASR)" + ) + use_adapter: Optional[Union[bool, str]] = Field( + None, + description = ( + "[x-unsloth] Adapter control for compare mode. " + "null = no change (default), " + "false = disable adapters (base model), " + "true = enable the current adapter, " + "string = enable a specific adapter by name." + ), + ) + + +# ── Streaming response chunks ──────────────────────────────────── + + +class ChoiceDelta(BaseModel): + """Delta content for a streaming chunk.""" + + role: Optional[str] = None + content: Optional[str] = None + + +class ChunkChoice(BaseModel): + """A single choice in a streaming chunk.""" + + index: int = 0 + delta: ChoiceDelta + finish_reason: Optional[Literal["stop", "length"]] = None + + +class ChatCompletionChunk(BaseModel): + """A single SSE chunk in OpenAI streaming format.""" + + id: str = Field(default_factory = lambda: f"chatcmpl-{uuid.uuid4().hex[:12]}") + object: Literal["chat.completion.chunk"] = "chat.completion.chunk" + created: int = Field(default_factory = lambda: int(time.time())) + model: str = "default" + choices: list[ChunkChoice] + + +# ── Non-streaming response ─────────────────────────────────────── + + +class CompletionMessage(BaseModel): + """The assistant's complete response message.""" + + role: Literal["assistant"] = "assistant" + content: str + + +class CompletionChoice(BaseModel): + """A single choice in a non-streaming response.""" + + index: int = 0 + message: CompletionMessage + finish_reason: Literal["stop", "length"] = "stop" + + +class CompletionUsage(BaseModel): + """Token usage statistics (approximate).""" + + prompt_tokens: int = 0 + completion_tokens: int = 0 + total_tokens: int = 0 + + +class ChatCompletion(BaseModel): + """Non-streaming chat completion response.""" + + id: str = Field(default_factory = lambda: f"chatcmpl-{uuid.uuid4().hex[:12]}") + object: Literal["chat.completion"] = "chat.completion" + created: int = Field(default_factory = lambda: int(time.time())) + model: str = "default" + choices: list[CompletionChoice] + usage: CompletionUsage = Field(default_factory = CompletionUsage) diff --git a/studio/backend/models/models.py b/studio/backend/models/models.py new file mode 100644 index 0000000000..6634f69385 --- /dev/null +++ b/studio/backend/models/models.py @@ -0,0 +1,182 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Pydantic schemas for Model Management API +""" + +from pydantic import BaseModel, Field +from typing import Optional, List, Dict, Any, Literal + +ModelType = Literal["text", "vision", "audio", "embeddings"] + + +class CheckpointInfo(BaseModel): + """Information about a discovered checkpoint directory.""" + + display_name: str = Field( + ..., description = "User-friendly checkpoint name (folder name)" + ) + path: str = Field(..., description = "Full path to the checkpoint directory") + loss: Optional[float] = Field(None, description = "Training loss at this checkpoint") + + +class ModelCheckpoints(BaseModel): + """A training run and its associated checkpoints.""" + + name: str = Field(..., description = "Training run folder name") + checkpoints: List[CheckpointInfo] = Field( + default_factory = list, + description = "List of checkpoints for this training run (final + intermediate)", + ) + base_model: Optional[str] = Field( + None, + description = "Base model name from adapter_config.json or config.json", + ) + peft_type: Optional[str] = Field( + None, + description = "PEFT type (e.g. LORA) if adapter training, None for full fine-tune", + ) + lora_rank: Optional[int] = Field( + None, + description = "LoRA rank (r) if applicable", + ) + + +class CheckpointListResponse(BaseModel): + """Response for listing available checkpoints in an outputs directory.""" + + outputs_dir: str = Field(..., description = "Directory that was scanned") + models: List[ModelCheckpoints] = Field( + default_factory = list, + description = "List of training runs with their checkpoints", + ) + + +class ModelDetails(BaseModel): + """Detailed model configuration and metadata - can be used for both list and detail views""" + + id: str = Field(..., description = "Model identifier") + model_name: Optional[str] = Field( + None, description = "Model identifier (alias for id, for backward compatibility)" + ) + name: Optional[str] = Field(None, description = "Display name for the model") + config: Optional[Dict[str, Any]] = Field( + None, description = "Model configuration dictionary" + ) + is_vision: bool = Field(False, description = "Whether model is a vision model") + is_embedding: bool = Field( + False, description = "Whether model is an embedding/sentence-transformer model" + ) + is_lora: bool = Field(False, description = "Whether model is a LoRA adapter") + is_gguf: bool = Field( + False, description = "Whether model is a GGUF model (llama.cpp format)" + ) + is_audio: bool = Field(False, description = "Whether model is a TTS audio model") + audio_type: Optional[str] = Field( + None, description = "Audio codec type: snac, csm, bicodec, dac" + ) + has_audio_input: bool = Field( + False, description = "Whether model accepts audio input (ASR)" + ) + model_type: Optional[ModelType] = Field( + None, description = "Collapsed model modality: text, vision, audio, or embeddings" + ) + base_model: Optional[str] = Field( + None, description = "Base model if this is a LoRA adapter" + ) + + +class LoRAInfo(BaseModel): + """LoRA adapter or exported model information""" + + display_name: str = Field(..., description = "Display name for the LoRA") + adapter_path: str = Field( + ..., description = "Path to the LoRA adapter or exported model" + ) + base_model: Optional[str] = Field(None, description = "Base model identifier") + source: Optional[str] = Field(None, description = "'training' or 'exported'") + export_type: Optional[str] = Field( + None, description = "'lora', 'merged', or 'gguf' (for exports)" + ) + + +class LoRAScanResponse(BaseModel): + """Response schema for scanning trained LoRA adapters""" + + loras: List[LoRAInfo] = Field( + default_factory = list, description = "List of found LoRA adapters" + ) + outputs_dir: str = Field(..., description = "Directory that was scanned") + + +class ModelListResponse(BaseModel): + """Response schema for listing models""" + + models: List[ModelDetails] = Field( + default_factory = list, description = "List of models" + ) + default_models: List[str] = Field( + default_factory = list, description = "List of default model IDs" + ) + + +class GgufVariantDetail(BaseModel): + """A single GGUF quantization variant in a HuggingFace repo.""" + + filename: str = Field( + ..., description = "GGUF filename (e.g., 'gemma-3-4b-it-Q4_K_M.gguf')" + ) + quant: str = Field(..., description = "Quantization label (e.g., 'Q4_K_M')") + size_bytes: int = Field(0, description = "File size in bytes") + + +class GgufVariantsResponse(BaseModel): + """Response for listing GGUF quantization variants in a HuggingFace repo.""" + + repo_id: str = Field(..., description = "HuggingFace repo ID") + variants: List[GgufVariantDetail] = Field( + default_factory = list, description = "Available GGUF variants" + ) + has_vision: bool = Field( + False, description = "Whether the model has vision support (mmproj files)" + ) + default_variant: Optional[str] = Field( + None, description = "Recommended default quantization variant" + ) + + +class LocalModelInfo(BaseModel): + """Discovered local model candidate.""" + + id: str = Field(..., description = "Identifier to use for loading/training") + display_name: str = Field(..., description = "Display label") + path: str = Field(..., description = "Local path where model data was discovered") + source: Literal["models_dir", "hf_cache"] = Field( + ..., + description = "Discovery source", + ) + model_id: Optional[str] = Field( + None, + description = "HF repo id for cached models, e.g. org/model", + ) + updated_at: Optional[float] = Field( + None, + description = "Unix timestamp of latest observed update", + ) + + +class LocalModelListResponse(BaseModel): + """Response schema for listing local/cached models.""" + + models_dir: str = Field( + ..., description = "Directory scanned for custom local models" + ) + hf_cache_dir: Optional[str] = Field( + None, + description = "HF cache root that was scanned", + ) + models: List[LocalModelInfo] = Field( + default_factory = list, + description = "Discovered local/cached models", + ) diff --git a/studio/backend/models/responses.py b/studio/backend/models/responses.py new file mode 100644 index 0000000000..3081f67422 --- /dev/null +++ b/studio/backend/models/responses.py @@ -0,0 +1,67 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Pydantic response schemas for endpoints that previously returned raw dicts. +These are small response models for training and model management routes. +""" + +from pydantic import BaseModel, Field +from typing import Optional, List + + +# --- Training route response models --- + + +class TrainingStopResponse(BaseModel): + """Response for stopping a training job""" + + status: str = Field(..., description = "Current status: 'stopped' or 'idle'") + message: str = Field(..., description = "Human-readable status message") + + +class TrainingMetricsResponse(BaseModel): + """Response for training metrics history""" + + loss_history: List[float] = Field( + default_factory = list, description = "Loss values per step" + ) + lr_history: List[float] = Field( + default_factory = list, description = "Learning rate per step" + ) + step_history: List[int] = Field(default_factory = list, description = "Step numbers") + grad_norm_history: List[float] = Field( + default_factory = list, description = "Gradient norm values" + ) + grad_norm_step_history: List[int] = Field( + default_factory = list, description = "Step numbers for gradient norm values" + ) + current_loss: Optional[float] = Field(None, description = "Most recent loss value") + current_lr: Optional[float] = Field(None, description = "Most recent learning rate") + current_step: Optional[int] = Field(None, description = "Most recent step number") + + +# --- Model management route response models --- + + +class LoRABaseModelResponse(BaseModel): + """Response for getting a LoRA's base model""" + + lora_path: str = Field(..., description = "Path to the LoRA adapter") + base_model: str = Field(..., description = "Base model identifier") + + +class VisionCheckResponse(BaseModel): + """Response for checking if a model is a vision model""" + + model_name: str = Field(..., description = "Model identifier") + is_vision: bool = Field(..., description = "Whether the model is a vision model") + + +class EmbeddingCheckResponse(BaseModel): + """Response for checking if a model is an embedding model""" + + model_name: str = Field(..., description = "Model identifier") + is_embedding: bool = Field( + ..., description = "Whether the model is an embedding/sentence-transformer model" + ) diff --git a/studio/backend/models/training.py b/studio/backend/models/training.py new file mode 100644 index 0000000000..35bd9744a9 --- /dev/null +++ b/studio/backend/models/training.py @@ -0,0 +1,195 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Pydantic schemas for Training API +""" + +from pydantic import BaseModel, Field, model_validator +from typing import Any, Optional, List, Dict, Literal + + +class TrainingStartRequest(BaseModel): + """Request schema for starting training""" + + # Model parameters + model_name: str = Field( + ..., description = "Model identifier (e.g., 'unsloth/llama-3-8b-bnb-4bit')" + ) + training_type: str = Field( + ..., description = "Training type: 'LoRA/QLoRA' or 'Full Finetuning'" + ) + hf_token: Optional[str] = Field(None, description = "HuggingFace token") + load_in_4bit: bool = Field(True, description = "Load model in 4-bit quantization") + max_seq_length: int = Field(2048, description = "Maximum sequence length") + trust_remote_code: bool = Field( + False, + description = "Allow loading models with custom code (e.g. NVIDIA Nemotron). Only enable for repos you trust.", + ) + + # Dataset parameters + hf_dataset: Optional[str] = Field( + None, description = "HuggingFace dataset identifier" + ) + local_datasets: List[str] = Field( + default_factory = list, description = "List of local dataset paths" + ) + format_type: str = Field(..., description = "Dataset format type") + subset: Optional[str] = None + train_split: Optional[str] = Field("train", description = "Training split name") + eval_split: Optional[str] = Field( + None, description = "Eval split name. None = auto-detect" + ) + eval_steps: float = Field( + 0.00, description = "Fraction of total steps between evals (0-1)" + ) + dataset_slice_start: Optional[int] = Field( + None, description = "Inclusive start row index for dataset slicing" + ) + dataset_slice_end: Optional[int] = Field( + None, description = "Inclusive end row index for dataset slicing" + ) + + @model_validator(mode = "before") + @classmethod + def _compat_split(cls, values: Any) -> Any: + """Accept legacy 'split' field as alias for 'train_split'.""" + if isinstance(values, dict) and "split" in values: + values.setdefault("train_split", values.pop("split")) + return values + + custom_format_mapping: Optional[Dict[str, Any]] = Field( + None, + description = ( + "User-provided column-to-role mapping, e.g. {'image': 'image', 'caption': 'text'} " + "for VLM or {'instruction': 'user', 'output': 'assistant'} for LLM. " + "Enhanced format includes __system_prompt, __user_template, " + "__assistant_template, __label_mapping metadata keys." + ), + ) + # Training parameters + num_epochs: int = Field(1, description = "Number of training epochs") + learning_rate: str = Field("2e-4", description = "Learning rate") + batch_size: int = Field(1, description = "Batch size") + gradient_accumulation_steps: int = Field( + 1, description = "Gradient accumulation steps" + ) + warmup_steps: Optional[int] = Field(None, description = "Warmup steps") + warmup_ratio: Optional[float] = Field(None, description = "Warmup ratio") + max_steps: Optional[int] = Field(None, description = "Maximum training steps") + save_steps: int = Field(100, description = "Steps between checkpoints") + weight_decay: float = Field(0.01, description = "Weight decay") + random_seed: int = Field(42, description = "Random seed") + packing: bool = Field(False, description = "Enable sequence packing") + optim: str = Field("adamw_8bit", description = "Optimizer") + lr_scheduler_type: str = Field("linear", description = "Learning rate scheduler type") + + # LoRA parameters + use_lora: bool = Field(True, description = "Use LoRA (derived from training_type)") + lora_r: int = Field(16, description = "LoRA rank") + lora_alpha: int = Field(16, description = "LoRA alpha") + lora_dropout: float = Field(0.0, description = "LoRA dropout") + target_modules: List[str] = Field( + default_factory = list, description = "Target modules for LoRA" + ) + gradient_checkpointing: str = Field( + "", description = "Gradient checkpointing setting" + ) + use_rslora: bool = Field(False, description = "Use RSLoRA") + use_loftq: bool = Field(False, description = "Use LoftQ") + train_on_completions: bool = Field(False, description = "Train on completions only") + + # Vision-specific LoRA parameters + finetune_vision_layers: bool = Field(False, description = "Finetune vision layers") + finetune_language_layers: bool = Field( + False, description = "Finetune language layers" + ) + finetune_attention_modules: bool = Field( + False, description = "Finetune attention modules" + ) + finetune_mlp_modules: bool = Field(False, description = "Finetune MLP modules") + is_dataset_image: bool = Field( + False, description = "Whether the dataset contains image data" + ) + is_dataset_audio: bool = Field( + False, description = "Whether the dataset contains audio data" + ) + is_embedding: bool = Field( + False, description = "Whether model is an embedding/sentence-transformer model" + ) + + # Logging parameters + enable_wandb: bool = Field(False, description = "Enable Weights & Biases logging") + wandb_token: Optional[str] = Field(None, description = "W&B token") + wandb_project: Optional[str] = Field(None, description = "W&B project name") + enable_tensorboard: bool = Field(False, description = "Enable TensorBoard logging") + tensorboard_dir: Optional[str] = Field(None, description = "TensorBoard directory") + + +class TrainingJobResponse(BaseModel): + """Immediate response when training is initiated""" + + job_id: str = Field(..., description = "Unique training job identifier") + status: Literal["queued", "error"] = Field(..., description = "Initial job status") + message: str = Field(..., description = "Human-readable status message") + error: Optional[str] = Field(None, description = "Error details if status is 'error'") + + +class TrainingStatus(BaseModel): + """Current training job status - works for streaming or polling""" + + job_id: str = Field(..., description = "Training job identifier") + phase: Literal[ + "idle", + "loading_model", + "loading_dataset", + "configuring", + "training", + "completed", + "error", + "stopped", + ] = Field(..., description = "Current phase of training pipeline") + is_training_running: bool = Field( + ..., description = "True if training loop is actively running" + ) + eval_enabled: bool = Field( + False, + description = "True if evaluation dataset is configured for this training run", + ) + message: str = Field(..., description = "Human-readable status message") + error: Optional[str] = Field(None, description = "Error details if phase is 'error'") + details: Optional[dict] = Field( + None, description = "Phase-specific info, e.g. {'model_size': '8B'}" + ) + metric_history: Optional[dict] = Field( + None, + description = "Full metric history arrays for chart recovery after SSE reconnection. " + "Keys: 'steps', 'loss', 'lr', 'grad_norm', 'grad_norm_steps' — each a list of numeric values.", + ) + + +class TrainingProgress(BaseModel): + """Training progress metrics - for streaming or polling""" + + job_id: str = Field(..., description = "Training job identifier") + step: int = Field(..., description = "Current training step") + total_steps: int = Field(..., description = "Total training steps") + loss: float = Field(..., description = "Current loss value") + learning_rate: float = Field(..., description = "Current learning rate") + progress_percent: float = Field( + ..., description = "Progress percentage (0.0 to 100.0)" + ) + epoch: Optional[float] = Field(None, description = "Current epoch") + elapsed_seconds: Optional[float] = Field( + None, description = "Time elapsed since training started" + ) + eta_seconds: Optional[float] = Field(None, description = "Estimated time remaining") + grad_norm: Optional[float] = Field( + None, description = "L2 norm of gradients, computed before gradient clipping" + ) + num_tokens: Optional[int] = Field( + None, description = "Total number of tokens processed so far" + ) + eval_loss: Optional[float] = Field( + None, description = "Eval loss from the most recent evaluation step" + ) diff --git a/studio/backend/models/users.py b/studio/backend/models/users.py new file mode 100644 index 0000000000..8e982ed9f9 --- /dev/null +++ b/studio/backend/models/users.py @@ -0,0 +1,17 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Pydantic models for authentication tokens. + +This module defines the Token response model used by auth routes. +""" + +from pydantic import BaseModel, Field + + +class Token(BaseModel): + """Authentication token model with access and refresh tokens.""" + + access_token: str = Field(..., description = "JWT access token (60 min expiry)") + refresh_token: str = Field(..., description = "Opaque refresh token (7 day expiry)") + token_type: str = Field(..., description = "Token type, always 'bearer'") diff --git a/studio/backend/plugins/__init__.py b/studio/backend/plugins/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/studio/backend/plugins/data-designer-unstructured-seed/__init__.py b/studio/backend/plugins/data-designer-unstructured-seed/__init__.py new file mode 100644 index 0000000000..32014236c6 --- /dev/null +++ b/studio/backend/plugins/data-designer-unstructured-seed/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 diff --git a/studio/backend/plugins/data-designer-unstructured-seed/pyproject.toml b/studio/backend/plugins/data-designer-unstructured-seed/pyproject.toml new file mode 100644 index 0000000000..f826ffd992 --- /dev/null +++ b/studio/backend/plugins/data-designer-unstructured-seed/pyproject.toml @@ -0,0 +1,25 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "data-designer-unstructured-seed" +version = "0.1.0" +description = "Local Data Designer unstructured seed reader plugin" +requires-python = ">=3.11" +dependencies = [ + "data-designer-engine>=0.5.1,<0.6", + "pandas>=2,<3", +] + +[project.entry-points."data_designer.plugins"] +unstructured = "data_designer_unstructured_seed.plugin:unstructured_seed_plugin" + +[tool.setuptools] +package-dir = {"" = "src"} + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/studio/backend/plugins/data-designer-unstructured-seed/src/data_designer_unstructured_seed/__init__.py b/studio/backend/plugins/data-designer-unstructured-seed/src/data_designer_unstructured_seed/__init__.py new file mode 100644 index 0000000000..78e2e40cbf --- /dev/null +++ b/studio/backend/plugins/data-designer-unstructured-seed/src/data_designer_unstructured_seed/__init__.py @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +from .chunking import ( + DEFAULT_CHUNK_OVERLAP, + DEFAULT_CHUNK_SIZE, + build_unstructured_preview_rows, + materialize_unstructured_seed_dataset, + resolve_chunking, +) +from .config import UnstructuredSeedSource +from .impl import UnstructuredSeedReader +from .plugin import unstructured_seed_plugin + +__all__ = [ + "DEFAULT_CHUNK_OVERLAP", + "DEFAULT_CHUNK_SIZE", + "build_unstructured_preview_rows", + "materialize_unstructured_seed_dataset", + "resolve_chunking", + "UnstructuredSeedSource", + "UnstructuredSeedReader", + "unstructured_seed_plugin", +] diff --git a/studio/backend/plugins/data-designer-unstructured-seed/src/data_designer_unstructured_seed/chunking.py b/studio/backend/plugins/data-designer-unstructured-seed/src/data_designer_unstructured_seed/chunking.py new file mode 100644 index 0000000000..80f51b2a24 --- /dev/null +++ b/studio/backend/plugins/data-designer-unstructured-seed/src/data_designer_unstructured_seed/chunking.py @@ -0,0 +1,195 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +from __future__ import annotations + +import hashlib +import re +from pathlib import Path +from typing import Any + +from utils.paths import ensure_dir, unstructured_seed_cache_root + +DEFAULT_CHUNK_SIZE = 1200 +DEFAULT_CHUNK_OVERLAP = 200 +MAX_CHUNK_SIZE = 20000 +_MIN_BREAK_RATIO = 0.6 +_CACHE_DIR = unstructured_seed_cache_root() + + +def resolve_chunking( + chunk_size: Any, + chunk_overlap: Any, +) -> tuple[int, int]: + size = _to_int(chunk_size, DEFAULT_CHUNK_SIZE) + size = max(1, min(size, MAX_CHUNK_SIZE)) + overlap = _to_int(chunk_overlap, DEFAULT_CHUNK_OVERLAP) + overlap = max(0, min(overlap, max(0, size - 1))) + return size, overlap + + +def build_unstructured_preview_rows( + *, + source_path: Path, + preview_size: int, + chunk_size: Any, + chunk_overlap: Any, +) -> list[dict[str, str]]: + parquet_path, rows = materialize_unstructured_seed_dataset( + source_path = source_path, + chunk_size = chunk_size, + chunk_overlap = chunk_overlap, + ) + count = max(0, int(preview_size)) + if rows: + return rows[:count] + + try: + import pandas as pd + except ImportError as exc: # pragma: no cover + raise RuntimeError( + f"pandas is required for unstructured seed processing: {exc}" + ) from exc + + dataframe = pd.read_parquet(parquet_path).head(count) + return [ + {"chunk_text": str(value.get("chunk_text", "")).strip()} + for value in dataframe.to_dict(orient = "records") + if str(value.get("chunk_text", "")).strip() + ] + + +def materialize_unstructured_seed_dataset( + *, + source_path: Path, + chunk_size: Any, + chunk_overlap: Any, +) -> tuple[Path, list[dict[str, str]]]: + resolved = source_path.expanduser().resolve() + if not resolved.is_file(): + raise FileNotFoundError(f"unstructured seed file not found: {resolved}") + + size, overlap = resolve_chunking(chunk_size, chunk_overlap) + key = _compute_cache_key( + source_path = resolved, + chunk_size = size, + chunk_overlap = overlap, + ) + parquet_path = _CACHE_DIR / f"{key}.parquet" + if parquet_path.exists(): + return parquet_path, [] + + text = load_unstructured_text_file(resolved) + chunks = split_text_into_chunks( + text = text, + chunk_size = size, + chunk_overlap = overlap, + ) + if not chunks: + raise ValueError("No text found in unstructured seed source.") + + rows = [{"chunk_text": chunk} for chunk in chunks] + ensure_dir(_CACHE_DIR) + try: + import pandas as pd + except ImportError as exc: # pragma: no cover + raise RuntimeError( + f"pandas is required for unstructured seed processing: {exc}" + ) from exc + + tmp_path = _CACHE_DIR / f"{key}.tmp.parquet" + pd.DataFrame(rows).to_parquet(tmp_path, index = False) + tmp_path.replace(parquet_path) + return parquet_path, rows + + +def load_unstructured_text_file(path: Path) -> str: + ext = path.suffix.lower() + if ext not in {".txt", ".md"}: + raise ValueError(f"Unsupported unstructured seed file type: {ext}") + + raw = path.read_text(encoding = "utf-8", errors = "ignore") + return normalize_unstructured_text(raw) + + +def normalize_unstructured_text(text: str) -> str: + normalized = text.replace("\r\n", "\n").replace("\r", "\n") + return re.sub(r"\n{3,}", "\n\n", normalized).strip() + + +def split_text_into_chunks( + *, + text: str, + chunk_size: int, + chunk_overlap: int, +) -> list[str]: + if not text: + return [] + if chunk_size <= 0: + return [text] + + chunks: list[str] = [] + start = 0 + min_break_index = int(chunk_size * _MIN_BREAK_RATIO) + text_len = len(text) + while start < text_len: + end = min(text_len, start + chunk_size) + if end < text_len: + window = text[start:end] + cut = _find_break_index(window, min_break_index) + if cut is not None and cut > 0: + end = start + cut + + if end <= start: + end = min(text_len, start + chunk_size) + + chunk = text[start:end].strip() + if chunk: + chunks.append(chunk) + if end >= text_len: + break + + next_start = end - chunk_overlap + if next_start <= start: + next_start = end + start = max(0, next_start) + + return chunks + + +def _find_break_index(window: str, min_index: int) -> int | None: + breakpoints = ["\n\n", "\n", " "] + for token in breakpoints: + idx = window.rfind(token) + if idx >= min_index: + return idx + len(token) + return None + + +def _to_int(value: Any, fallback: int) -> int: + if isinstance(value, bool): + return fallback + try: + parsed = int(str(value).strip()) + except (TypeError, ValueError): + return fallback + return parsed + + +def _compute_cache_key( + *, + source_path: Path, + chunk_size: int, + chunk_overlap: int, +) -> str: + stat = source_path.stat() + payload = "|".join( + [ + str(source_path), + str(stat.st_size), + str(stat.st_mtime_ns), + str(chunk_size), + str(chunk_overlap), + ] + ).encode("utf-8") + return hashlib.sha256(payload).hexdigest() diff --git a/studio/backend/plugins/data-designer-unstructured-seed/src/data_designer_unstructured_seed/config.py b/studio/backend/plugins/data-designer-unstructured-seed/src/data_designer_unstructured_seed/config.py new file mode 100644 index 0000000000..e0a0392a69 --- /dev/null +++ b/studio/backend/plugins/data-designer-unstructured-seed/src/data_designer_unstructured_seed/config.py @@ -0,0 +1,41 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +from __future__ import annotations + +from pathlib import Path +from typing import Literal + +from pydantic import Field, field_validator + +from data_designer.config.seed_source import SeedSource + +from .chunking import DEFAULT_CHUNK_OVERLAP, DEFAULT_CHUNK_SIZE, resolve_chunking + + +class UnstructuredSeedSource(SeedSource): + seed_type: Literal["unstructured"] = "unstructured" + path: str = Field(..., min_length = 1) + chunk_size: int = DEFAULT_CHUNK_SIZE + chunk_overlap: int = DEFAULT_CHUNK_OVERLAP + + @field_validator("path", mode = "after") + @classmethod + def _validate_path(cls, value: str) -> str: + path = Path(value).expanduser() + if not path.is_file(): + raise ValueError(f"Unstructured seed path is not a file: {path}") + return value + + @field_validator("chunk_size", mode = "after") + @classmethod + def _validate_chunk_size(cls, value: int) -> int: + size, _ = resolve_chunking(value, 0) + return size + + @field_validator("chunk_overlap", mode = "after") + @classmethod + def _validate_chunk_overlap(cls, value: int, info) -> int: + size = info.data.get("chunk_size", cls.model_fields["chunk_size"].default) + _, overlap = resolve_chunking(size, value) + return overlap diff --git a/studio/backend/plugins/data-designer-unstructured-seed/src/data_designer_unstructured_seed/impl.py b/studio/backend/plugins/data-designer-unstructured-seed/src/data_designer_unstructured_seed/impl.py new file mode 100644 index 0000000000..8a3deb9b92 --- /dev/null +++ b/studio/backend/plugins/data-designer-unstructured-seed/src/data_designer_unstructured_seed/impl.py @@ -0,0 +1,25 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +from __future__ import annotations + +from pathlib import Path + +import data_designer.lazy_heavy_imports as lazy +from data_designer.engine.resources.seed_reader import SeedReader + +from .chunking import materialize_unstructured_seed_dataset +from .config import UnstructuredSeedSource + + +class UnstructuredSeedReader(SeedReader[UnstructuredSeedSource]): + def create_duckdb_connection(self): + return lazy.duckdb.connect() + + def get_dataset_uri(self) -> str: + path, _ = materialize_unstructured_seed_dataset( + source_path = Path(self.source.path), + chunk_size = self.source.chunk_size, + chunk_overlap = self.source.chunk_overlap, + ) + return str(path) diff --git a/studio/backend/plugins/data-designer-unstructured-seed/src/data_designer_unstructured_seed/plugin.py b/studio/backend/plugins/data-designer-unstructured-seed/src/data_designer_unstructured_seed/plugin.py new file mode 100644 index 0000000000..6f0d7ffd49 --- /dev/null +++ b/studio/backend/plugins/data-designer-unstructured-seed/src/data_designer_unstructured_seed/plugin.py @@ -0,0 +1,10 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +from data_designer.plugins.plugin import Plugin, PluginType + +unstructured_seed_plugin = Plugin( + impl_qualified_name = "data_designer_unstructured_seed.impl.UnstructuredSeedReader", + config_qualified_name = "data_designer_unstructured_seed.config.UnstructuredSeedSource", + plugin_type = PluginType.SEED_READER, +) diff --git a/studio/backend/requirements/__init__.py b/studio/backend/requirements/__init__.py new file mode 100644 index 0000000000..32014236c6 --- /dev/null +++ b/studio/backend/requirements/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 diff --git a/studio/backend/requirements/base.txt b/studio/backend/requirements/base.txt new file mode 100644 index 0000000000..407ae01b52 --- /dev/null +++ b/studio/backend/requirements/base.txt @@ -0,0 +1,3 @@ +# Core unsloth packages +unsloth-zoo +unsloth diff --git a/studio/backend/requirements/extras-no-deps.txt b/studio/backend/requirements/extras-no-deps.txt new file mode 100644 index 0000000000..29bd421a0a --- /dev/null +++ b/studio/backend/requirements/extras-no-deps.txt @@ -0,0 +1,14 @@ +# Audio extras (installed with --no-deps --no-cache-dir) +descript-audio-codec +descript-audiotools +julius +torchcodec +snac + +# TRL and related packages +trl==0.23.1 +git+https://github.com/meta-pytorch/OpenEnv.git +executorch>=1.0.1 +torch-c-dlpack-ext +sentence_transformers==5.2.0 +transformers==4.57.1 diff --git a/studio/backend/requirements/extras.txt b/studio/backend/requirements/extras.txt new file mode 100644 index 0000000000..51ef69cf5d --- /dev/null +++ b/studio/backend/requirements/extras.txt @@ -0,0 +1,56 @@ +# OpenEnv dependencies +tomli +tomli-w + +# ExecuTorch dependencies +ruamel.yaml +coremltools +expecttest +flatbuffers +hydra-core +hypothesis +kgb +parameterized +pytest<9.0 +pytest-json-report +pytest-rerunfailures==15.1 +pytest-xdist +# Also needed by sentence_transformers +scikit-learn==1.7.1 + +# Additional extras +pybind11 +langid +jiwer +omegaconf +einx +pyloudnorm +openai-whisper +uroman +MeCab +loguru +flatten_dict +ffmpy +randomname +argbind +tiktoken +ftfy +importlib-resources +librosa +markdown2 +matplotlib +pystoi +soundfile +tensorboard +torch-stoi +evaluate +timm +transformers-cfg +open_spiel +addict +easydict +einops +tabulate +fastmcp>=3.0.2 +openai>=2.7.2 +websockets>=15.0.1 diff --git a/studio/backend/requirements/overrides.txt b/studio/backend/requirements/overrides.txt new file mode 100644 index 0000000000..6852f601ed --- /dev/null +++ b/studio/backend/requirements/overrides.txt @@ -0,0 +1,6 @@ +# Torch AO overrides (installed with --force-reinstall --no-cache-dir) +torchao==0.14.0 +pytorch_tokenizers + +# Kernel packages +kernels diff --git a/studio/backend/requirements/single-env/constraints.txt b/studio/backend/requirements/single-env/constraints.txt new file mode 100644 index 0000000000..1789bbf713 --- /dev/null +++ b/studio/backend/requirements/single-env/constraints.txt @@ -0,0 +1,16 @@ +# Single-env pins for unsloth + studio + data-designer +# Keep compatible with unsloth transformers bounds. +transformers==4.57.1 +trl==0.23.1 +huggingface-hub==0.36.2 + +# Studio stack +datasets==4.3.0 +pyarrow==23.0.1 + +# FastMCP/OpenEnv compat +fastmcp>=3.0.2 +mcp>=1.24,<2 +websockets>=15.0.1 + +pandas==2.3.3 diff --git a/studio/backend/requirements/single-env/data-designer-deps.txt b/studio/backend/requirements/single-env/data-designer-deps.txt new file mode 100644 index 0000000000..dfc5b9bf21 --- /dev/null +++ b/studio/backend/requirements/single-env/data-designer-deps.txt @@ -0,0 +1,19 @@ +# Data Designer runtime deps installed explicitly (single-env mode). +# DuckDB 1.5 removed Relation.record_batch(); keep <1.5 until upstream ships the fix. +anyascii<1,>=0.3.3 +duckdb<1.5,>=1.1.3 +faker<21,>=20.1.0 +httpx<1,>=0.27.2 +httpx-retries<1,>=0.4.2 +json-repair<1,>=0.48.0 +jsonpath-rust-bindings<2,>=1.0 +jsonschema<5,>=4.0.0 +litellm<1.80.12,>=1.73.6 +lxml<7,>=6.0.2 +marko<3,>=2.1.2 +networkx<4,>=3.0 +python-json-logger<4,>=3 +ruff<1,>=0.14.10 +scipy<2,>=1.11.0 +sqlfluff<4,>=3.2.0 +tiktoken<1,>=0.8.0 diff --git a/studio/backend/requirements/single-env/data-designer.txt b/studio/backend/requirements/single-env/data-designer.txt new file mode 100644 index 0000000000..8daa1eca43 --- /dev/null +++ b/studio/backend/requirements/single-env/data-designer.txt @@ -0,0 +1,5 @@ +# Install Data Designer in same env as Unsloth. +data-designer==0.5.2 +data-designer-config==0.5.2 +data-designer-engine==0.5.2 +prompt-toolkit>=3,<4 diff --git a/studio/backend/requirements/single-env/patch_metadata.py b/studio/backend/requirements/single-env/patch_metadata.py new file mode 100644 index 0000000000..7bcaa56ac8 --- /dev/null +++ b/studio/backend/requirements/single-env/patch_metadata.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Relax strict metadata pins so pip check matches known working single-env stack. + +Why: +- data-designer pins huggingface-hub>=1.0.1 and pyarrow<20. +- unsloth/transformers pins huggingface-hub<1. +- studio datasets pins pyarrow>=21. + +Runtime works in this app with hub 0.36.x + pyarrow 23.x, but metadata conflicts. +""" + +from __future__ import annotations + +import importlib.metadata as im +import re +from pathlib import Path + +TARGETS = ( + "data-designer", + "data-designer-engine", + "data-designer-config", +) + +PATCHES: tuple[tuple[re.Pattern[str], str], ...] = ( + ( + re.compile(r"^Requires-Dist: huggingface-hub<2,>=1\.0\.1$", re.MULTILINE), + "Requires-Dist: huggingface-hub<2,>=0.34.0", + ), + ( + re.compile(r"^Requires-Dist: pyarrow<20,>=19\.0\.1$", re.MULTILINE), + "Requires-Dist: pyarrow>=21.0.0", + ), +) + + +def metadata_path(dist_name: str) -> Path | None: + try: + dist = im.distribution(dist_name) + except im.PackageNotFoundError: + return None + for f in dist.files or []: + sf = str(f) + if sf.endswith(".dist-info/METADATA"): + return Path(dist.locate_file(f)) + return None + + +def patch_file(path: Path) -> bool: + original = path.read_text(encoding = "utf-8") + updated = original + for pattern, repl in PATCHES: + updated = pattern.sub(repl, updated) + if updated == original: + return False + path.write_text(updated, encoding = "utf-8") + return True + + +def main() -> int: + changed = 0 + checked = 0 + for name in TARGETS: + p = metadata_path(name) + if p is None: + continue + checked += 1 + if patch_file(p): + changed += 1 + print(f"single-env metadata patch: checked={checked}, changed={changed}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/studio/backend/requirements/studio.txt b/studio/backend/requirements/studio.txt new file mode 100644 index 0000000000..04592147b9 --- /dev/null +++ b/studio/backend/requirements/studio.txt @@ -0,0 +1,15 @@ +# Studio UI backend dependencies +typer +fastapi +uvicorn +pydantic +matplotlib +pandas +nest_asyncio +datasets==4.3.0 +pyjwt +easydict +addict +gradio>=4.0.0 +huggingface-hub==0.36.2 +structlog>=24.1.0 diff --git a/studio/backend/requirements/triton-kernels.txt b/studio/backend/requirements/triton-kernels.txt new file mode 100644 index 0000000000..17e265b35e --- /dev/null +++ b/studio/backend/requirements/triton-kernels.txt @@ -0,0 +1,2 @@ +# Triton kernels (installed with --no-deps, from source) +triton_kernels @ git+https://github.com/triton-lang/triton.git@release/3.6.x#subdirectory=python/triton_kernels diff --git a/studio/backend/routes/.gitkeep b/studio/backend/routes/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/studio/backend/routes/__init__.py b/studio/backend/routes/__init__.py new file mode 100644 index 0000000000..b45eff821b --- /dev/null +++ b/studio/backend/routes/__init__.py @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +API Routes +""" + +from routes.training import router as training_router +from routes.models import router as models_router +from routes.inference import router as inference_router +from routes.datasets import router as datasets_router +from routes.auth import router as auth_router +from routes.data_recipe import router as data_recipe_router +from routes.export import router as export_router + +__all__ = [ + "training_router", + "models_router", + "inference_router", + "datasets_router", + "auth_router", + "data_recipe_router", + "export_router", +] diff --git a/studio/backend/routes/auth.py b/studio/backend/routes/auth.py new file mode 100644 index 0000000000..83cc41e83b --- /dev/null +++ b/studio/backend/routes/auth.py @@ -0,0 +1,140 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Authentication API routes +""" + +from fastapi import APIRouter, HTTPException, status +import secrets + +from models.auth import ( + AuthSetupRequest, + AuthLoginRequest, + RefreshTokenRequest, + AuthStatusResponse, +) +from models.users import Token +from auth import storage, hashing +from auth.authentication import ( + create_access_token, + create_refresh_token, + refresh_access_token, + reload_secret, +) + +router = APIRouter() + + +@router.get("/status", response_model = AuthStatusResponse) +async def auth_status() -> AuthStatusResponse: + """ + Check whether auth has already been initialized. + + - initialized = False -> frontend should show "Set admin password" screen. + - initialized = True -> frontend should show normal login. + """ + return AuthStatusResponse(initialized = storage.is_initialized()) + + +@router.post("/setup", response_model = Token, status_code = status.HTTP_201_CREATED) +async def setup_auth(payload: AuthSetupRequest) -> Token: + """ + First-time setup: create the admin user and a JWT secret. + + Requires a valid setup token (printed to the server console on startup). + Can only be called once. Subsequent calls will return 400. + """ + if storage.is_initialized(): + raise HTTPException( + status_code = status.HTTP_400_BAD_REQUEST, + detail = "Auth is already initialized.", + ) + + # Validate the one-time setup token + if not storage.consume_setup_token(payload.setup_token): + raise HTTPException( + status_code = status.HTTP_403_FORBIDDEN, + detail = "Invalid or expired setup token.", + ) + + # Generate a strong random JWT secret for this installation + jwt_secret = secrets.token_urlsafe(64) + + # Create user + generate tokens atomically — rollback if anything fails + try: + storage.create_initial_user( + username = payload.username, + password = payload.password, + jwt_secret = jwt_secret, + ) + + # Reload JWT secret from DB (so authentication.py picks it up) + reload_secret() + + # Issue access + refresh tokens for the new user + access_token = create_access_token(subject = payload.username) + refresh_token = create_refresh_token(subject = payload.username) + + except Exception as e: + # Rollback: remove the user row so setup can be retried + storage.delete_user(payload.username) + raise HTTPException( + status_code = status.HTTP_500_INTERNAL_SERVER_ERROR, + detail = f"Setup failed (rolled back): {str(e)}", + ) + + return Token( + access_token = access_token, + refresh_token = refresh_token, + token_type = "bearer", + ) + + +@router.post("/login", response_model = Token) +async def login(payload: AuthLoginRequest) -> Token: + """ + Login with username/password and receive access + refresh tokens. + """ + record = storage.get_user_and_secret(payload.username) + if record is None: + raise HTTPException( + status_code = status.HTTP_401_UNAUTHORIZED, + detail = "Incorrect username or password", + ) + + salt, pwd_hash, _jwt_secret = record + if not hashing.verify_password(payload.password, salt, pwd_hash): + raise HTTPException( + status_code = status.HTTP_401_UNAUTHORIZED, + detail = "Incorrect username or password", + ) + + access_token = create_access_token(subject = payload.username) + refresh_token = create_refresh_token(subject = payload.username) + return Token( + access_token = access_token, + refresh_token = refresh_token, + token_type = "bearer", + ) + + +@router.post("/refresh", response_model = Token) +async def refresh(payload: RefreshTokenRequest) -> Token: + """ + Exchange a valid refresh token for a new access token. + + The refresh token itself is reusable until it expires (7 days). + """ + new_access_token = refresh_access_token(payload.refresh_token) + if new_access_token is None: + raise HTTPException( + status_code = status.HTTP_401_UNAUTHORIZED, + detail = "Invalid or expired refresh token", + ) + + return Token( + access_token = new_access_token, + refresh_token = payload.refresh_token, + token_type = "bearer", + ) diff --git a/studio/backend/routes/data_recipe/__init__.py b/studio/backend/routes/data_recipe/__init__.py new file mode 100644 index 0000000000..c596f189d4 --- /dev/null +++ b/studio/backend/routes/data_recipe/__init__.py @@ -0,0 +1,30 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Data Recipe route package.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +from fastapi import APIRouter, Depends + +from auth.authentication import get_current_subject + +backend_path = Path(__file__).parent.parent.parent +if str(backend_path) not in sys.path: + sys.path.insert(0, str(backend_path)) + +from .jobs import router as jobs_router +from .mcp import router as mcp_router +from .seed import router as seed_router +from .validate import router as validate_router + +router = APIRouter(dependencies = [Depends(get_current_subject)]) +router.include_router(seed_router) +router.include_router(validate_router) +router.include_router(jobs_router) +router.include_router(mcp_router) + +__all__ = ["router"] diff --git a/studio/backend/routes/data_recipe/jobs.py b/studio/backend/routes/data_recipe/jobs.py new file mode 100644 index 0000000000..4661615338 --- /dev/null +++ b/studio/backend/routes/data_recipe/jobs.py @@ -0,0 +1,165 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Job lifecycle endpoints for data recipe.""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, HTTPException, Query, Request +from fastapi.responses import JSONResponse, StreamingResponse +from pydantic import ValidationError + +from core.data_recipe.jobs import get_job_manager +from models.data_recipe import JobCreateResponse, RecipePayload + +router = APIRouter() + + +def _normalize_run_name(value: Any) -> str | None: + if value is None: + return None + if not isinstance(value, str): + raise HTTPException( + status_code = 400, detail = "invalid run_name: must be a string" + ) + trimmed = value.strip() + if not trimmed: + return None + return trimmed[:120] + + +@router.post("/jobs", response_class = JSONResponse, response_model = JobCreateResponse) +def create_job(payload: RecipePayload): + recipe = payload.recipe + if not recipe.get("columns"): + raise HTTPException(status_code = 400, detail = "Recipe must include columns.") + + run: dict[str, Any] = payload.run or {} + run.pop("artifact_path", None) + run.pop("dataset_name", None) + execution_type = str(run.get("execution_type") or "full").strip().lower() + if execution_type not in {"preview", "full"}: + raise HTTPException( + status_code = 400, + detail = "invalid execution_type: must be 'preview' or 'full'", + ) + run["execution_type"] = execution_type + run["run_name"] = _normalize_run_name(run.get("run_name")) + run_config_raw = run.get("run_config") + if run_config_raw is not None: + try: + from data_designer.config.run_config import RunConfig + + RunConfig.model_validate(run_config_raw) + except (ImportError, ValidationError, TypeError, ValueError) as exc: + raise HTTPException( + status_code = 400, detail = f"invalid run_config: {exc}" + ) from exc + + mgr = get_job_manager() + try: + job_id = mgr.start(recipe = recipe, run = run) + except RuntimeError as exc: + raise HTTPException(status_code = 409, detail = str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code = 400, detail = str(exc)) from exc + + return {"job_id": job_id} + + +@router.get("/jobs/{job_id}/status") +def job_status(job_id: str): + mgr = get_job_manager() + state = mgr.get_status(job_id) + if state is None: + raise HTTPException(status_code = 404, detail = "job not found") + return state + + +@router.get("/jobs/current") +def current_job(): + mgr = get_job_manager() + state = mgr.get_current_status() + if state is None: + raise HTTPException(status_code = 404, detail = "no job") + return state + + +@router.post("/jobs/{job_id}/cancel") +def cancel_job(job_id: str): + mgr = get_job_manager() + ok = mgr.cancel(job_id) + if not ok: + raise HTTPException(status_code = 404, detail = "job not found") + return mgr.get_status(job_id) + + +@router.get("/jobs/{job_id}/analysis") +def job_analysis(job_id: str): + mgr = get_job_manager() + analysis = mgr.get_analysis(job_id) + if analysis is None: + raise HTTPException(status_code = 404, detail = "analysis not ready") + return analysis + + +@router.get("/jobs/{job_id}/dataset") +def job_dataset( + job_id: str, + limit: int = Query(default = 20, ge = 1, le = 500), + offset: int = Query(default = 0, ge = 0), +): + mgr = get_job_manager() + result = mgr.get_dataset(job_id, limit = limit, offset = offset) + if result is None: + raise HTTPException(status_code = 404, detail = "dataset not ready") + if "error" in result: + raise HTTPException(status_code = 422, detail = result["error"]) + return { + "dataset": result["dataset"], + "total": result["total"], + "limit": limit, + "offset": offset, + } + + +@router.get("/jobs/{job_id}/events") +async def job_events(request: Request, job_id: str): + mgr = get_job_manager() + last_id = request.headers.get("last-event-id") + after_seq: int | None = None + if last_id: + try: + after_seq = int(str(last_id).strip()) + except (TypeError, ValueError): + after_seq = None + + after_q = request.query_params.get("after") + if after_q: + try: + after_seq = int(str(after_q).strip()) + except (TypeError, ValueError): + pass + + sub = mgr.subscribe(job_id, after_seq = after_seq) + if sub is None: + raise HTTPException(status_code = 404, detail = "job not found") + + async def gen(): + try: + for event in sub.replay: + yield sub.format_sse(event) + + while True: + if await request.is_disconnected(): + break + event = await sub.next_event(timeout_sec = 1.0) + if event is None: + continue + yield sub.format_sse(event) + finally: + mgr.unsubscribe(sub) + + return StreamingResponse(gen(), media_type = "text/event-stream") diff --git a/studio/backend/routes/data_recipe/mcp.py b/studio/backend/routes/data_recipe/mcp.py new file mode 100644 index 0000000000..1f5c0f34e0 --- /dev/null +++ b/studio/backend/routes/data_recipe/mcp.py @@ -0,0 +1,82 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""MCP helper endpoints for data recipe.""" + +from __future__ import annotations + +from collections import defaultdict + +from fastapi import APIRouter + +from core.data_recipe.service import build_mcp_providers +from models.data_recipe import ( + McpToolsListRequest, + McpToolsListResponse, + McpToolsProviderResult, +) + +router = APIRouter() + + +@router.post("/mcp/tools", response_model = McpToolsListResponse) +def list_mcp_tools(payload: McpToolsListRequest) -> McpToolsListResponse: + try: + from data_designer.engine.mcp import io as mcp_io + except ImportError as exc: + return McpToolsListResponse( + providers = [ + McpToolsProviderResult( + name = "", + error = f"MCP dependencies unavailable: {exc}", + ) + ] + ) + + providers: list[McpToolsProviderResult] = [] + tool_to_providers: dict[str, list[str]] = defaultdict(list) + + for provider_payload in payload.mcp_providers: + provider_name = str(provider_payload.get("name", "")).strip() + built = build_mcp_providers({"mcp_providers": [provider_payload]}) + if len(built) != 1: + providers.append( + McpToolsProviderResult( + name = provider_name, + error = "Unsupported MCP provider config.", + ) + ) + continue + + provider = built[0] + try: + tools = mcp_io.list_tools(provider, timeout_sec = payload.timeout_sec) + tool_names = sorted( + {tool.name for tool in tools if getattr(tool, "name", "")} + ) + for tool_name in tool_names: + tool_to_providers[tool_name].append(provider.name) + providers.append( + McpToolsProviderResult( + name = provider.name, + tools = tool_names, + ) + ) + except Exception as exc: + providers.append( + McpToolsProviderResult( + name = provider.name or provider_name, + error = str(exc).strip() or "Failed to load tools.", + ) + ) + + duplicate_tools = { + tool_name: provider_names + for tool_name, provider_names in sorted(tool_to_providers.items()) + if len(provider_names) > 1 + } + + return McpToolsListResponse( + providers = providers, + duplicate_tools = duplicate_tools, + ) diff --git a/studio/backend/routes/data_recipe/seed.py b/studio/backend/routes/data_recipe/seed.py new file mode 100644 index 0000000000..76e05be954 --- /dev/null +++ b/studio/backend/routes/data_recipe/seed.py @@ -0,0 +1,365 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Seed inspect endpoints for data recipe.""" + +from __future__ import annotations + +import base64 +import binascii +from itertools import islice +from pathlib import Path +from typing import Any +from uuid import uuid4 + +from fastapi import APIRouter, HTTPException +from data_designer_unstructured_seed.chunking import ( + build_unstructured_preview_rows, + resolve_chunking, +) +from core.data_recipe.jsonable import to_preview_jsonable +from utils.paths import ensure_dir, seed_uploads_root + +from models.data_recipe import ( + SeedInspectRequest, + SeedInspectResponse, + SeedInspectUploadRequest, +) + +router = APIRouter() + +DATA_EXTS = (".parquet", ".jsonl", ".json", ".csv") +DEFAULT_SPLIT = "train" +LOCAL_UPLOAD_EXTS = {".csv", ".json", ".jsonl"} +UNSTRUCTURED_UPLOAD_EXTS = {".txt", ".md"} +SEED_UPLOAD_DIR = seed_uploads_root() + + +def _serialize_preview_value(value: Any) -> Any: + return to_preview_jsonable(value) + + +def _serialize_preview_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + return [ + {str(key): _serialize_preview_value(value) for key, value in row.items()} + for row in rows + ] + + +def _normalize_optional_text(value: str | None) -> str | None: + if value is None: + return None + trimmed = value.strip() + return trimmed if trimmed else None + + +def _list_hf_data_files(*, dataset_name: str, token: str | None) -> list[str]: + try: + from huggingface_hub import HfApi + from huggingface_hub.utils import HfHubHTTPError + except ImportError: + return [] + try: + api = HfApi() + repo_files = api.list_repo_files(dataset_name, repo_type = "dataset", token = token) + return [file for file in repo_files if file.lower().endswith(DATA_EXTS)] + except (HfHubHTTPError, OSError, ValueError): + return [] + + +def _select_best_file(data_files: list[str], split: str = DEFAULT_SPLIT) -> str | None: + if not data_files: + return None + split_lower = split.lower() + + def score(path: str) -> tuple[int, int]: + name = path.lower() + if f"/{split_lower}/" in name: + return (0, len(path)) + if ( + f"_{split_lower}." in name + or f"-{split_lower}." in name + or f"/{split_lower}." in name + or f"/{split_lower}_" in name + or f"/{split_lower}-" in name + ): + return (1, len(path)) + return (2, len(path)) + + return sorted(data_files, key = score)[0] + + +def _resolve_seed_hf_path( + dataset_name: str, data_files: list[str], split: str = DEFAULT_SPLIT +) -> str | None: + selected = _select_best_file(data_files, split) + if not selected: + return None + + ext = Path(selected).suffix.lower() + if ext not in DATA_EXTS: + return f"datasets/{dataset_name}/{selected}" + + parent = Path(selected).parent.as_posix() + if not parent or parent == ".": + return f"datasets/{dataset_name}/**/*{ext}" + return f"datasets/{dataset_name}/{parent}/**/*{ext}" + + +def _build_stream_load_kwargs( + *, + dataset_name: str, + split: str, + subset: str | None, + token: str | None, + data_file: str | None = None, +) -> dict[str, Any]: + kwargs: dict[str, Any] = { + "path": dataset_name, + "split": split, + "streaming": True, + } + if data_file: + kwargs["data_files"] = [data_file] + if subset: + kwargs["name"] = subset + if token: + kwargs["token"] = token + return kwargs + + +def _load_preview_rows( + *, + load_dataset_fn, + load_kwargs: dict[str, Any], + preview_size: int, +) -> list[dict[str, Any]]: + streamed_ds = load_dataset_fn(**load_kwargs) + return [row for row in islice(streamed_ds, preview_size)] + + +def _extract_columns(rows: list[dict[str, Any]]) -> list[str]: + columns_seen: dict[str, None] = {} + for row in rows: + for key in row.keys(): + columns_seen[str(key)] = None + return list(columns_seen.keys()) + + +def _sanitize_filename(filename: str) -> str: + name = Path(filename).name.strip().replace("\x00", "") + if not name: + return "seed_upload" + return name + + +def _decode_base64_payload(content_base64: str) -> bytes: + raw = content_base64.strip() + if "," in raw and raw.lower().startswith("data:"): + raw = raw.split(",", 1)[1] + try: + return base64.b64decode(raw, validate = True) + except binascii.Error as exc: + raise HTTPException(status_code = 400, detail = "invalid base64 payload") from exc + + +def _read_preview_rows_from_local_file( + path: Path, preview_size: int +) -> list[dict[str, Any]]: + try: + import pandas as pd + except ImportError as exc: + raise HTTPException( + status_code = 500, detail = f"seed inspect dependencies unavailable: {exc}" + ) from exc + + ext = path.suffix.lower() + try: + if ext == ".csv": + df = pd.read_csv(path, nrows = preview_size) + elif ext == ".jsonl": + df = pd.read_json(path, lines = True).head(preview_size) + elif ext == ".json": + try: + df = pd.read_json(path).head(preview_size) + except ValueError: + df = pd.read_json(path, lines = True).head(preview_size) + else: + raise HTTPException(status_code = 422, detail = f"unsupported file type: {ext}") + except HTTPException: + raise + except (ValueError, OSError) as exc: + raise HTTPException( + status_code = 422, detail = f"seed inspect failed: {exc}" + ) from exc + + rows = df.to_dict(orient = "records") + return _serialize_preview_rows(rows) + + +def _read_preview_rows_from_unstructured_file( + *, + path: Path, + preview_size: int, + chunk_size: int | None, + chunk_overlap: int | None, +) -> list[dict[str, Any]]: + size, overlap = resolve_chunking(chunk_size, chunk_overlap) + try: + rows = build_unstructured_preview_rows( + source_path = path, + preview_size = preview_size, + chunk_size = size, + chunk_overlap = overlap, + ) + except (FileNotFoundError, RuntimeError, ValueError, OSError) as exc: + raise HTTPException( + status_code = 422, detail = f"seed inspect failed: {exc}" + ) from exc + return _serialize_preview_rows(rows) + + +@router.post("/seed/inspect", response_model = SeedInspectResponse) +def inspect_seed_dataset(payload: SeedInspectRequest) -> SeedInspectResponse: + dataset_name = payload.dataset_name.strip() + if not dataset_name or dataset_name.count("/") < 1: + raise HTTPException( + status_code = 400, + detail = "dataset_name must be a Hugging Face repo id like org/repo", + ) + + try: + from datasets import load_dataset + except ImportError as exc: + raise HTTPException( + status_code = 500, detail = f"seed inspect dependencies unavailable: {exc}" + ) from exc + + split = _normalize_optional_text(payload.split) or DEFAULT_SPLIT + subset = _normalize_optional_text(payload.subset) + token = _normalize_optional_text(payload.hf_token) + preview_size = int(payload.preview_size) + + preview_rows: list[dict[str, Any]] = [] + data_files = _list_hf_data_files(dataset_name = dataset_name, token = token) + + selected_file = _select_best_file(data_files, split) + if selected_file: + try: + single_file_kwargs = _build_stream_load_kwargs( + dataset_name = dataset_name, + split = split, + subset = subset, + token = token, + data_file = selected_file, + ) + preview_rows = _load_preview_rows( + load_dataset_fn = load_dataset, + load_kwargs = single_file_kwargs, + preview_size = preview_size, + ) + except (ValueError, OSError, RuntimeError): + preview_rows = [] + + if not preview_rows: + try: + split_kwargs = _build_stream_load_kwargs( + dataset_name = dataset_name, + split = split, + subset = subset, + token = token, + ) + preview_rows = _load_preview_rows( + load_dataset_fn = load_dataset, + load_kwargs = split_kwargs, + preview_size = preview_size, + ) + except (ValueError, OSError, RuntimeError) as exc: + raise HTTPException( + status_code = 422, detail = f"seed inspect failed: {exc}" + ) from exc + + if not preview_rows: + raise HTTPException( + status_code = 422, detail = "dataset appears empty or unreadable" + ) + preview_rows = _serialize_preview_rows(preview_rows) + columns = _extract_columns(preview_rows) + + if not data_files: + resolved_path = f"datasets/{dataset_name}/**/*.parquet" + else: + resolved_path = _resolve_seed_hf_path(dataset_name, data_files, split) + if not resolved_path: + raise HTTPException( + status_code = 422, detail = "unable to resolve seed dataset path" + ) + + return SeedInspectResponse( + dataset_name = dataset_name, + resolved_path = resolved_path, + columns = columns, + preview_rows = preview_rows, + split = split, + subset = subset, + ) + + +@router.post("/seed/inspect-upload", response_model = SeedInspectResponse) +def inspect_seed_upload(payload: SeedInspectUploadRequest) -> SeedInspectResponse: + seed_source_type = _normalize_optional_text(payload.seed_source_type) or "local" + filename = _sanitize_filename(payload.filename) + ext = Path(filename).suffix.lower() + if seed_source_type == "unstructured": + if ext not in UNSTRUCTURED_UPLOAD_EXTS: + allowed = ", ".join(sorted(UNSTRUCTURED_UPLOAD_EXTS)) + raise HTTPException( + status_code = 400, + detail = f"unsupported file type: {ext}. allowed: {allowed}", + ) + else: + if ext not in LOCAL_UPLOAD_EXTS: + allowed = ", ".join(sorted(LOCAL_UPLOAD_EXTS)) + raise HTTPException( + status_code = 400, + detail = f"unsupported file type: {ext}. allowed: {allowed}", + ) + + file_bytes = _decode_base64_payload(payload.content_base64) + if not file_bytes: + raise HTTPException(status_code = 400, detail = "empty upload payload") + max_size_bytes = 50 * 1024 * 1024 + if len(file_bytes) > max_size_bytes: + raise HTTPException(status_code = 413, detail = "file too large (max 50MB)") + + ensure_dir(SEED_UPLOAD_DIR) + stored_name = f"{uuid4().hex}_{filename}" + stored_path = SEED_UPLOAD_DIR / stored_name + stored_path.write_bytes(file_bytes) + + if seed_source_type == "unstructured": + preview_rows = _read_preview_rows_from_unstructured_file( + path = stored_path, + preview_size = int(payload.preview_size), + chunk_size = payload.unstructured_chunk_size, + chunk_overlap = payload.unstructured_chunk_overlap, + ) + else: + preview_rows = _read_preview_rows_from_local_file( + stored_path, + int(payload.preview_size), + ) + if not preview_rows: + raise HTTPException( + status_code = 422, detail = "dataset appears empty or unreadable" + ) + columns = _extract_columns(preview_rows) + + return SeedInspectResponse( + dataset_name = filename, + resolved_path = str(stored_path), + columns = columns, + preview_rows = preview_rows, + split = None, + subset = None, + ) diff --git a/studio/backend/routes/data_recipe/validate.py b/studio/backend/routes/data_recipe/validate.py new file mode 100644 index 0000000000..a793a3b172 --- /dev/null +++ b/studio/backend/routes/data_recipe/validate.py @@ -0,0 +1,93 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Validation endpoints for data recipe.""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, HTTPException + +from core.data_recipe.service import ( + build_config_builder, + create_data_designer, + validate_recipe, +) +from models.data_recipe import RecipePayload, ValidateError, ValidateResponse + +router = APIRouter() + + +def _collect_validation_errors(recipe: dict[str, Any]) -> list[ValidateError]: + try: + from data_designer.engine.compiler import ( + _add_internal_row_id_column_if_needed, + _get_allowed_references, + _resolve_and_add_seed_columns, + ) + from data_designer.engine.validation import ( + ViolationLevel, + validate_data_designer_config, + ) + except ImportError: + return [] + + try: + builder = build_config_builder(recipe) + designer = create_data_designer(recipe) + resource_provider = designer._create_resource_provider( # type: ignore[attr-defined] + "validate-configuration", + builder, + ) + config = builder.build() + _resolve_and_add_seed_columns(config, resource_provider.seed_reader) + _add_internal_row_id_column_if_needed(config) + violations = validate_data_designer_config( + columns = config.columns, + processor_configs = config.processors or [], + allowed_references = _get_allowed_references(config), + ) + except (TypeError, ValueError, AttributeError): + return [] + + errors: list[ValidateError] = [] + for violation in violations: + if violation.level != ViolationLevel.ERROR: + continue + code = getattr(violation.type, "value", None) + path = violation.column if violation.column else None + message = str(violation.message).strip() or "Validation failed." + errors.append( + ValidateError( + message = message, + path = path, + code = code, + ) + ) + return errors + + +@router.post("/validate", response_model = ValidateResponse) +def validate(payload: RecipePayload) -> ValidateResponse: + recipe = payload.recipe + if not recipe.get("columns"): + return ValidateResponse( + valid = False, + errors = [ValidateError(message = "Recipe must include columns.")], + ) + + try: + validate_recipe(recipe) + except RuntimeError as exc: + raise HTTPException(status_code = 503, detail = str(exc)) from exc + except Exception as exc: + detail = str(exc).strip() or "Validation failed." + parsed_errors = _collect_validation_errors(recipe) + return ValidateResponse( + valid = False, + errors = parsed_errors or [ValidateError(message = detail)], + raw_detail = detail, + ) + + return ValidateResponse(valid = True) diff --git a/studio/backend/routes/datasets.py b/studio/backend/routes/datasets.py new file mode 100644 index 0000000000..415c30cd72 --- /dev/null +++ b/studio/backend/routes/datasets.py @@ -0,0 +1,565 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Datasets API routes +""" + +import base64 +import io +import json +import sys +from pathlib import Path +from uuid import uuid4 +from fastapi import APIRouter, Depends, HTTPException, UploadFile +import structlog +from loggers import get_logger + +# Add backend directory to path +backend_path = Path(__file__).parent.parent.parent +if str(backend_path) not in sys.path: + sys.path.insert(0, str(backend_path)) + +# Import dataset utilities +from utils.datasets import check_dataset_format +from auth.authentication import get_current_subject + +router = APIRouter() +logger = get_logger(__name__) + + +from models.datasets import ( + AiAssistMappingRequest, + AiAssistMappingResponse, + CheckFormatRequest, + CheckFormatResponse, + LocalDatasetItem, + LocalDatasetsResponse, + UploadDatasetResponse, +) +from utils.paths import ( + dataset_uploads_root, + ensure_dir, + recipe_datasets_root, + resolve_dataset_path, +) + + +def _serialize_preview_value(value): + """make it json safe for client preview ⊂(◉‿◉)つ""" + if value is None or isinstance(value, (str, int, float, bool)): + return value + + try: + from PIL.Image import Image as PILImage + + if isinstance(value, PILImage): + buffer = io.BytesIO() + value.convert("RGB").save(buffer, format = "JPEG", quality = 85) + return { + "type": "image", + "mime": "image/jpeg", + "width": value.width, + "height": value.height, + "data": base64.b64encode(buffer.getvalue()).decode("ascii"), + } + except Exception: + pass + + if isinstance(value, dict): + return {str(key): _serialize_preview_value(item) for key, item in value.items()} + + if isinstance(value, (list, tuple)): + return [_serialize_preview_value(item) for item in value] + + return str(value) + + +def _serialize_preview_rows(rows): + return [ + {str(key): _serialize_preview_value(value) for key, value in dict(row).items()} + for row in rows + ] + + +# --- Endpoints --- + +# Recognized data-file extensions for the single-file fallback approach. +# Tabular formats are preferred over archives for Tier 1 preview because +# archives (e.g. images.zip) may be loaded as ImageFolder datasets with +# synthetic columns (image/label) that don't match the real dataset schema. +_TABULAR_EXTS = (".parquet", ".json", ".jsonl", ".csv", ".tsv", ".arrow") +_ARCHIVE_EXTS = (".tar", ".tar.gz", ".tgz", ".gz", ".zst", ".zip", ".txt") +DATA_EXTS = _TABULAR_EXTS + _ARCHIVE_EXTS +LOCAL_FILE_EXTS = (".json", ".jsonl", ".csv", ".parquet") +LOCAL_UPLOAD_EXTS = {".csv", ".json", ".jsonl", ".parquet"} +LOCAL_DATASETS_ROOT = recipe_datasets_root() +DATASET_UPLOAD_DIR = dataset_uploads_root() + + +def _safe_read_metadata(path: Path) -> dict | None: + try: + payload = json.loads(path.read_text(encoding = "utf-8")) + except (OSError, ValueError, TypeError): + return None + if not isinstance(payload, dict): + return None + return payload + + +def _safe_read_rows_from_metadata(payload: dict | None) -> int | None: + if not payload: + return None + for key in ("actual_num_records", "target_num_records"): + value = payload.get(key) + if isinstance(value, int): + return value + return None + + +def _safe_read_metadata_summary(payload: dict | None) -> dict | None: + if not payload: + return None + + actual_num_records = ( + payload.get("actual_num_records") + if isinstance(payload.get("actual_num_records"), int) + else None + ) + target_num_records = ( + payload.get("target_num_records") + if isinstance(payload.get("target_num_records"), int) + else actual_num_records + ) + + columns: list[str] | None = None + schema = payload.get("schema") + if isinstance(schema, dict): + columns = [str(key) for key in schema.keys()] + if not columns: + stats = payload.get("column_statistics") + if isinstance(stats, list): + derived = [ + str(item.get("column_name")) + for item in stats + if isinstance(item, dict) and item.get("column_name") + ] + columns = derived or None + + parquet_files_count = None + file_paths = payload.get("file_paths") + if isinstance(file_paths, dict): + parquet_files = file_paths.get("parquet-files") + if isinstance(parquet_files, list): + parquet_files_count = len(parquet_files) + + total_num_batches = ( + payload.get("total_num_batches") + if isinstance(payload.get("total_num_batches"), int) + else parquet_files_count + ) + num_completed_batches = ( + payload.get("num_completed_batches") + if isinstance(payload.get("num_completed_batches"), int) + else total_num_batches + ) + + return { + "actual_num_records": actual_num_records, + "target_num_records": target_num_records, + "total_num_batches": total_num_batches, + "num_completed_batches": num_completed_batches, + "columns": columns, + } + + +def _build_local_dataset_items() -> list[LocalDatasetItem]: + if not LOCAL_DATASETS_ROOT.exists(): + return [] + + items: list[LocalDatasetItem] = [] + for entry in LOCAL_DATASETS_ROOT.iterdir(): + if not entry.is_dir() or not entry.name.startswith("recipe_"): + continue + parquet_dir = entry / "parquet-files" + if not parquet_dir.exists() or not any(parquet_dir.glob("*.parquet")): + continue + + rows = None + metadata_summary = None + metadata_path = entry / "metadata.json" + if metadata_path.exists(): + metadata_payload = _safe_read_metadata(metadata_path) + rows = _safe_read_rows_from_metadata(metadata_payload) + metadata_summary = _safe_read_metadata_summary(metadata_payload) + + try: + updated_at = entry.stat().st_mtime + except OSError: + updated_at = None + + items.append( + LocalDatasetItem( + id = entry.name, + label = entry.name, + path = str(parquet_dir.resolve()), + rows = rows, + updated_at = updated_at, + metadata = metadata_summary, + ) + ) + + items.sort(key = lambda item: item.updated_at or 0, reverse = True) + return items + + +def _load_local_preview_slice( + *, dataset_path: Path, train_split: str, preview_size: int +): + from datasets import load_dataset + + if dataset_path.is_dir(): + parquet_dir = ( + dataset_path / "parquet-files" + if (dataset_path / "parquet-files").exists() + else dataset_path + ) + parquet_files = sorted(parquet_dir.glob("*.parquet")) + if parquet_files: + dataset = load_dataset( + "parquet", + data_files = [str(path) for path in parquet_files], + split = train_split, + ) + total_rows = len(dataset) + preview_slice = dataset.select(range(min(preview_size, total_rows))) + return preview_slice, total_rows + else: + candidate_files: list[Path] = [] + for ext in LOCAL_FILE_EXTS: + candidate_files.extend(sorted(dataset_path.glob(f"*{ext}"))) + if not candidate_files: + raise HTTPException( + status_code = 400, + detail = "Unsupported local dataset directory (expected parquet/json/jsonl/csv files)", + ) + dataset_path = candidate_files[0] + + if dataset_path.suffix in [".json", ".jsonl"]: + dataset = load_dataset("json", data_files = str(dataset_path), split = train_split) + elif dataset_path.suffix == ".csv": + dataset = load_dataset("csv", data_files = str(dataset_path), split = train_split) + elif dataset_path.suffix == ".parquet": + dataset = load_dataset( + "parquet", data_files = str(dataset_path), split = train_split + ) + else: + raise HTTPException( + status_code = 400, detail = f"Unsupported file format: {dataset_path.suffix}" + ) + + total_rows = len(dataset) + preview_slice = dataset.select(range(min(preview_size, total_rows))) + return preview_slice, total_rows + + +def _sanitize_filename(filename: str) -> str: + name = Path(filename).name.strip().replace("\x00", "") + if not name: + return "dataset_upload" + return name + + +@router.post("/upload", response_model = UploadDatasetResponse) +async def upload_dataset( + file: UploadFile, + current_subject: str = Depends(get_current_subject), +) -> UploadDatasetResponse: + filename = _sanitize_filename(file.filename or "dataset_upload") + ext = Path(filename).suffix.lower() + if ext not in LOCAL_UPLOAD_EXTS: + allowed = ", ".join(sorted(LOCAL_UPLOAD_EXTS)) + raise HTTPException( + status_code = 400, + detail = f"Unsupported file type: {ext}. Allowed: {allowed}", + ) + + max_size_bytes = 512 * 1024 * 1024 + ensure_dir(DATASET_UPLOAD_DIR) + stem = Path(filename).stem + stored_name = f"{uuid4().hex}_{stem}{ext}" + stored_path = DATASET_UPLOAD_DIR / stored_name + + # Stream file to disk in chunks to avoid holding entire file in memory + size = 0 + with open(stored_path, "wb") as f: + while chunk := await file.read(1024 * 1024): + size += len(chunk) + if size > max_size_bytes: + stored_path.unlink(missing_ok = True) + raise HTTPException( + status_code = 413, detail = "File too large (max 512MB)" + ) + f.write(chunk) + + if size == 0: + stored_path.unlink(missing_ok = True) + raise HTTPException(status_code = 400, detail = "Empty upload payload") + + return UploadDatasetResponse(filename = filename, stored_path = str(stored_path)) + + +@router.get("/local", response_model = LocalDatasetsResponse) +def list_local_datasets( + current_subject: str = Depends(get_current_subject), +) -> LocalDatasetsResponse: + return LocalDatasetsResponse(datasets = _build_local_dataset_items()) + + +@router.post("/check-format", response_model = CheckFormatResponse) +def check_format( + request: CheckFormatRequest, + current_subject: str = Depends(get_current_subject), +): + """ + Check if a dataset requires manual column mapping. + + Strategy for HuggingFace datasets: + 1. list_repo_files → pick the first data file → load_dataset(data_files=[…]) + Avoids resolving thousands of files; typically ~2-4 s. + 2. Full streaming load_dataset as a last-resort fallback. + + Local files are loaded directly. + + Using a plain `def` (not async) so FastAPI runs this in a thread-pool, + preventing any blocking IO from freezing the event loop. + """ + try: + from itertools import islice + from datasets import Dataset, load_dataset + from utils.datasets import format_dataset + + PREVIEW_SIZE = 10 + + logger.info(f"Checking format for dataset: {request.dataset_name}") + + dataset_path = resolve_dataset_path(request.dataset_name) + total_rows = None + + if dataset_path.exists(): + # ── Local file ────────────────────────────────────────── + train_split = request.train_split or "train" + preview_slice, total_rows = _load_local_preview_slice( + dataset_path = dataset_path, + train_split = train_split, + preview_size = PREVIEW_SIZE, + ) + else: + # ── HuggingFace dataset ───────────────────────────────── + # Tier 1: list_repo_files → load only the first data file + preview_slice = None + + try: + from huggingface_hub import HfApi + + api = HfApi() + repo_files = api.list_repo_files( + request.dataset_name, + repo_type = "dataset", + token = request.hf_token or None, + ) + data_files = [ + f for f in repo_files if any(f.endswith(ext) for ext in DATA_EXTS) + ] + + # Prefer tabular formats over archives (e.g. images.zip → ImageFolder + # with synthetic image/label columns that don't match the real schema). + tabular_files = [ + f + for f in data_files + if any(f.endswith(ext) for ext in _TABULAR_EXTS) + ] + candidates = tabular_files or data_files + + # When a subset is specified, narrow to files whose name matches + # (e.g. subset="testmini" → prefer "testmini.parquet"). + if request.subset and candidates: + subset_matches = [ + f for f in candidates if request.subset in Path(f).stem + ] + if subset_matches: + candidates = subset_matches + + if candidates: + first_file = candidates[0] + logger.info(f"Tier 1: loading single file {first_file}") + load_kwargs = { + "path": request.dataset_name, + "data_files": [first_file], + "split": "train", + "streaming": True, + } + if request.hf_token: + load_kwargs["token"] = request.hf_token + + streamed_ds = load_dataset(**load_kwargs) + rows = list(islice(streamed_ds, PREVIEW_SIZE)) + if rows: + preview_slice = Dataset.from_list(rows) + except Exception as e: + logger.warning(f"Tier 1 (single-file) failed: {e}") + + if preview_slice is None: + # Tier 2: full streaming (resolves all files — slow for large repos) + logger.info("Tier 2: falling back to full streaming load_dataset") + load_kwargs = { + "path": request.dataset_name, + "split": request.train_split, + "streaming": True, + } + if request.subset: + load_kwargs["name"] = request.subset + if request.hf_token: + load_kwargs["token"] = request.hf_token + + streamed_ds = load_dataset(**load_kwargs) + + rows = list(islice(streamed_ds, PREVIEW_SIZE)) + if not rows: + raise HTTPException( + status_code = 400, + detail = "Dataset appears to be empty or could not be streamed", + ) + + preview_slice = Dataset.from_list(rows) + total_rows = None + + # Run lightweight format check on the preview slice + result = check_dataset_format(preview_slice, is_vlm = request.is_vlm) + + logger.info( + f"Format check result: requires_mapping={result['requires_manual_mapping']}, format={result['detected_format']}, is_image={result.get('is_image', False)}" + ) + + # Generate preview samples + preview_samples = None + if not result["requires_manual_mapping"]: + if result.get("suggested_mapping"): + # Heuristic-detected: show raw data so columns match the API response. + # Processing (column stripping) happens at training time, not preview. + preview_samples = _serialize_preview_rows(preview_slice) + else: + try: + format_result = format_dataset( + preview_slice, + format_type = "auto", + num_proc = 1, # Only 10 preview rows — no need for multiprocessing + ) + processed = format_result["dataset"] + preview_samples = _serialize_preview_rows(processed) + except Exception as e: + logger.warning( + f"Processed preview generation failed (non-fatal): {e}" + ) + preview_samples = _serialize_preview_rows(preview_slice) + else: + preview_samples = _serialize_preview_rows(preview_slice) + + # Collect warnings: from check_dataset_format + URL-based image detection + warning = result.get("warning") + image_col = result.get("detected_image_column") + if image_col and image_col in (result.get("columns") or []): + try: + sample_val = preview_slice[0][image_col] + if isinstance(sample_val, str) and sample_val.startswith( + ("http://", "https://") + ): + url_warning = ( + "This dataset contains image URLs instead of embedded images. " + "Images will be downloaded during training, which may be slow for large datasets." + ) + logger.info(f"URL-based image column detected: {image_col}") + warning = f"{warning} {url_warning}" if warning else url_warning + except Exception: + pass + + return CheckFormatResponse( + requires_manual_mapping = result["requires_manual_mapping"], + detected_format = result["detected_format"], + columns = result["columns"], + is_image = result.get("is_image", False), + is_audio = result.get("is_audio", False), + multimodal_columns = result.get("multimodal_columns"), + suggested_mapping = result.get("suggested_mapping"), + detected_image_column = result.get("detected_image_column"), + detected_audio_column = result.get("detected_audio_column"), + detected_text_column = result.get("detected_text_column"), + detected_speaker_column = result.get("detected_speaker_column"), + preview_samples = preview_samples, + total_rows = total_rows, + warning = warning, + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error checking dataset format: {e}", exc_info = True) + raise HTTPException( + status_code = 500, detail = f"Failed to check dataset format: {str(e)}" + ) + + +@router.post("/ai-assist-mapping", response_model = AiAssistMappingResponse) +def ai_assist_mapping( + request: AiAssistMappingRequest, + current_subject: str = Depends(get_current_subject), +): + """ + Run LLM-assisted dataset conversion advisor (user-triggered). + + Multi-pass analysis using a 7B helper model: + Pass 1: Classify dataset type from HF card + samples + Pass 2: Generate conversion strategy (system prompt, templates) + Pass 3: Validate conversion quality + + Falls back to simple column classification if the advisor fails. + """ + try: + from utils.datasets.llm_assist import llm_conversion_advisor + + # Truncate sample values for the LLM prompt + truncated = [ + {col: str(s.get(col, ""))[:200] for col in request.columns} + for s in request.samples[:5] + ] + + result = llm_conversion_advisor( + column_names = request.columns, + samples = truncated, + dataset_name = request.dataset_name, + hf_token = request.hf_token, + model_name = request.model_name, + model_type = request.model_type, + ) + + if result and result.get("success"): + return AiAssistMappingResponse( + success = True, + suggested_mapping = result.get("suggested_mapping"), + system_prompt = result.get("system_prompt"), + user_template = result.get("user_template"), + assistant_template = result.get("assistant_template"), + label_mapping = result.get("label_mapping"), + dataset_type = result.get("dataset_type"), + is_conversational = result.get("is_conversational"), + user_notification = result.get("user_notification"), + ) + + return AiAssistMappingResponse( + success = False, + warning = "AI could not determine column roles. Please assign them manually.", + ) + + except Exception as e: + logger.error(f"AI assist mapping failed: {e}", exc_info = True) + raise HTTPException(status_code = 500, detail = f"AI assist failed: {str(e)}") diff --git a/studio/backend/routes/export.py b/studio/backend/routes/export.py new file mode 100644 index 0000000000..3e60eaaf20 --- /dev/null +++ b/studio/backend/routes/export.py @@ -0,0 +1,311 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Export API routes: checkpoint discovery and model export operations. +""" + +import sys +from pathlib import Path +from fastapi import APIRouter, Depends, HTTPException, Query +import structlog +from loggers import get_logger + +# Add backend directory to path +backend_path = Path(__file__).parent.parent.parent +if str(backend_path) not in sys.path: + sys.path.insert(0, str(backend_path)) + +# Auth +from auth.authentication import get_current_subject + +# Import backend functions +try: + from core.export import get_export_backend +except ImportError: + parent_backend = backend_path.parent / "backend" + if str(parent_backend) not in sys.path: + sys.path.insert(0, str(parent_backend)) + from core.export import get_export_backend + +# Import Pydantic models +from models import ( + LoadCheckpointRequest, + ExportStatusResponse, + ExportOperationResponse, + ExportMergedModelRequest, + ExportBaseModelRequest, + ExportGGUFRequest, + ExportLoRAAdapterRequest, +) + +router = APIRouter() +logger = get_logger(__name__) + + +@router.post("/load-checkpoint", response_model = ExportOperationResponse) +async def load_checkpoint( + request: LoadCheckpointRequest, + current_subject: str = Depends(get_current_subject), +): + """ + Load a checkpoint into the export backend. + + Wraps ExportBackend.load_checkpoint. + """ + try: + # Version switching is handled automatically by the subprocess-based + # export backend — no need for ensure_transformers_version() here. + + # Free GPU memory: shut down any running inference/training subprocesses + # before loading the export checkpoint (they'd compete for VRAM). + try: + from core.inference import get_inference_backend + + inf = get_inference_backend() + if inf.active_model_name: + logger.info( + "Unloading inference model '%s' to free GPU memory for export", + inf.active_model_name, + ) + inf._shutdown_subprocess() + inf.active_model_name = None + inf.models.clear() + except Exception as e: + logger.warning("Could not unload inference model: %s", e) + + try: + from core.training import get_training_backend + + trn = get_training_backend() + if trn.is_training_active(): + logger.info("Stopping active training to free GPU memory for export") + trn.stop_training() + # Wait for training subprocess to actually exit before proceeding, + # otherwise it may still hold GPU memory when export tries to load. + for _ in range(60): # up to 30s + if not trn.is_training_active(): + break + import time + + time.sleep(0.5) + else: + logger.warning( + "Training subprocess did not exit within 30s, proceeding anyway" + ) + except Exception as e: + logger.warning("Could not stop training: %s", e) + + backend = get_export_backend() + success, message = backend.load_checkpoint( + checkpoint_path = request.checkpoint_path, + max_seq_length = request.max_seq_length, + load_in_4bit = request.load_in_4bit, + trust_remote_code = request.trust_remote_code, + ) + + if not success: + raise HTTPException(status_code = 400, detail = message) + + return ExportOperationResponse(success = True, message = message) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error loading checkpoint: {e}", exc_info = True) + raise HTTPException( + status_code = 500, + detail = f"Failed to load checkpoint: {str(e)}", + ) + + +@router.post("/cleanup", response_model = ExportOperationResponse) +async def cleanup_export_memory( + current_subject: str = Depends(get_current_subject), +): + """ + Cleanup export-related models from memory (GPU/CPU). + + Wraps ExportBackend.cleanup_memory. + """ + try: + backend = get_export_backend() + success = backend.cleanup_memory() + + if not success: + raise HTTPException( + status_code = 500, + detail = "Memory cleanup failed. See server logs for details.", + ) + + return ExportOperationResponse( + success = True, + message = "Memory cleanup completed successfully", + ) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error during export memory cleanup: {e}", exc_info = True) + raise HTTPException( + status_code = 500, + detail = f"Failed to cleanup export memory: {str(e)}", + ) + + +@router.get("/status", response_model = ExportStatusResponse) +async def get_export_status( + current_subject: str = Depends(get_current_subject), +): + """ + Get current export backend status (loaded checkpoint, model type, PEFT flag). + """ + try: + backend = get_export_backend() + return ExportStatusResponse( + current_checkpoint = backend.current_checkpoint, + is_vision = bool(getattr(backend, "is_vision", False)), + is_peft = bool(getattr(backend, "is_peft", False)), + ) + except Exception as e: + logger.error(f"Error getting export status: {e}", exc_info = True) + raise HTTPException( + status_code = 500, + detail = f"Failed to get export status: {str(e)}", + ) + + +@router.post("/export/merged", response_model = ExportOperationResponse) +async def export_merged_model( + request: ExportMergedModelRequest, + current_subject: str = Depends(get_current_subject), +): + """ + Export a merged PEFT model (e.g., 16-bit or 4-bit) and optionally push to Hub. + + Wraps ExportBackend.export_merged_model. + """ + try: + backend = get_export_backend() + success, message = backend.export_merged_model( + save_directory = request.save_directory, + format_type = request.format_type, + push_to_hub = request.push_to_hub, + repo_id = request.repo_id, + hf_token = request.hf_token, + private = request.private, + ) + + if not success: + raise HTTPException(status_code = 400, detail = message) + + return ExportOperationResponse(success = True, message = message) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error exporting merged model: {e}", exc_info = True) + raise HTTPException( + status_code = 500, + detail = f"Failed to export merged model: {str(e)}", + ) + + +@router.post("/export/base", response_model = ExportOperationResponse) +async def export_base_model( + request: ExportBaseModelRequest, + current_subject: str = Depends(get_current_subject), +): + """ + Export a non-PEFT base model and optionally push to Hub. + + Wraps ExportBackend.export_base_model. + """ + try: + backend = get_export_backend() + success, message = backend.export_base_model( + save_directory = request.save_directory, + push_to_hub = request.push_to_hub, + repo_id = request.repo_id, + hf_token = request.hf_token, + private = request.private, + base_model_id = request.base_model_id, + ) + + if not success: + raise HTTPException(status_code = 400, detail = message) + + return ExportOperationResponse(success = True, message = message) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error exporting base model: {e}", exc_info = True) + raise HTTPException( + status_code = 500, + detail = f"Failed to export base model: {str(e)}", + ) + + +@router.post("/export/gguf", response_model = ExportOperationResponse) +async def export_gguf( + request: ExportGGUFRequest, + current_subject: str = Depends(get_current_subject), +): + """ + Export the current model to GGUF format and optionally push to Hub. + + Wraps ExportBackend.export_gguf. + """ + try: + backend = get_export_backend() + success, message = backend.export_gguf( + save_directory = request.save_directory, + quantization_method = request.quantization_method, + push_to_hub = request.push_to_hub, + repo_id = request.repo_id, + hf_token = request.hf_token, + ) + + if not success: + raise HTTPException(status_code = 400, detail = message) + + return ExportOperationResponse(success = True, message = message) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error exporting GGUF model: {e}", exc_info = True) + raise HTTPException( + status_code = 500, + detail = f"Failed to export GGUF model: {str(e)}", + ) + + +@router.post("/export/lora", response_model = ExportOperationResponse) +async def export_lora_adapter( + request: ExportLoRAAdapterRequest, + current_subject: str = Depends(get_current_subject), +): + """ + Export only the LoRA adapter (if the loaded model is PEFT). + + Wraps ExportBackend.export_lora_adapter. + """ + try: + backend = get_export_backend() + success, message = backend.export_lora_adapter( + save_directory = request.save_directory, + push_to_hub = request.push_to_hub, + repo_id = request.repo_id, + hf_token = request.hf_token, + private = request.private, + ) + + if not success: + raise HTTPException(status_code = 400, detail = message) + + return ExportOperationResponse(success = True, message = message) + except HTTPException: + raise + except Exception as e: + logger.error(f"Error exporting LoRA adapter: {e}", exc_info = True) + raise HTTPException( + status_code = 500, + detail = f"Failed to export LoRA adapter: {str(e)}", + ) diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py new file mode 100644 index 0000000000..7de7336946 --- /dev/null +++ b/studio/backend/routes/inference.py @@ -0,0 +1,1180 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Inference API routes for model loading and text generation. +""" + +import sys +import time +import uuid +from pathlib import Path +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import StreamingResponse, JSONResponse +from typing import Optional +import json +import structlog +from loggers import get_logger +import asyncio +import threading + + +# Add backend directory to path +backend_path = Path(__file__).parent.parent.parent +if str(backend_path) not in sys.path: + sys.path.insert(0, str(backend_path)) + +# Import backend functions +try: + from core.inference import get_inference_backend + from core.inference.llama_cpp import LlamaCppBackend + from utils.models import ModelConfig + from utils.inference import load_inference_config + from utils.models.model_config import load_model_defaults +except ImportError: + parent_backend = backend_path.parent / "backend" + if str(parent_backend) not in sys.path: + sys.path.insert(0, str(parent_backend)) + from core.inference import get_inference_backend + from core.inference.llama_cpp import LlamaCppBackend + from utils.models import ModelConfig + from utils.inference import load_inference_config + from utils.models.model_config import load_model_defaults + +from models.inference import ( + LoadRequest, + UnloadRequest, + GenerateRequest, + LoadResponse, + UnloadResponse, + InferenceStatusResponse, + ChatCompletionRequest, + ChatCompletionChunk, + ChatCompletion, + ChunkChoice, + ChoiceDelta, + CompletionChoice, + CompletionMessage, + ValidateModelRequest, + ValidateModelResponse, +) +from auth.authentication import get_current_subject + +import io +import wave +import base64 +import numpy as np + +router = APIRouter() +logger = get_logger(__name__) + + +# GGUF inference backend (llama-server) +_llama_cpp_backend = LlamaCppBackend() + + +def get_llama_cpp_backend() -> LlamaCppBackend: + return _llama_cpp_backend + + +@router.post("/load", response_model = LoadResponse) +async def load_model( + request: LoadRequest, + current_subject: str = Depends(get_current_subject), +): + """ + Load a model for inference. + + The model_path should be a clean identifier from GET /models/list. + Returns inference configuration parameters (temperature, top_p, top_k, min_p) + from the model's YAML config, falling back to default.yaml for missing values. + + GGUF models are loaded via llama-server (llama.cpp) instead of Unsloth. + """ + try: + # Version switching is handled automatically by the subprocess-based + # inference backend — no need for ensure_transformers_version() here. + + # Create config using clean factory method + # is_lora is auto-detected from adapter_config.json on disk/HF + config = ModelConfig.from_identifier( + model_id = request.model_path, + hf_token = request.hf_token, + gguf_variant = request.gguf_variant, + ) + + if not config: + raise HTTPException( + status_code = 400, + detail = f"Invalid model identifier: {request.model_path}", + ) + + # ── GGUF path: load via llama-server ────────────────────── + if config.is_gguf: + llama_backend = get_llama_cpp_backend() + unsloth_backend = get_inference_backend() + + # Unload any active Unsloth model first to free VRAM + if unsloth_backend.active_model_name: + logger.info( + f"Unloading Unsloth model '{unsloth_backend.active_model_name}' before loading GGUF" + ) + unsloth_backend.unload_model(unsloth_backend.active_model_name) + + # Route to HF mode or local mode based on config + if config.gguf_hf_repo: + # HF mode: llama-server downloads via -hf "repo:quant" + success = llama_backend.load_model( + hf_repo = config.gguf_hf_repo, + hf_variant = config.gguf_variant, + hf_token = request.hf_token, + model_identifier = config.identifier, + is_vision = config.is_vision, + n_ctx = request.max_seq_length, + ) + else: + # Local mode: llama-server loads via -m + success = llama_backend.load_model( + gguf_path = config.gguf_file, + mmproj_path = config.gguf_mmproj_file, + model_identifier = config.identifier, + is_vision = config.is_vision, + n_ctx = request.max_seq_length, + ) + + if not success: + raise HTTPException( + status_code = 500, + detail = f"Failed to load GGUF model: {config.display_name}", + ) + + logger.info(f"Loaded GGUF model via llama-server: {config.identifier}") + + inference_config = load_inference_config(config.identifier) + + return LoadResponse( + status = "loaded", + model = config.identifier, + display_name = config.display_name, + is_vision = config.is_vision, + is_lora = False, + is_gguf = True, + inference = inference_config, + ) + + # ── Standard path: load via Unsloth/transformers ────────── + backend = get_inference_backend() + + # Unload any active GGUF model first + llama_backend = get_llama_cpp_backend() + if llama_backend.is_loaded: + logger.info("Unloading GGUF model before loading Unsloth model") + llama_backend.unload_model() + + # Shut down any export subprocess to free VRAM + try: + from core.export import get_export_backend + + exp_backend = get_export_backend() + if exp_backend.current_checkpoint: + logger.info( + "Shutting down export subprocess to free GPU memory for inference" + ) + exp_backend._shutdown_subprocess() + exp_backend.current_checkpoint = None + exp_backend.is_vision = False + exp_backend.is_peft = False + except Exception as e: + logger.warning("Could not shut down export subprocess: %s", e) + + # Auto-detect quantization for LoRA adapters from adapter_config.json + # The training pipeline patches this file with "unsloth_training_method" + # which is 'qlora' or 'lora'. Only LoRA (16-bit) needs load_in_4bit=False. + load_in_4bit = request.load_in_4bit + if config.is_lora and config.path: + import json + from pathlib import Path + + adapter_cfg_path = Path(config.path) / "adapter_config.json" + if adapter_cfg_path.exists(): + try: + with open(adapter_cfg_path) as f: + adapter_cfg = json.load(f) + training_method = adapter_cfg.get("unsloth_training_method") + if training_method == "lora" and load_in_4bit: + logger.info( + f"adapter_config.json says unsloth_training_method='lora' — " + f"setting load_in_4bit=False to match 16-bit training" + ) + load_in_4bit = False + elif training_method == "qlora" and not load_in_4bit: + logger.info( + f"adapter_config.json says unsloth_training_method='qlora' — " + f"setting load_in_4bit=True to match QLoRA training" + ) + load_in_4bit = True + elif training_method: + logger.info( + f"Training method: {training_method}, load_in_4bit={load_in_4bit}" + ) + else: + # No unsloth_training_method — fallback to base model name + if ( + config.base_model + and "-bnb-4bit" not in config.base_model.lower() + and load_in_4bit + ): + logger.info( + f"No unsloth_training_method in adapter_config.json. " + f"Base model '{config.base_model}' has no -bnb-4bit suffix — " + f"setting load_in_4bit=False" + ) + load_in_4bit = False + except Exception as e: + logger.warning(f"Could not read adapter_config.json: {e}") + + # Load the model + success = backend.load_model( + config = config, + max_seq_length = request.max_seq_length, + load_in_4bit = load_in_4bit, + hf_token = request.hf_token, + trust_remote_code = request.trust_remote_code, + ) + + if not success: + # Check if YAML says this model needs trust_remote_code + if not request.trust_remote_code: + model_defaults = load_model_defaults(config.identifier) + yaml_trust = model_defaults.get("inference", {}).get( + "trust_remote_code", False + ) + if yaml_trust: + raise HTTPException( + status_code = 400, + detail = ( + f"Model '{config.display_name}' requires trust_remote_code to be enabled. " + f"Please enable 'Trust remote code' in Chat Settings and try again." + ), + ) + raise HTTPException( + status_code = 500, detail = f"Failed to load model: {config.display_name}" + ) + + logger.info(f"Loaded model: {config.identifier}") + + # Load inference configuration parameters + inference_config = load_inference_config(config.identifier) + + return LoadResponse( + status = "loaded", + model = config.identifier, + display_name = config.display_name, + is_vision = config.is_vision, + is_lora = config.is_lora, + is_gguf = False, + is_audio = config.is_audio, + audio_type = config.audio_type, + has_audio_input = config.has_audio_input, + inference = inference_config, + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error loading model: {e}", exc_info = True) + raise HTTPException(status_code = 500, detail = f"Failed to load model: {str(e)}") + + +@router.post("/validate", response_model = ValidateModelResponse) +async def validate_model( + request: ValidateModelRequest, + current_subject: str = Depends(get_current_subject), +): + """ + Lightweight validation endpoint for model identifiers. + + This checks that ModelConfig.from_identifier() can resolve the given + model_path, but it does NOT actually load model weights into GPU memory. + """ + try: + config = ModelConfig.from_identifier( + model_id = request.model_path, + hf_token = request.hf_token, + gguf_variant = request.gguf_variant, + ) + + if not config: + raise HTTPException( + status_code = 400, + detail = f"Invalid model identifier: {request.model_path}", + ) + + return ValidateModelResponse( + valid = True, + message = "Model identifier is valid.", + identifier = config.identifier, + display_name = getattr(config, "display_name", config.identifier), + is_gguf = getattr(config, "is_gguf", False), + is_lora = getattr(config, "is_lora", False), + is_vision = getattr(config, "is_vision", False), + ) + + except HTTPException: + raise + except Exception as e: + logger.error( + f"Error validating model identifier '{request.model_path}': {e}", + exc_info = True, + ) + raise HTTPException( + status_code = 400, + detail = f"Invalid model: {str(e)}", + ) + + +@router.post("/unload", response_model = UnloadResponse) +async def unload_model( + request: UnloadRequest, + current_subject: str = Depends(get_current_subject), +): + """ + Unload a model from memory. + Routes to the correct backend (llama-server for GGUF, Unsloth otherwise). + """ + try: + # Check if the GGUF backend has this model loaded + llama_backend = get_llama_cpp_backend() + if ( + llama_backend.is_loaded + and llama_backend.model_identifier == request.model_path + ): + llama_backend.unload_model() + logger.info(f"Unloaded GGUF model: {request.model_path}") + return UnloadResponse(status = "unloaded", model = request.model_path) + + # Otherwise, unload from Unsloth backend + backend = get_inference_backend() + backend.unload_model(request.model_path) + logger.info(f"Unloaded model: {request.model_path}") + return UnloadResponse(status = "unloaded", model = request.model_path) + + except Exception as e: + logger.error(f"Error unloading model: {e}", exc_info = True) + raise HTTPException(status_code = 500, detail = f"Failed to unload model: {str(e)}") + + +@router.post("/generate/stream") +async def generate_stream( + request: GenerateRequest, + current_subject: str = Depends(get_current_subject), +): + """ + Generate a chat response with Server-Sent Events (SSE) streaming. + + For vision models, provide image_base64 with the base64-encoded image. + """ + backend = get_inference_backend() + + if not backend.active_model_name: + raise HTTPException( + status_code = 400, detail = "No model loaded. Call POST /inference/load first." + ) + + # Decode image if provided (for vision models) + image = None + if request.image_base64: + try: + import base64 + from PIL import Image + from io import BytesIO + + # Check if current model supports vision + model_info = backend.models.get(backend.active_model_name, {}) + if not model_info.get("is_vision"): + raise HTTPException( + status_code = 400, + detail = "Image provided but current model is text-only. Load a vision model.", + ) + + image_data = base64.b64decode(request.image_base64) + image = Image.open(BytesIO(image_data)) + image = backend.resize_image(image) + + except HTTPException: + raise + except Exception as e: + raise HTTPException( + status_code = 400, detail = f"Failed to decode image: {str(e)}" + ) + + async def stream(): + try: + for chunk in backend.generate_chat_response( + messages = request.messages, + system_prompt = request.system_prompt, + image = image, + temperature = request.temperature, + top_p = request.top_p, + top_k = request.top_k, + max_new_tokens = request.max_new_tokens, + repetition_penalty = request.repetition_penalty, + ): + yield f"data: {json.dumps({'content': chunk})}\n\n" + yield "data: [DONE]\n\n" + + except Exception as e: + backend.reset_generation_state() + logger.error(f"Error during generation: {e}", exc_info = True) + yield f"data: {json.dumps({'error': 'An internal error occurred'})}\n\n" + + return StreamingResponse( + stream(), + media_type = "text/event-stream", + headers = { + "Cache-Control": "no-cache", + "Connection": "keep-alive", + }, + ) + + +@router.get("/status", response_model = InferenceStatusResponse) +async def get_status( + current_subject: str = Depends(get_current_subject), +): + """ + Get current inference backend status. + Reports whichever backend (Unsloth or llama-server) is currently active. + """ + try: + llama_backend = get_llama_cpp_backend() + + # If a GGUF model is loaded via llama-server, report that + if llama_backend.is_loaded: + return InferenceStatusResponse( + active_model = llama_backend.model_identifier, + is_vision = llama_backend.is_vision, + is_gguf = True, + gguf_variant = llama_backend.hf_variant, + loading = [], + loaded = [llama_backend.model_identifier], + ) + + # Otherwise, report Unsloth backend status + backend = get_inference_backend() + + is_vision = False + is_audio = False + audio_type = None + has_audio_input = False + if backend.active_model_name: + model_info = backend.models.get(backend.active_model_name, {}) + is_vision = model_info.get("is_vision", False) + is_audio = model_info.get("is_audio", False) + audio_type = model_info.get("audio_type") + has_audio_input = model_info.get("has_audio_input", False) + + return InferenceStatusResponse( + active_model = backend.active_model_name, + is_vision = is_vision, + is_gguf = False, + is_audio = is_audio, + audio_type = audio_type, + has_audio_input = has_audio_input, + loading = list(getattr(backend, "loading_models", set())), + loaded = list(backend.models.keys()), + ) + + except Exception as e: + logger.error(f"Error getting status: {e}", exc_info = True) + raise HTTPException(status_code = 500, detail = f"Failed to get status: {str(e)}") + + +# ===================================================================== +# Audio (TTS) Generation (/audio/generate) +# ===================================================================== + + +@router.post("/audio/generate") +async def generate_audio( + payload: ChatCompletionRequest, + request: Request, + current_subject: str = Depends(get_current_subject), +): + """ + Generate audio (TTS) from the latest user message. + Returns a JSON response with base64-encoded WAV audio. + Only works when an audio model is loaded. + """ + import base64 + + backend = get_inference_backend() + if not backend.active_model_name: + raise HTTPException(status_code = 400, detail = "No model loaded.") + + model_info = backend.models.get(backend.active_model_name, {}) + if not model_info.get("is_audio"): + raise HTTPException( + status_code = 400, detail = "Active model is not an audio model." + ) + + # Extract text from the last user message + _, chat_messages, _ = _extract_content_parts(payload.messages) + if not chat_messages: + raise HTTPException(status_code = 400, detail = "No messages provided.") + + last_user_msg = next( + (m for m in reversed(chat_messages) if m["role"] == "user"), None + ) + if not last_user_msg: + raise HTTPException(status_code = 400, detail = "No user message found.") + + text = last_user_msg["content"] + + try: + wav_bytes, sample_rate = await asyncio.get_event_loop().run_in_executor( + None, + lambda: backend.generate_audio_response( + text = text, + temperature = payload.temperature, + top_p = payload.top_p, + top_k = payload.top_k, + min_p = payload.min_p, + max_new_tokens = payload.max_tokens or 2048, + repetition_penalty = payload.repetition_penalty, + use_adapter = payload.use_adapter, + ), + ) + + audio_b64 = base64.b64encode(wav_bytes).decode("ascii") + completion_id = f"chatcmpl-{uuid.uuid4().hex[:12]}" + + return JSONResponse( + content = { + "id": completion_id, + "object": "chat.completion.audio", + "model": backend.active_model_name, + "audio": { + "data": audio_b64, + "format": "wav", + "sample_rate": sample_rate, + }, + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": f'[Generated audio from: "{text[:100]}"]', + }, + "finish_reason": "stop", + } + ], + } + ) + + except Exception as e: + logger.error(f"Audio generation error: {e}", exc_info = True) + raise HTTPException(status_code = 500, detail = str(e)) + + +# ===================================================================== +# OpenAI-Compatible Chat Completions (/chat/completions) +# ===================================================================== + + +def _decode_audio_base64(b64: str) -> np.ndarray: + """Decode base64 audio (any format) → float32 numpy array at 16kHz.""" + import torch + import torchaudio + import tempfile + import os + from utils.paths import ensure_dir, tmp_root + + raw = base64.b64decode(b64) + # torchaudio.load needs a file path or file-like object with format hint + # Write to a temp file so torchaudio can auto-detect the format + with tempfile.NamedTemporaryFile( + suffix = ".audio", + delete = False, + dir = str(ensure_dir(tmp_root())), + ) as tmp: + tmp.write(raw) + tmp_path = tmp.name + try: + waveform, sr = torchaudio.load(tmp_path) + finally: + os.unlink(tmp_path) + + # Convert to mono if stereo + if waveform.shape[0] > 1: + waveform = waveform.mean(dim = 0, keepdim = True) + + # Resample to 16kHz if needed + if sr != 16000: + resampler = torchaudio.transforms.Resample(orig_freq = sr, new_freq = 16000) + waveform = resampler(waveform) + + return waveform.squeeze(0).numpy() + + +def _extract_content_parts( + messages: list, +) -> tuple[str, list[dict], "Optional[str]"]: + """ + Parse OpenAI-format messages into components the inference backend expects. + + Handles both plain-string ``content`` and multimodal content-part arrays + (``[{type: "text", ...}, {type: "image_url", ...}]``). + + Returns: + system_prompt: The system message text (or a default). + chat_messages: Non-system messages with content flattened to strings. + image_base64: Base64 data of the *first* image found, or ``None``. + """ + system_prompt = "You are a helpful AI assistant." + chat_messages: list[dict] = [] + first_image_b64: Optional[str] = None + + for msg in messages: + # ── System messages → extract as system_prompt ──────── + if msg.role == "system": + if isinstance(msg.content, str): + system_prompt = msg.content + elif isinstance(msg.content, list): + # Unlikely but handle: join text parts + system_prompt = "\n".join( + p.text for p in msg.content if p.type == "text" + ) + continue + + # ── User / assistant messages ───────────────────────── + if isinstance(msg.content, str): + # Plain string content — pass through + chat_messages.append({"role": msg.role, "content": msg.content}) + elif isinstance(msg.content, list): + # Multimodal content parts + text_parts: list[str] = [] + for part in msg.content: + if part.type == "text": + text_parts.append(part.text) + elif part.type == "image_url" and first_image_b64 is None: + url = part.image_url.url + if url.startswith("data:"): + # data:image/png;base64, → extract + first_image_b64 = url.split(",", 1)[1] if "," in url else None + else: + logger.warning( + f"Remote image URLs not yet supported: {url[:80]}..." + ) + combined_text = "\n".join(text_parts) if text_parts else "" + chat_messages.append({"role": msg.role, "content": combined_text}) + + return system_prompt, chat_messages, first_image_b64 + + +@router.post("/chat/completions") +async def openai_chat_completions( + payload: ChatCompletionRequest, + request: Request, + current_subject: str = Depends(get_current_subject), +): + """ + OpenAI-compatible chat completions endpoint. + + Supports multimodal messages: ``content`` may be a plain string or a + list of content parts (``text`` / ``image_url``). + + Streaming (default): returns SSE chunks matching OpenAI's format. + Non-streaming: returns a single ChatCompletion JSON object. + + Automatically routes to the correct backend: + - GGUF models → llama-server via LlamaCppBackend + - Other models → Unsloth/transformers via InferenceBackend + """ + llama_backend = get_llama_cpp_backend() + using_gguf = llama_backend.is_loaded + + # ── Determine which backend is active ───────────────────── + if using_gguf: + model_name = llama_backend.model_identifier or payload.model + else: + backend = get_inference_backend() + if not backend.active_model_name: + raise HTTPException( + status_code = 400, + detail = "No model loaded. Call POST /inference/load first.", + ) + model_name = backend.active_model_name or payload.model + + # ── Audio TTS path: auto-route to audio generation ──── + # (Whisper is ASR not TTS — handled below in audio input path) + model_info = backend.models.get(backend.active_model_name, {}) + if model_info.get("is_audio") and model_info.get("audio_type") != "whisper": + return await generate_audio(payload, request) + + # ── Whisper without audio: return clear error ── + if model_info.get("audio_type") == "whisper" and not payload.audio_base64: + raise HTTPException( + status_code = 400, + detail = "Whisper models require audio input. Please upload an audio file.", + ) + + # ── Audio INPUT path: decode WAV and route to audio input generation ── + if payload.audio_base64 and model_info.get("has_audio_input"): + audio_array = _decode_audio_base64(payload.audio_base64) + system_prompt, chat_messages, _ = _extract_content_parts(payload.messages) + cancel_event = threading.Event() + completion_id = f"chatcmpl-{uuid.uuid4().hex[:12]}" + created = int(time.time()) + + def audio_input_generate(): + if model_info.get("audio_type") == "whisper": + return backend.generate_whisper_response( + audio_array = audio_array, + cancel_event = cancel_event, + ) + return backend.generate_audio_input_response( + messages = chat_messages, + system_prompt = system_prompt, + audio_array = audio_array, + temperature = payload.temperature, + top_p = payload.top_p, + top_k = payload.top_k, + min_p = payload.min_p, + max_new_tokens = payload.max_tokens or 2048, + repetition_penalty = payload.repetition_penalty, + cancel_event = cancel_event, + ) + + if payload.stream: + + async def audio_input_stream(): + try: + first_chunk = ChatCompletionChunk( + id = completion_id, + created = created, + model = model_name, + choices = [ + ChunkChoice( + delta = ChoiceDelta(role = "assistant"), + finish_reason = None, + ) + ], + ) + yield f"data: {first_chunk.model_dump_json(exclude_none = True)}\n\n" + + for chunk_text in audio_input_generate(): + if await request.is_disconnected(): + cancel_event.set() + return + if chunk_text: + chunk = ChatCompletionChunk( + id = completion_id, + created = created, + model = model_name, + choices = [ + ChunkChoice( + delta = ChoiceDelta(content = chunk_text), + finish_reason = None, + ) + ], + ) + yield f"data: {chunk.model_dump_json(exclude_none = True)}\n\n" + + final_chunk = ChatCompletionChunk( + id = completion_id, + created = created, + model = model_name, + choices = [ + ChunkChoice(delta = ChoiceDelta(), finish_reason = "stop") + ], + ) + yield f"data: {final_chunk.model_dump_json(exclude_none = True)}\n\n" + yield "data: [DONE]\n\n" + except asyncio.CancelledError: + cancel_event.set() + raise + except Exception as e: + logger.error( + f"Error during audio input streaming: {e}", exc_info = True + ) + yield f"data: {json.dumps({'error': {'message': 'An internal error occurred', 'type': 'server_error'}})}\n\n" + + return StreamingResponse( + audio_input_stream(), + media_type = "text/event-stream", + headers = { + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + else: + full_text = "".join(audio_input_generate()) + response = ChatCompletion( + id = completion_id, + created = created, + model = model_name, + choices = [ + CompletionChoice( + message = CompletionMessage(content = full_text), + finish_reason = "stop", + ) + ], + ) + return JSONResponse(content = response.model_dump()) + + # ── Parse messages (handles multimodal content parts) ───── + system_prompt, chat_messages, extracted_image_b64 = _extract_content_parts( + payload.messages + ) + + if not chat_messages: + raise HTTPException( + status_code = 400, + detail = "At least one non-system message is required.", + ) + + # ── GGUF path: proxy to llama-server /v1/chat/completions ── + if using_gguf: + # Reject images if this GGUF model doesn't support vision + image_b64 = extracted_image_b64 or payload.image_base64 + if image_b64 and not llama_backend.is_vision: + raise HTTPException( + status_code = 400, + detail = "Image provided but current GGUF model does not support vision.", + ) + + # Build message list with system prompt prepended + gguf_messages = [] + if system_prompt: + gguf_messages.append({"role": "system", "content": system_prompt}) + gguf_messages.extend(chat_messages) + + cancel_event = threading.Event() + + completion_id = f"chatcmpl-{uuid.uuid4().hex[:12]}" + created = int(time.time()) + + def gguf_generate(): + return llama_backend.generate_chat_completion( + messages = gguf_messages, + image_b64 = image_b64, + temperature = payload.temperature, + top_p = payload.top_p, + top_k = payload.top_k, + min_p = payload.min_p, + max_tokens = payload.max_tokens or 2048, + repetition_penalty = payload.repetition_penalty, + cancel_event = cancel_event, + ) + + if payload.stream: + + async def gguf_stream_chunks(): + try: + # First chunk: role + first_chunk = ChatCompletionChunk( + id = completion_id, + created = created, + model = model_name, + choices = [ + ChunkChoice( + delta = ChoiceDelta(role = "assistant"), + finish_reason = None, + ) + ], + ) + yield f"data: {first_chunk.model_dump_json(exclude_none = True)}\n\n" + + # Content chunks — llama backend yields cumulative text + prev_text = "" + for cumulative in gguf_generate(): + if await request.is_disconnected(): + cancel_event.set() + return + new_text = cumulative[len(prev_text) :] + prev_text = cumulative + if not new_text: + continue + chunk = ChatCompletionChunk( + id = completion_id, + created = created, + model = model_name, + choices = [ + ChunkChoice( + delta = ChoiceDelta(content = new_text), + finish_reason = None, + ) + ], + ) + yield f"data: {chunk.model_dump_json(exclude_none = True)}\n\n" + + # Final chunk + final_chunk = ChatCompletionChunk( + id = completion_id, + created = created, + model = model_name, + choices = [ + ChunkChoice( + delta = ChoiceDelta(), + finish_reason = "stop", + ) + ], + ) + yield f"data: {final_chunk.model_dump_json(exclude_none = True)}\n\n" + yield "data: [DONE]\n\n" + + except asyncio.CancelledError: + cancel_event.set() + raise + except Exception as e: + logger.error(f"Error during GGUF streaming: {e}", exc_info = True) + error_chunk = { + "error": { + "message": "An internal error occurred", + "type": "server_error", + }, + } + yield f"data: {json.dumps(error_chunk)}\n\n" + + return StreamingResponse( + gguf_stream_chunks(), + media_type = "text/event-stream", + headers = { + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + else: + try: + full_text = "" + for token in gguf_generate(): + full_text = token + + response = ChatCompletion( + id = completion_id, + created = created, + model = model_name, + choices = [ + CompletionChoice( + message = CompletionMessage(content = full_text), + finish_reason = "stop", + ) + ], + ) + return JSONResponse(content = response.model_dump()) + + except Exception as e: + logger.error(f"Error during GGUF completion: {e}", exc_info = True) + raise HTTPException(status_code = 500, detail = str(e)) + + # ── Standard Unsloth path ───────────────────────────────── + + # Decode image (from content parts OR legacy field) + image_b64 = extracted_image_b64 or payload.image_base64 + image = None + + if image_b64: + try: + import base64 + from PIL import Image + from io import BytesIO + + model_info = backend.models.get(backend.active_model_name, {}) + if not model_info.get("is_vision"): + raise HTTPException( + status_code = 400, + detail = "Image provided but current model is text-only. Load a vision model.", + ) + + image_data = base64.b64decode(image_b64) + image = Image.open(BytesIO(image_data)) + image = backend.resize_image(image) + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code = 400, detail = f"Failed to decode image: {e}") + + # Shared generation kwargs + gen_kwargs = dict( + messages = chat_messages, + system_prompt = system_prompt, + image = image, + temperature = payload.temperature, + top_p = payload.top_p, + top_k = payload.top_k, + min_p = payload.min_p, + max_new_tokens = payload.max_tokens or 2048, + repetition_penalty = payload.repetition_penalty, + ) + + # Choose generation path (adapter-controlled or standard) + cancel_event = threading.Event() + + if payload.use_adapter is not None: + + def generate(): + return backend.generate_with_adapter_control( + use_adapter = payload.use_adapter, + cancel_event = cancel_event, + **gen_kwargs, + ) + else: + + def generate(): + return backend.generate_chat_response( + cancel_event = cancel_event, **gen_kwargs + ) + + completion_id = f"chatcmpl-{uuid.uuid4().hex[:12]}" + created = int(time.time()) + + # ── Streaming response ──────────────────────────────────────── + if payload.stream: + + async def stream_chunks(): + try: + first_chunk = ChatCompletionChunk( + id = completion_id, + created = created, + model = model_name, + choices = [ + ChunkChoice( + delta = ChoiceDelta(role = "assistant"), + finish_reason = None, + ) + ], + ) + yield f"data: {first_chunk.model_dump_json(exclude_none = True)}\n\n" + + prev_text = "" + for cumulative in generate(): + if await request.is_disconnected(): + cancel_event.set() + backend.reset_generation_state() + return + new_text = cumulative[len(prev_text) :] + prev_text = cumulative + if not new_text: + continue + chunk = ChatCompletionChunk( + id = completion_id, + created = created, + model = model_name, + choices = [ + ChunkChoice( + delta = ChoiceDelta(content = new_text), + finish_reason = None, + ) + ], + ) + yield f"data: {chunk.model_dump_json(exclude_none = True)}\n\n" + + final_chunk = ChatCompletionChunk( + id = completion_id, + created = created, + model = model_name, + choices = [ + ChunkChoice( + delta = ChoiceDelta(), + finish_reason = "stop", + ) + ], + ) + yield f"data: {final_chunk.model_dump_json(exclude_none = True)}\n\n" + yield "data: [DONE]\n\n" + + except asyncio.CancelledError: + cancel_event.set() + backend.reset_generation_state() + raise + except Exception as e: + backend.reset_generation_state() + logger.error(f"Error during OpenAI streaming: {e}", exc_info = True) + error_chunk = { + "error": { + "message": "An internal error occurred", + "type": "server_error", + }, + } + yield f"data: {json.dumps(error_chunk)}\n\n" + + return StreamingResponse( + stream_chunks(), + media_type = "text/event-stream", + headers = { + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + # ── Non-streaming response ──────────────────────────────────── + else: + try: + full_text = "" + for token in generate(): + full_text = token + + response = ChatCompletion( + id = completion_id, + created = created, + model = model_name, + choices = [ + CompletionChoice( + message = CompletionMessage(content = full_text), + finish_reason = "stop", + ) + ], + ) + return JSONResponse(content = response.model_dump()) + + except Exception as e: + backend.reset_generation_state() + logger.error(f"Error during OpenAI completion: {e}", exc_info = True) + raise HTTPException(status_code = 500, detail = str(e)) + + +# ===================================================================== +# OpenAI-Compatible Models Listing (/models → /v1/models) +# ===================================================================== + + +@router.get("/models") +async def openai_list_models( + current_subject: str = Depends(get_current_subject), +): + """ + OpenAI-compatible model listing endpoint. + + Returns the currently loaded model in the format expected by + OpenAI-compatible clients (``GET /v1/models``). + """ + models = [] + + # Check GGUF backend + llama_backend = get_llama_cpp_backend() + if llama_backend.is_loaded: + models.append( + { + "id": llama_backend.model_identifier, + "object": "model", + "owned_by": "local", + } + ) + + # Check Unsloth backend + backend = get_inference_backend() + if backend.active_model_name: + models.append( + { + "id": backend.active_model_name, + "object": "model", + "owned_by": "local", + } + ) + + return {"object": "list", "data": models} diff --git a/studio/backend/routes/models.py b/studio/backend/routes/models.py new file mode 100644 index 0000000000..99b42b491e --- /dev/null +++ b/studio/backend/routes/models.py @@ -0,0 +1,620 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Model Management API routes +""" + +import os +import sys +from pathlib import Path +from fastapi import APIRouter, Depends, HTTPException, Query +from typing import List, Optional +import structlog +from loggers import get_logger + +# Add backend directory to path +backend_path = Path(__file__).parent.parent.parent +if str(backend_path) not in sys.path: + sys.path.insert(0, str(backend_path)) + +from auth.authentication import get_current_subject + +# Import backend functions +try: + from utils.models import ( + scan_trained_loras, + scan_exported_models, + load_model_defaults, + get_base_model_from_lora, + is_vision_model, + is_embedding_model, + scan_checkpoints, + list_gguf_variants, + ModelConfig, + ) + from utils.models.model_config import ( + _pick_best_gguf, + _extract_quant_label, + is_audio_input_type, + ) + from core.inference import get_inference_backend + from utils.paths import ( + outputs_root, + exports_root, + resolve_output_dir, + resolve_export_dir, + ) +except ImportError: + # Fallback: try to import from parent directory + parent_backend = backend_path.parent / "backend" + if str(parent_backend) not in sys.path: + sys.path.insert(0, str(parent_backend)) + from utils.models import ( + scan_trained_loras, + scan_exported_models, + load_model_defaults, + get_base_model_from_lora, + is_vision_model, + is_embedding_model, + scan_checkpoints, + list_gguf_variants, + ModelConfig, + ) + from utils.models.model_config import ( + _pick_best_gguf, + _extract_quant_label, + is_audio_input_type, + ) + from core.inference import get_inference_backend + from utils.paths import ( + outputs_root, + exports_root, + resolve_output_dir, + resolve_export_dir, + ) + +from models import ( + CheckpointInfo, + CheckpointListResponse, + LocalModelInfo, + LocalModelListResponse, + ModelCheckpoints, + ModelDetails, + LoRAScanResponse, + LoRAInfo, + ModelListResponse, +) +from models.models import GgufVariantDetail, GgufVariantsResponse, ModelType +from models.responses import ( + LoRABaseModelResponse, + VisionCheckResponse, + EmbeddingCheckResponse, +) + +router = APIRouter() +logger = get_logger(__name__) + + +def derive_model_type( + is_vision: bool, audio_type: Optional[str], is_embedding: bool = False +) -> ModelType: + """Collapse individual capability flags into a single model modality string.""" + if is_embedding: + return "embeddings" + if audio_type is not None: + return "audio" + if is_vision: + return "vision" + return "text" + + +def _resolve_hf_cache_dir() -> Path: + """Resolve local HF cache root used by hub downloads.""" + try: + from huggingface_hub.constants import HF_HUB_CACHE + + return Path(HF_HUB_CACHE) + except Exception: + return Path.home() / ".cache" / "huggingface" / "hub" + + +def _scan_models_dir(models_dir: Path) -> List[LocalModelInfo]: + if not models_dir.exists() or not models_dir.is_dir(): + return [] + + found: List[LocalModelInfo] = [] + for child in models_dir.iterdir(): + if not child.is_dir(): + continue + has_model_files = ( + (child / "config.json").exists() + or (child / "adapter_config.json").exists() + or any(child.glob("*.safetensors")) + or any(child.glob("*.bin")) + or any(child.glob("*.gguf")) + ) + if not has_model_files: + continue + try: + updated_at = child.stat().st_mtime + except OSError: + updated_at = None + found.append( + LocalModelInfo( + id = str(child), + display_name = child.name, + path = str(child), + source = "models_dir", + updated_at = updated_at, + ), + ) + # Also scan for standalone .gguf files directly in the models directory + for gguf_file in models_dir.glob("*.gguf"): + if gguf_file.is_file(): + try: + updated_at = gguf_file.stat().st_mtime + except OSError: + updated_at = None + found.append( + LocalModelInfo( + id = str(gguf_file), + display_name = gguf_file.stem, + path = str(gguf_file), + source = "models_dir", + updated_at = updated_at, + ), + ) + + return found + + +def _scan_hf_cache(cache_dir: Path) -> List[LocalModelInfo]: + if not cache_dir.exists() or not cache_dir.is_dir(): + return [] + + found: List[LocalModelInfo] = [] + for repo_dir in cache_dir.glob("models--*"): + if not repo_dir.is_dir(): + continue + + repo_name = repo_dir.name[len("models--") :] + if not repo_name: + continue + model_id = repo_name.replace("--", "/") + + try: + updated_at = repo_dir.stat().st_mtime + except OSError: + updated_at = None + + found.append( + LocalModelInfo( + id = model_id, + model_id = model_id, + display_name = model_id.split("/")[-1], + path = str(repo_dir), + source = "hf_cache", + updated_at = updated_at, + ), + ) + return found + + +@router.get("/local", response_model = LocalModelListResponse) +async def list_local_models( + models_dir: str = Query( + default = "./models", description = "Directory to scan for local model folders" + ), + current_subject: str = Depends(get_current_subject), +): + """ + List local model candidates from custom models dir and HF cache. + """ + # Validate models_dir against an allowlist of trusted directories. + # Only the trusted Path objects are used for filesystem access -- the + # user-supplied string is only used for matching, never for path construction. + hf_cache_dir = _resolve_hf_cache_dir() + allowed_roots = [Path("./models").resolve(), hf_cache_dir] + try: + from utils.paths import studio_root, outputs_root + + allowed_roots.extend([studio_root(), outputs_root()]) + except Exception: + pass + + requested = os.path.realpath(os.path.expanduser(models_dir)) + models_root = None + for root in allowed_roots: + root_str = os.path.realpath(str(root)) + if requested == root_str or requested.startswith(root_str + os.sep): + models_root = root # Use the trusted root, not the user-supplied path + break + if models_root is None: + raise HTTPException( + status_code = 403, + detail = "Directory not allowed", + ) + + try: + local_models = _scan_models_dir(models_root) + _scan_hf_cache(hf_cache_dir) + + deduped: dict[str, LocalModelInfo] = {} + for model in local_models: + if model.id not in deduped: + deduped[model.id] = model + + models = sorted( + deduped.values(), + key = lambda item: (item.updated_at or 0), + reverse = True, + ) + + return LocalModelListResponse( + models_dir = str(models_root), + hf_cache_dir = str(hf_cache_dir), + models = models, + ) + except Exception as e: + logger.error(f"Error listing local models: {e}", exc_info = True) + raise HTTPException( + status_code = 500, + detail = f"Failed to list local models: {str(e)}", + ) + + +@router.get("/list") +async def list_models( + current_subject: str = Depends(get_current_subject), +): + """ + List available models (default models and loaded models). + + This endpoint returns the default models and any currently loaded models. + """ + try: + inference_backend = get_inference_backend() + + # Get default models + default_models = inference_backend.default_models + + # Get loaded models + loaded_models = [] + for model_name, model_data in inference_backend.models.items(): + _is_vision = model_data.get("is_vision", False) + _audio_type = model_data.get("audio_type") + model_info = ModelDetails( + id = model_name, + name = model_name.split("/")[-1] if "/" in model_name else model_name, + is_vision = _is_vision, + is_lora = model_data.get("is_lora", False), + is_audio = model_data.get("is_audio", False), + audio_type = _audio_type, + has_audio_input = model_data.get("has_audio_input", False), + model_type = derive_model_type(_is_vision, _audio_type), + ) + loaded_models.append(model_info) + + # Combine default and loaded models + all_models = [] + seen_ids = set() + + # Add default models + for model_id in default_models: + if model_id not in seen_ids: + model_info = ModelDetails( + id = model_id, + name = model_id.split("/")[-1] if "/" in model_id else model_id, + ) + all_models.append(model_info) + seen_ids.add(model_id) + + # Add loaded models + for model_info in loaded_models: + if model_info.id not in seen_ids: + all_models.append(model_info) + seen_ids.add(model_info.id) + + return ModelListResponse(models = all_models, default_models = default_models) + + except Exception as e: + logger.error(f"Error listing models: {e}", exc_info = True) + raise HTTPException(status_code = 500, detail = f"Failed to list models: {str(e)}") + + +@router.get("/config/{model_name:path}") +async def get_model_config( + model_name: str, + hf_token: Optional[str] = Query(None), + current_subject: str = Depends(get_current_subject), +): + """ + Get configuration for a specific model. + + This endpoint wraps the backend load_model_defaults function. + """ + try: + from utils.models.model_config import is_local_path + + if not is_local_path(model_name): + model_name = model_name.lower() + + logger.info(f"Getting model config for: {model_name}") + from utils.models.model_config import detect_audio_type + + # Load model defaults from backend + config_dict = load_model_defaults(model_name) + + # Detect model capabilities (pass HF token for gated models) + is_vision = is_vision_model(model_name) + is_embedding = is_embedding_model(model_name, hf_token = hf_token) + audio_type = detect_audio_type(model_name, hf_token = hf_token) + + # Check if it's a LoRA adapter + is_lora = False + base_model = None + try: + model_config = ModelConfig.from_identifier(model_name) + is_lora = model_config.is_lora + base_model = model_config.base_model if is_lora else None + except Exception: + pass + + logger.info( + f"Model config result for {model_name}: is_vision={is_vision}, is_embedding={is_embedding}, audio_type={audio_type}, is_lora={is_lora}" + ) + return ModelDetails( + id = model_name, + model_name = model_name, + config = config_dict, + is_vision = is_vision, + is_embedding = is_embedding, + is_lora = is_lora, + is_audio = audio_type is not None, + audio_type = audio_type, + has_audio_input = is_audio_input_type(audio_type), + model_type = derive_model_type(is_vision, audio_type, is_embedding), + base_model = base_model, + ) + + except Exception as e: + logger.error(f"Error getting model config: {e}", exc_info = True) + raise HTTPException( + status_code = 500, detail = f"Failed to get model config: {str(e)}" + ) + + +@router.get("/loras") +async def scan_loras( + outputs_dir: str = Query( + default = str(outputs_root()), description = "Directory to scan for LoRA adapters" + ), + exports_dir: str = Query( + default = str(exports_root()), description = "Directory to scan for exported models" + ), + current_subject: str = Depends(get_current_subject), +): + """ + Scan for trained LoRA adapters and exported models. + + Returns both training outputs (from outputs_dir) and exported models + (from exports_dir) in a single list, distinguished by source field. + """ + try: + resolved_outputs_dir = str(resolve_output_dir(outputs_dir)) + resolved_exports_dir = str(resolve_export_dir(exports_dir)) + lora_list = [] + + # Scan training outputs + trained_loras = scan_trained_loras(outputs_dir = resolved_outputs_dir) + for display_name, adapter_path in trained_loras: + base_model = get_base_model_from_lora(adapter_path) + lora_list.append( + LoRAInfo( + display_name = display_name, + adapter_path = adapter_path, + base_model = base_model, + source = "training", + ) + ) + + # Scan exported models (merged, LoRA, base — skips GGUF) + exported = scan_exported_models(exports_dir = resolved_exports_dir) + for display_name, model_path, export_type, base_model in exported: + lora_list.append( + LoRAInfo( + display_name = display_name, + adapter_path = model_path, + base_model = base_model, + source = "exported", + export_type = export_type, + ) + ) + + return LoRAScanResponse(loras = lora_list, outputs_dir = resolved_outputs_dir) + + except Exception as e: + logger.error(f"Error scanning LoRAs: {e}", exc_info = True) + raise HTTPException( + status_code = 500, detail = f"Failed to scan LoRA adapters: {str(e)}" + ) + + +@router.get("/loras/{lora_path:path}/base-model", response_model = LoRABaseModelResponse) +async def get_lora_base_model( + lora_path: str, + current_subject: str = Depends(get_current_subject), +): + """ + Get the base model for a LoRA adapter. + + This endpoint wraps the backend get_base_model_from_lora function. + """ + try: + base_model = get_base_model_from_lora(lora_path) + + if base_model is None: + raise HTTPException( + status_code = 404, + detail = f"Could not determine base model for LoRA: {lora_path}", + ) + + return LoRABaseModelResponse( + lora_path = lora_path, + base_model = base_model, + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error getting LoRA base model: {e}", exc_info = True) + raise HTTPException( + status_code = 500, detail = f"Failed to get base model: {str(e)}" + ) + + +@router.get("/check-vision/{model_name:path}", response_model = VisionCheckResponse) +async def check_vision_model( + model_name: str, + current_subject: str = Depends(get_current_subject), +): + """ + Check if a model is a vision model. + + This endpoint wraps the backend is_vision_model function. + """ + try: + logger.info(f"Checking if vision model: {model_name}") + is_vision = is_vision_model(model_name) + + logger.info(f"Vision check result for {model_name}: is_vision={is_vision}") + return VisionCheckResponse( + model_name = model_name, + is_vision = is_vision, + ) + + except Exception as e: + logger.error(f"Error checking vision model: {e}", exc_info = True) + raise HTTPException( + status_code = 500, detail = f"Failed to check vision model: {str(e)}" + ) + + +@router.get("/check-embedding/{model_name:path}", response_model = EmbeddingCheckResponse) +async def check_embedding_model( + model_name: str, + hf_token: Optional[str] = Query(None), + current_subject: str = Depends(get_current_subject), +): + """ + Check if a model is an embedding model. + + This endpoint wraps the backend is_embedding_model function. + """ + try: + logger.info(f"Checking if embedding model: {model_name}") + is_embedding = is_embedding_model(model_name, hf_token = hf_token) + + logger.info( + f"Embedding check result for {model_name}: is_embedding={is_embedding}" + ) + return EmbeddingCheckResponse( + model_name = model_name, + is_embedding = is_embedding, + ) + + except Exception as e: + logger.error(f"Error checking embedding model: {e}", exc_info = True) + raise HTTPException( + status_code = 500, detail = f"Failed to check embedding model: {str(e)}" + ) + + +@router.get("/gguf-variants", response_model = GgufVariantsResponse) +async def get_gguf_variants( + repo_id: str = Query( + ..., description = "HuggingFace repo ID (e.g. 'unsloth/gemma-3-4b-it-GGUF')" + ), + hf_token: Optional[str] = Query( + None, description = "HuggingFace token for private repos" + ), + current_subject: str = Depends(get_current_subject), +): + """ + List available GGUF quantization variants for a HuggingFace repo. + + Returns all available quantization variants (Q4_K_M, Q8_0, BF16, etc.) + with file sizes, whether the model supports vision, and the recommended + default variant. + """ + try: + variants, has_vision = list_gguf_variants(repo_id, hf_token = hf_token) + + # Determine default variant + filenames = [v.filename for v in variants] + best = _pick_best_gguf(filenames) + default_variant = _extract_quant_label(best) if best else None + + return GgufVariantsResponse( + repo_id = repo_id, + variants = [ + GgufVariantDetail( + filename = v.filename, + quant = v.quant, + size_bytes = v.size_bytes, + ) + for v in variants + ], + has_vision = has_vision, + default_variant = default_variant, + ) + + except Exception as e: + logger.error(f"Error listing GGUF variants for '{repo_id}': {e}", exc_info = True) + raise HTTPException( + status_code = 500, + detail = f"Failed to list GGUF variants: {str(e)}", + ) + + +@router.get("/checkpoints", response_model = CheckpointListResponse) +async def list_checkpoints( + outputs_dir: str = Query( + default = str(outputs_root()), + description = "Directory to scan for checkpoints", + ), + current_subject: str = Depends(get_current_subject), +): + """ + List available checkpoints in the outputs directory. + + Scans the outputs folder for training runs and their checkpoints. + """ + try: + resolved_outputs_dir = str(resolve_output_dir(outputs_dir)) + raw_models = scan_checkpoints(outputs_dir = resolved_outputs_dir) + + models = [ + ModelCheckpoints( + name = model_name, + checkpoints = [ + CheckpointInfo(display_name = display_name, path = path, loss = loss) + for display_name, path, loss in checkpoints + ], + base_model = metadata.get("base_model"), + peft_type = metadata.get("peft_type"), + lora_rank = metadata.get("lora_rank"), + ) + for model_name, checkpoints, metadata in raw_models + ] + + return CheckpointListResponse( + outputs_dir = resolved_outputs_dir, + models = models, + ) + except Exception as e: + logger.error(f"Error listing checkpoints: {e}", exc_info = True) + raise HTTPException( + status_code = 500, + detail = f"Failed to list checkpoints: {str(e)}", + ) diff --git a/studio/backend/routes/training.py b/studio/backend/routes/training.py new file mode 100644 index 0000000000..43b5212010 --- /dev/null +++ b/studio/backend/routes/training.py @@ -0,0 +1,829 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Training API routes +""" + +import sys +from pathlib import Path +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import StreamingResponse +from typing import Dict, Optional, Any +import structlog +from loggers import get_logger +import asyncio +from datetime import datetime + +# Add backend directory to path +# The backend code should be in the same directory structure +backend_path = Path(__file__).parent.parent.parent +if str(backend_path) not in sys.path: + sys.path.insert(0, str(backend_path)) + +# Import backend functions +try: + from core.training import get_training_backend + from utils.models.model_config import load_model_defaults + from utils.paths import resolve_dataset_path +except ImportError: + # Fallback: try to import from parent directory + parent_backend = backend_path.parent / "backend" + if str(parent_backend) not in sys.path: + sys.path.insert(0, str(parent_backend)) + from core.training import get_training_backend + from utils.models.model_config import load_model_defaults + from utils.paths import resolve_dataset_path + +# Auth +from auth.authentication import get_current_subject + +from models import ( + TrainingStartRequest, + TrainingJobResponse, + TrainingStatus, + TrainingProgress, +) +from models.responses import TrainingStopResponse, TrainingMetricsResponse +from pydantic import BaseModel as PydanticBaseModel + + +class TrainingStopRequest(PydanticBaseModel): + save: bool = True + + +router = APIRouter() +logger = get_logger(__name__) + + +@router.get("/hardware") +async def get_hardware_utilization( + current_subject: str = Depends(get_current_subject), +): + """ + Get a live snapshot of GPU hardware utilization. + + Designed to be polled by the frontend during training. + Returns GPU utilization %, temperature, VRAM usage, and power draw + via nvidia-smi for maximum accuracy. + """ + from utils.hardware import get_gpu_utilization + + return get_gpu_utilization() + + +@router.post("/start") +async def start_training( + request: TrainingStartRequest, + current_subject: str = Depends(get_current_subject), +): + """ + Start a training job. + + This endpoint initiates training in the background and returns immediately. + Use the /status endpoint to check training progress. + """ + try: + logger.info(f"Starting training job with model: {request.model_name}") + + # NOTE: No in-process ensure_transformers_version() call here. + # The subprocess (worker.py) activates the correct version in a + # fresh Python interpreter before importing any ML libraries. + + backend = get_training_backend() + + # Generate job ID and attach to backend for later status/progress calls + job_id = f"job_{datetime.now().strftime('%Y%m%d_%H%M%S')}" + backend.current_job_id = job_id + + # Check if training is already active + if backend.is_training_active(): + existing_job_id: Optional[str] = getattr(backend, "current_job_id", "") + return TrainingJobResponse( + job_id = existing_job_id or job_id, + status = "error", + message = ( + "Training is already in progress. " + "Stop current training before starting a new one." + ), + error = "Training already active", + ) + + # Validate dataset paths if provided + if request.local_datasets: + validated_datasets = [] + missing_datasets = [] + for dataset_path in request.local_datasets: + dataset_file = resolve_dataset_path(dataset_path) + + if not dataset_file.exists(): + missing_datasets.append( + f"{dataset_path} (resolved: {dataset_file})" + ) + continue + + logger.info(f"Found dataset file: {dataset_file}") + validated_datasets.append(str(dataset_file)) + + if missing_datasets: + missing_detail = "; ".join(missing_datasets[:3]) + raise HTTPException( + status_code = 400, + detail = f"Local dataset not found: {missing_detail}", + ) + request.local_datasets = validated_datasets + + # Convert request to kwargs for backend + training_kwargs = { + "model_name": request.model_name, + "training_type": request.training_type, + "hf_token": request.hf_token or "", + "load_in_4bit": request.load_in_4bit, + "max_seq_length": request.max_seq_length, + "hf_dataset": request.hf_dataset or "", + "local_datasets": request.local_datasets, + "format_type": request.format_type, + "subset": request.subset, + "train_split": request.train_split, + "eval_split": request.eval_split, + "eval_steps": request.eval_steps, + "dataset_slice_start": request.dataset_slice_start, + "dataset_slice_end": request.dataset_slice_end, + "custom_format_mapping": request.custom_format_mapping, + "num_epochs": request.num_epochs, + "learning_rate": request.learning_rate, + "batch_size": request.batch_size, + "gradient_accumulation_steps": request.gradient_accumulation_steps, + "warmup_steps": request.warmup_steps, + "warmup_ratio": request.warmup_ratio, + "max_steps": request.max_steps, + "save_steps": request.save_steps, + "weight_decay": request.weight_decay, + "random_seed": request.random_seed, + "packing": request.packing, + "optim": request.optim, + "lr_scheduler_type": request.lr_scheduler_type, + "use_lora": request.use_lora, + "lora_r": request.lora_r, + "lora_alpha": request.lora_alpha, + "lora_dropout": request.lora_dropout, + "target_modules": request.target_modules + if request.target_modules + else None, + "gradient_checkpointing": request.gradient_checkpointing.strip() + if request.gradient_checkpointing and request.gradient_checkpointing.strip() + else "unsloth", + "use_rslora": request.use_rslora, + "use_loftq": request.use_loftq, + "train_on_completions": request.train_on_completions, + "finetune_vision_layers": request.finetune_vision_layers, + "finetune_language_layers": request.finetune_language_layers, + "finetune_attention_modules": request.finetune_attention_modules, + "finetune_mlp_modules": request.finetune_mlp_modules, + "is_dataset_image": request.is_dataset_image, + "is_dataset_audio": request.is_dataset_audio, + "is_embedding": request.is_embedding, + "enable_wandb": request.enable_wandb, + "wandb_token": request.wandb_token or "", + "wandb_project": request.wandb_project or "", + "enable_tensorboard": request.enable_tensorboard, + "tensorboard_dir": request.tensorboard_dir or "", + "trust_remote_code": request.trust_remote_code, + } + + # Training page has no trust_remote_code toggle — the value comes from + # YAML model defaults applied when the user selects a model. As a safety + # net, consult the YAML directly so models that need it always get it. + if not training_kwargs["trust_remote_code"]: + model_defaults = load_model_defaults(request.model_name) + yaml_trust = model_defaults.get("training", {}).get( + "trust_remote_code", False + ) + if yaml_trust: + logger.info( + f"YAML config sets trust_remote_code=True for {request.model_name}" + ) + training_kwargs["trust_remote_code"] = True + + # Free GPU memory: shut down any running inference/export subprocesses + # before training starts (they'd compete for VRAM otherwise) + try: + from core.inference import get_inference_backend + + inf_backend = get_inference_backend() + if inf_backend.active_model_name: + logger.info( + "Unloading inference model '%s' to free GPU memory for training", + inf_backend.active_model_name, + ) + inf_backend._shutdown_subprocess() + inf_backend.active_model_name = None + inf_backend.models.clear() + except Exception as e: + logger.warning("Could not unload inference model: %s", e) + + try: + from core.export import get_export_backend + + exp_backend = get_export_backend() + if exp_backend.current_checkpoint: + logger.info( + "Shutting down export subprocess to free GPU memory for training" + ) + exp_backend._shutdown_subprocess() + exp_backend.current_checkpoint = None + exp_backend.is_vision = False + exp_backend.is_peft = False + except Exception as e: + logger.warning("Could not shut down export subprocess: %s", e) + + # start_training now spawns a subprocess (non-blocking) + success = backend.start_training(**training_kwargs) + + if not success: + progress_error = backend.trainer.training_progress.error + return TrainingJobResponse( + job_id = job_id, + status = "error", + message = progress_error or "Failed to start training subprocess", + error = progress_error or "subprocess_start_failed", + ) + + return TrainingJobResponse( + job_id = job_id, + status = "queued", + message = "Training job queued and starting in subprocess", + error = None, + ) + + except Exception as e: + logger.error(f"Error starting training: {e}", exc_info = True) + raise HTTPException( + status_code = 500, + detail = f"Failed to start training: {str(e)}", + ) + + +@router.post("/stop", response_model = TrainingStopResponse) +async def stop_training( + body: TrainingStopRequest = TrainingStopRequest(), + current_subject: str = Depends(get_current_subject), +): + """ + Stop the currently running training job. + + Body: + save (bool): If True (default), save the model at the current checkpoint. + """ + try: + backend = get_training_backend() + is_active = backend.is_training_active() + logger.info("Stop requested: save=%s is_active=%s", body.save, is_active) + + if not is_active: + return TrainingStopResponse( + status = "idle", message = "No training job is currently running" + ) + + # Call backend stop method + backend.stop_training(save = body.save) + + return TrainingStopResponse( + status = "stopped", + message = "Stop requested. Training will stop at the next safe step.", + ) + + except Exception as e: + logger.error(f"Error stopping training: {e}", exc_info = True) + raise HTTPException( + status_code = 500, detail = f"Failed to stop training: {str(e)}" + ) + + +@router.post("/reset") +async def reset_training( + current_subject: str = Depends(get_current_subject), +): + """ + Reset training state so the user can return to configuration. + """ + try: + backend = get_training_backend() + is_active = backend.is_training_active() + + if is_active: + if backend._cancel_requested: + # Cancel (save=False) was requested — force-terminate so we can reset immediately + logger.info( + "Force-terminating subprocess for immediate reset (cancel path)" + ) + backend.force_terminate() + else: + logger.warning( + "Rejected reset while training active: is_active=%s", is_active + ) + raise HTTPException( + status_code = 409, + detail = "Training is still running. Stop training and wait for it to finish before resetting.", + ) + + logger.info("Reset training state: clearing runtime + metric history") + backend._should_stop = False # Clear stop flag so status returns to idle + backend.trainer._update_progress( + is_training = False, + is_completed = False, + error = None, + status_message = "Ready to train", + step = 0, + loss = 0.0, + epoch = 0, + total_steps = 0, + ) + backend.loss_history = [] + backend.lr_history = [] + backend.step_history = [] + backend.grad_norm_history = [] + backend.grad_norm_step_history = [] + return {"status": "ok"} + except HTTPException: + raise + except Exception as e: + logger.error(f"Error resetting training: {e}", exc_info = True) + raise HTTPException( + status_code = 500, + detail = f"Failed to reset training: {str(e)}", + ) + + +@router.get("/status") +async def get_training_status( + current_subject: str = Depends(get_current_subject), +): + """ + Get the current training status. + """ + try: + backend = get_training_backend() + job_id: str = getattr(backend, "current_job_id", "") or "" + + # Check if training is active + is_active = backend.is_training_active() + + # Get progress info from trainer + try: + progress = backend.trainer.get_training_progress() + except Exception: + progress = None + + status_message = ( + getattr(progress, "status_message", None) if progress else None + ) or "Ready to train" + error_message = getattr(progress, "error", None) if progress else None + + # Check if training was stopped by user + trainer_stopped = getattr(backend, "_should_stop", False) + + # Derive high-level phase + if error_message: + phase = "error" + elif is_active: + msg_lower = status_message.lower() + if "loading" in msg_lower or "importing" in msg_lower: + phase = "loading_model" + elif any( + k in msg_lower for k in ["preparing", "initializing", "configuring"] + ): + phase = "configuring" + else: + phase = "training" + elif trainer_stopped: + phase = "stopped" + elif progress and getattr(progress, "is_completed", False): + phase = "completed" + else: + phase = "idle" + + details = None + if progress: + details = { + "epoch": getattr(progress, "epoch", 0), + "step": getattr(progress, "step", 0), + "total_steps": getattr(progress, "total_steps", 0), + "loss": getattr(progress, "loss", 0.0), + "learning_rate": getattr(progress, "learning_rate", 0.0), + } + + # Build metric history for chart recovery after SSE reconnection + metric_history = None + if backend.step_history: + metric_history = { + "steps": list(backend.step_history), + "loss": list(backend.loss_history), + "lr": list(backend.lr_history), + "grad_norm": list(getattr(backend, "grad_norm_history", [])), + "grad_norm_steps": list(getattr(backend, "grad_norm_step_history", [])), + "eval_loss": list(backend.eval_loss_history), + "eval_steps": list(backend.eval_step_history), + } + + return TrainingStatus( + job_id = job_id, + phase = phase, + is_training_running = is_active, + eval_enabled = backend.eval_enabled, + message = status_message, + error = error_message, + details = details, + metric_history = metric_history, + ) + + except Exception as e: + logger.error(f"Error getting training status: {e}", exc_info = True) + raise HTTPException( + status_code = 500, detail = f"Failed to get training status: {str(e)}" + ) + + +@router.get("/metrics", response_model = TrainingMetricsResponse) +async def get_training_metrics( + current_subject: str = Depends(get_current_subject), +): + """ + Get training metrics (loss, learning rate, steps). + """ + try: + backend = get_training_backend() + + # Get metrics from backend + loss_history = backend.loss_history + lr_history = backend.lr_history + step_history = backend.step_history + grad_norm_history = getattr(backend, "grad_norm_history", []) + grad_norm_step_history = getattr(backend, "grad_norm_step_history", []) + + # Get current values + current_loss = loss_history[-1] if loss_history else None + current_lr = lr_history[-1] if lr_history else None + current_step = step_history[-1] if step_history else None + + return TrainingMetricsResponse( + loss_history = loss_history, + lr_history = lr_history, + step_history = step_history, + grad_norm_history = grad_norm_history, + grad_norm_step_history = grad_norm_step_history, + current_loss = current_loss, + current_lr = current_lr, + current_step = current_step, + ) + + except Exception as e: + logger.error(f"Error getting training metrics: {e}", exc_info = True) + raise HTTPException( + status_code = 500, detail = f"Failed to get training metrics: {str(e)}" + ) + + +@router.get("/progress") +async def stream_training_progress( + request: Request, + current_subject: str = Depends(get_current_subject), +): + """ + Stream training progress updates using Server-Sent Events (SSE). + + This endpoint provides real-time updates on training progress. + Supports reconnection via the SSE spec: + - Sends `id:` with each event so the browser tracks position. + - Sends `retry:` to control reconnection interval. + - Sends named `event:` types (progress, heartbeat, complete, error). + - Reads `Last-Event-ID` header on reconnect to replay missed steps. + """ + # Read Last-Event-ID header for reconnection resume + last_event_id = request.headers.get("last-event-id") + resume_from_step: Optional[int] = None + if last_event_id is not None: + try: + resume_from_step = int(last_event_id) + logger.info(f"SSE reconnect: resuming from step {resume_from_step}") + except ValueError: + logger.warning(f"Invalid Last-Event-ID: {last_event_id}") + + async def event_generator(): + backend = get_training_backend() + job_id: str = getattr(backend, "current_job_id", "") or "" + + # ── Helpers ────────────────────────────────────────────── + def build_progress( + step: int, + loss: float, + learning_rate: float, + total_steps: int, + epoch: Optional[float] = None, + progress: Optional[Any] = None, + grad_norm_override: Optional[float] = None, + eval_loss_override: Optional[float] = None, + ) -> TrainingProgress: + total = max(total_steps, 0) + if step < 0 or total == 0: + progress_percent = 0.0 + else: + progress_percent = ( + float(step) / float(total) * 100.0 if total > 0 else 0.0 + ) + + # Get actual values from progress object if available + elapsed_seconds = ( + getattr(progress, "elapsed_seconds", None) if progress else None + ) + eta_seconds = getattr(progress, "eta_seconds", None) if progress else None + grad_norm = grad_norm_override + if grad_norm is None and progress: + grad_norm = getattr(progress, "grad_norm", None) + num_tokens = getattr(progress, "num_tokens", None) if progress else None + eval_loss = eval_loss_override + if eval_loss is None and progress: + eval_loss = getattr(progress, "eval_loss", None) + + return TrainingProgress( + job_id = job_id, + step = step, + total_steps = total, + loss = loss, + learning_rate = learning_rate, + progress_percent = progress_percent, + epoch = epoch, + elapsed_seconds = elapsed_seconds, + eta_seconds = eta_seconds, + grad_norm = grad_norm, + num_tokens = num_tokens, + eval_loss = eval_loss, + ) + + def format_sse( + data: str, + event: str = "progress", + event_id: Optional[int] = None, + ) -> str: + """Format a single SSE message with id/event/data fields.""" + lines = [] + if event_id is not None: + lines.append(f"id: {event_id}") + lines.append(f"event: {event}") + lines.append(f"data: {data}") + lines.append("") # trailing blank line + lines.append("") # double newline terminates the event + return "\n".join(lines) + + # ── Retry directive ────────────────────────────────────── + # Tell the browser to reconnect after 3 seconds if the connection drops + yield "retry: 3000\n\n" + + # ── Replay missed steps on reconnect ───────────────────── + if resume_from_step is not None and backend.step_history: + replayed = 0 + grad_norm_by_step = { + step_val: grad_val + for step_val, grad_val in zip( + getattr(backend, "grad_norm_step_history", []), + getattr(backend, "grad_norm_history", []), + ) + } + for i, step_val in enumerate(backend.step_history): + if step_val > resume_from_step: + loss_val = ( + backend.loss_history[i] + if i < len(backend.loss_history) + else 0.0 + ) + lr_val = ( + backend.lr_history[i] if i < len(backend.lr_history) else 0.0 + ) + tp_replay = getattr( + getattr(backend, "trainer", None), "training_progress", None + ) + total_replay = ( + getattr(tp_replay, "total_steps", step_val) + if tp_replay + else step_val + ) + epoch_replay = ( + getattr(tp_replay, "epoch", None) if tp_replay else None + ) + payload = build_progress( + step_val, + loss_val, + lr_val, + total_replay, + epoch_replay, + progress = tp_replay, + grad_norm_override = grad_norm_by_step.get(step_val), + ) + yield format_sse( + payload.model_dump_json(), event = "progress", event_id = step_val + ) + replayed += 1 + if replayed: + logger.info(f"SSE reconnect: replayed {replayed} missed steps") + + # ── Initial status (only on fresh connections) ─────────── + if resume_from_step is None: + is_active = backend.is_training_active() + tp = getattr(getattr(backend, "trainer", None), "training_progress", None) + initial_total_steps = getattr(tp, "total_steps", 0) if tp else 0 + initial_epoch = getattr(tp, "epoch", None) if tp else None + + initial_progress = build_progress( + step = 0, + loss = 0.0, + learning_rate = 0.0, + total_steps = initial_total_steps, + epoch = initial_epoch, + progress = tp, + ) + yield format_sse( + initial_progress.model_dump_json(), event = "progress", event_id = 0 + ) + + # If not active, send final state and exit + if not is_active: + if backend.step_history: + final_step = backend.step_history[-1] + final_loss = ( + backend.loss_history[-1] if backend.loss_history else 0.0 + ) + final_lr = backend.lr_history[-1] if backend.lr_history else 0.0 + final_total_steps = ( + getattr(tp, "total_steps", final_step) if tp else final_step + ) + final_epoch = getattr(tp, "epoch", None) if tp else None + payload = build_progress( + final_step, + final_loss, + final_lr, + final_total_steps, + final_epoch, + progress = tp, + ) + yield format_sse( + payload.model_dump_json(), event = "complete", event_id = final_step + ) + else: + yield format_sse( + build_progress(-1, 0.0, 0.0, 0, progress = tp).model_dump_json(), + event = "complete", + event_id = 0, + ) + return + + # ── Live polling loop ──────────────────────────────────── + last_step = resume_from_step if resume_from_step is not None else -1 + no_update_count = 0 + max_no_updates = ( + 1800 # Timeout after 30 minutes (large models need time for compilation) + ) + + while backend.is_training_active(): + try: + if backend.step_history: + current_step = backend.step_history[-1] + current_loss = ( + backend.loss_history[-1] if backend.loss_history else 0.0 + ) + current_lr = backend.lr_history[-1] if backend.lr_history else 0.0 + tp_inner = getattr( + getattr(backend, "trainer", None), "training_progress", None + ) + current_total_steps = ( + getattr(tp_inner, "total_steps", current_step) + if tp_inner + else current_step + ) + current_epoch = ( + getattr(tp_inner, "epoch", None) if tp_inner else None + ) + + # Only send if step changed + if current_step != last_step: + progress_payload = build_progress( + current_step, + current_loss, + current_lr, + current_total_steps, + current_epoch, + progress = tp_inner, + ) + yield format_sse( + progress_payload.model_dump_json(), + event = "progress", + event_id = current_step, + ) + last_step = current_step + no_update_count = 0 + else: + no_update_count += 1 + # Send heartbeat every 10 seconds + if no_update_count % 10 == 0: + heartbeat_payload = build_progress( + current_step, + current_loss, + current_lr, + current_total_steps, + current_epoch, + progress = tp_inner, + ) + yield format_sse( + heartbeat_payload.model_dump_json(), + event = "heartbeat", + event_id = current_step, + ) + else: + # No steps yet, but training is active (model loading, etc.) + no_update_count += 1 + if no_update_count % 5 == 0: + # Pull total_steps and status from trainer so + # the frontend can show "Tokenizing…" etc. + tp_prep = getattr( + getattr(backend, "trainer", None), + "training_progress", + None, + ) + prep_total = ( + getattr(tp_prep, "total_steps", 0) if tp_prep else 0 + ) + preparing_payload = build_progress( + 0, + 0.0, + 0.0, + prep_total, + progress = tp_prep, + ) + yield format_sse( + preparing_payload.model_dump_json(), + event = "heartbeat", + event_id = 0, + ) + + # Timeout check + if no_update_count > max_no_updates: + logger.warning("Progress stream timeout - no updates received") + tp_timeout = getattr( + getattr(backend, "trainer", None), "training_progress", None + ) + timeout_payload = build_progress( + last_step, 0.0, 0.0, 0, progress = tp_timeout + ) + yield format_sse( + timeout_payload.model_dump_json(), + event = "error", + event_id = last_step if last_step >= 0 else 0, + ) + break + + await asyncio.sleep(1) # Poll every second + + except Exception as e: + logger.error(f"Error in progress stream: {e}", exc_info = True) + tp_error = getattr( + getattr(backend, "trainer", None), "training_progress", None + ) + error_payload = build_progress(0, 0.0, 0.0, 0, progress = tp_error) + yield format_sse( + error_payload.model_dump_json(), + event = "error", + event_id = last_step if last_step >= 0 else 0, + ) + break + + # ── Final "complete" event ─────────────────────────────── + final_step = backend.step_history[-1] if backend.step_history else last_step + final_loss = backend.loss_history[-1] if backend.loss_history else 0.0 + final_lr = backend.lr_history[-1] if backend.lr_history else 0.0 + final_tp = getattr(getattr(backend, "trainer", None), "training_progress", None) + final_total_steps = ( + getattr(final_tp, "total_steps", final_step) if final_tp else final_step + ) + final_epoch = getattr(final_tp, "epoch", None) if final_tp else None + final_payload = build_progress( + final_step, + final_loss, + final_lr, + final_total_steps, + final_epoch, + progress = final_tp, + ) + yield format_sse( + final_payload.model_dump_json(), + event = "complete", + event_id = final_step if final_step >= 0 else 0, + ) + + return StreamingResponse( + event_generator(), + media_type = "text/event-stream", + headers = { + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) diff --git a/studio/backend/run.py b/studio/backend/run.py new file mode 100644 index 0000000000..c1aad55b14 --- /dev/null +++ b/studio/backend/run.py @@ -0,0 +1,164 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Run script for Unsloth UI Backend. +Works independently and can be moved to any directory. +""" + +import os +import sys + +# Suppress annoying C-level dependency warnings globally (e.g. SwigPyPacked) +os.environ["PYTHONWARNINGS"] = "ignore" + +from pathlib import Path + +# Add the backend directory to Python path +backend_dir = Path(__file__).parent +if str(backend_dir) not in sys.path: + sys.path.insert(0, str(backend_dir)) + +from loggers import get_logger + +logger = get_logger(__name__) + + +def _resolve_external_ip() -> str: + """ + Resolve the machine's external IP address. + + Tries (in order): + 1. GCE metadata server (instant, works on Google Cloud VMs) + 2. ifconfig.me (works anywhere with internet) + 3. LAN IP via UDP socket trick (fallback) + """ + import urllib.request + import socket + + # 1. Try GCE metadata server (responds in <10ms on GCE, times out fast elsewhere) + try: + req = urllib.request.Request( + "http://metadata.google.internal/computeMetadata/v1/instance/network-interfaces/0/access-configs/0/external-ip", + headers = {"Metadata-Flavor": "Google"}, + ) + with urllib.request.urlopen(req, timeout = 1) as resp: + ip = resp.read().decode().strip() + if ip: + return ip + except Exception: + pass + + # 2. Try public IP service + try: + with urllib.request.urlopen("https://ifconfig.me", timeout = 3) as resp: + ip = resp.read().decode().strip() + if ip: + return ip + except Exception: + pass + + # 3. Fallback: LAN IP via UDP socket trick + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + ip = s.getsockname()[0] + s.close() + return ip + except Exception: + return "0.0.0.0" + + +def run_server( + host: str = "0.0.0.0", + port: int = 8000, + frontend_path: Path = "studio/frontend/dist", + silent: bool = False, +): + """ + Start the FastAPI server. + + Args: + host: Host to bind to + port: Port to bind to + frontend_path: Path to frontend build directory (optional) + silent: Suppress startup messages + """ + import nest_asyncio + + nest_asyncio.apply() + + import asyncio + from threading import Thread + import time + import uvicorn + + from main import app, setup_frontend + from utils.paths import ensure_studio_directories + + # Create all standard directories on startup + ensure_studio_directories() + + # Setup frontend if path provided + if frontend_path: + if setup_frontend(app, frontend_path): + if not silent: + print(f"✅ Frontend loaded from {frontend_path}") + else: + if not silent: + print(f"⚠️ Frontend not found at {frontend_path}") + + # Run server + def _run(): + config = uvicorn.Config( + app, host = host, port = port, log_level = "info", access_log = False + ) + server = uvicorn.Server(config) + asyncio.run(server.serve()) + + thread = Thread(target = _run, daemon = True) + thread.start() + time.sleep(3) + + if not silent: + display_host = _resolve_external_ip() if host == "0.0.0.0" else host + + print("") + print("=" * 50) + print(f"🦥 Unsloth Studio is running on port {port}") + print(f" Local: http://localhost:{port}") + print(f" External: http://{display_host}:{port}") + print(f" API: http://{display_host}:{port}/api") + print(f" Health: http://{display_host}:{port}/api/health") + print("=" * 50) + + return app + + +# For direct execution +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description = "Run Unsloth UI Backend server") + parser.add_argument("--host", default = "0.0.0.0", help = "Host to bind to") + parser.add_argument("--port", type = int, default = 8000, help = "Port to bind to") + parser.add_argument( + "--frontend", + type = str, + default = "studio/frontend/dist", + help = "Path to frontend build", + ) + parser.add_argument("--silent", action = "store_true", help = "Suppress output") + + args = parser.parse_args() + + frontend_path = Path(args.frontend) if args.frontend else None + run_server( + host = args.host, port = args.port, frontend_path = frontend_path, silent = args.silent + ) + + # Keep running + import time + + while True: + time.sleep(1) diff --git a/studio/backend/state/.gitkeep b/studio/backend/state/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/studio/backend/state/__init__.py b/studio/backend/state/__init__.py new file mode 100644 index 0000000000..32014236c6 --- /dev/null +++ b/studio/backend/state/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 diff --git a/studio/backend/tests/__init__.py b/studio/backend/tests/__init__.py new file mode 100644 index 0000000000..32014236c6 --- /dev/null +++ b/studio/backend/tests/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 diff --git a/studio/backend/tests/conftest.py b/studio/backend/tests/conftest.py new file mode 100644 index 0000000000..053e9b85d9 --- /dev/null +++ b/studio/backend/tests/conftest.py @@ -0,0 +1,16 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Shared pytest configuration for the backend test suite. +Ensures that the backend root is on sys.path so that +`import utils.utils` (and similar flat imports) resolve correctly. +""" + +import sys +from pathlib import Path + +# Add backend root to sys.path (mirrors how the app itself is launched) +_backend_root = Path(__file__).resolve().parent.parent +if str(_backend_root) not in sys.path: + sys.path.insert(0, str(_backend_root)) diff --git a/studio/backend/tests/test_utils.py b/studio/backend/tests/test_utils.py new file mode 100644 index 0000000000..3c33b33cb3 --- /dev/null +++ b/studio/backend/tests/test_utils.py @@ -0,0 +1,387 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Tests for utils/hardware and utils/utils — device detection, GPU memory, error formatting. + +These tests are designed to pass on ANY platform: + • NVIDIA GPU (CUDA backend, requires torch) + • Apple Silicon (MLX backend, requires mlx) + • CPU-only (no GPU at all) + +No ML framework is imported at the top level. +Tests that need torch/mlx internals for mocking are skipped when unavailable. + +Run with: + cd studio/backend + python -m pytest tests/test_utils.py -v +""" + +import platform +from unittest.mock import patch, MagicMock + +import pytest + +# --- Conditional framework imports --- +try: + import torch + + HAS_TORCH = True +except ImportError: + HAS_TORCH = False + +try: + import mlx.core as mx + + HAS_MLX = True +except ImportError: + HAS_MLX = False + +needs_torch = pytest.mark.skipif(not HAS_TORCH, reason = "PyTorch not installed") +needs_mlx = pytest.mark.skipif(not HAS_MLX, reason = "MLX not installed") + +from utils.hardware import ( + get_device, + detect_hardware, + is_apple_silicon, + clear_gpu_cache, + get_gpu_memory_info, + log_gpu_memory, + DeviceType, +) +import utils.hardware.hardware as _hw_module +from utils.utils import format_error_message + + +# ========== Helpers ========== + + +def _actual_device() -> str: + """Return the real device string for the current machine.""" + if HAS_TORCH and torch.cuda.is_available(): + return "cuda" + if is_apple_silicon() and HAS_MLX: + return "mlx" + return "cpu" + + +def _reset_and_detect(): + """Reset the cached DEVICE global and re-run detection.""" + _hw_module.DEVICE = None + return detect_hardware() + + +# ========== get_device() ========== + + +class TestGetDevice: + """Tests for get_device() — should agree with the real hardware.""" + + def setup_method(self): + self._saved_device = _hw_module.DEVICE + + def teardown_method(self): + _hw_module.DEVICE = self._saved_device + + def test_returns_valid_device_type(self): + result = get_device() + assert result in (DeviceType.CUDA, DeviceType.MLX, DeviceType.CPU) + + def test_matches_actual_hardware(self): + assert get_device().value == _actual_device() + + # --- Mocked paths --- + + @needs_torch + def test_returns_cuda_when_cuda_available(self): + with ( + patch("utils.hardware.hardware._has_torch", return_value = True), + patch("torch.cuda.is_available", return_value = True), + ): + assert _reset_and_detect() == DeviceType.CUDA + + @needs_mlx + def test_returns_mlx_when_on_apple_silicon_with_mlx(self): + with ( + patch("utils.hardware.hardware._has_torch", return_value = False), + patch("utils.hardware.hardware.is_apple_silicon", return_value = True), + patch("utils.hardware.hardware._has_mlx", return_value = True), + ): + assert _reset_and_detect() == DeviceType.MLX + + def test_returns_cpu_when_nothing_available(self): + with ( + patch("utils.hardware.hardware._has_torch", return_value = False), + patch("utils.hardware.hardware.is_apple_silicon", return_value = False), + patch("utils.hardware.hardware._has_mlx", return_value = False), + ): + assert _reset_and_detect() == DeviceType.CPU + + +# ========== is_apple_silicon() ========== + + +class TestIsAppleSilicon: + def test_returns_bool(self): + assert isinstance(is_apple_silicon(), bool) + + def test_true_on_darwin_arm64(self): + with patch("utils.hardware.hardware.platform") as mock_plat: + mock_plat.system.return_value = "Darwin" + mock_plat.machine.return_value = "arm64" + assert is_apple_silicon() is True + + def test_false_on_linux_x86(self): + with patch("utils.hardware.hardware.platform") as mock_plat: + mock_plat.system.return_value = "Linux" + mock_plat.machine.return_value = "x86_64" + assert is_apple_silicon() is False + + def test_false_on_darwin_x86(self): + """Intel Mac should return False.""" + with patch("utils.hardware.hardware.platform") as mock_plat: + mock_plat.system.return_value = "Darwin" + mock_plat.machine.return_value = "x86_64" + assert is_apple_silicon() is False + + +# ========== clear_gpu_cache() ========== + + +class TestClearGpuCache: + """clear_gpu_cache() must never raise, regardless of platform.""" + + def test_does_not_raise(self): + clear_gpu_cache() + + @needs_torch + def test_calls_cuda_cache_when_cuda(self): + with ( + patch("utils.hardware.hardware.get_device", return_value = DeviceType.CUDA), + patch("torch.cuda.empty_cache") as mock_empty, + patch("torch.cuda.ipc_collect") as mock_ipc, + ): + clear_gpu_cache() + mock_empty.assert_called_once() + mock_ipc.assert_called_once() + + @needs_mlx + def test_mlx_does_not_raise(self): + """MLX cache clear is a no-op — should just succeed.""" + with patch("utils.hardware.hardware.get_device", return_value = DeviceType.MLX): + clear_gpu_cache() + + def test_noop_on_cpu(self): + with patch("utils.hardware.hardware.get_device", return_value = DeviceType.CPU): + clear_gpu_cache() + + +# ========== get_gpu_memory_info() ========== + + +class TestGetGpuMemoryInfo: + def test_returns_dict(self): + result = get_gpu_memory_info() + assert isinstance(result, dict) + + def test_has_available_key(self): + assert "available" in get_gpu_memory_info() + + def test_has_backend_key(self): + assert "backend" in get_gpu_memory_info() + + def test_backend_matches_device(self): + result = get_gpu_memory_info() + assert result["backend"] == get_device().value + + # --- When a GPU IS available --- + + @pytest.mark.skipif( + _actual_device() == "cpu", reason = "No GPU available on this machine" + ) + def test_gpu_available_fields(self): + result = get_gpu_memory_info() + assert result["available"] is True + assert result["total_gb"] > 0 + assert result["allocated_gb"] >= 0 + assert result["free_gb"] >= 0 + assert 0 <= result["utilization_pct"] <= 100 + assert "device_name" in result + + # --- CUDA-specific mocked test --- + + @needs_torch + def test_cuda_path_returns_correct_fields(self): + mock_props = MagicMock() + mock_props.total_memory = 16 * (1024**3) + mock_props.name = "NVIDIA Test GPU" + + with ( + patch("utils.hardware.hardware.get_device", return_value = DeviceType.CUDA), + patch("torch.cuda.current_device", return_value = 0), + patch("torch.cuda.get_device_properties", return_value = mock_props), + patch("torch.cuda.memory_allocated", return_value = 4 * (1024**3)), + patch("torch.cuda.memory_reserved", return_value = 6 * (1024**3)), + ): + result = get_gpu_memory_info() + + assert result["available"] is True + assert result["backend"] == "cuda" + assert result["device_name"] == "NVIDIA Test GPU" + assert abs(result["total_gb"] - 16.0) < 0.01 + assert abs(result["allocated_gb"] - 4.0) < 0.01 + assert abs(result["free_gb"] - 12.0) < 0.01 + assert abs(result["utilization_pct"] - 25.0) < 0.1 + + # --- MLX-specific mocked test --- + + @needs_mlx + def test_mlx_path_returns_correct_fields(self): + mock_psutil_mem = MagicMock() + mock_psutil_mem.total = 32 * (1024**3) # 32 GB unified + + mock_psutil = MagicMock() + mock_psutil.virtual_memory.return_value = mock_psutil_mem + + with ( + patch("utils.hardware.hardware.get_device", return_value = DeviceType.MLX), + patch.dict("sys.modules", {"psutil": mock_psutil}), + ): + result = get_gpu_memory_info() + + assert result["available"] is True + assert result["backend"] == "mlx" + assert "Apple Silicon" in result["device_name"] + assert abs(result["total_gb"] - 32.0) < 0.01 + + # --- CPU-only path --- + + def test_cpu_path_returns_unavailable(self): + with patch("utils.hardware.hardware.get_device", return_value = DeviceType.CPU): + result = get_gpu_memory_info() + assert result["available"] is False + assert result["backend"] == "cpu" + + # --- Error resilience --- + + @needs_torch + def test_cuda_error_returns_unavailable(self): + with ( + patch("utils.hardware.hardware.get_device", return_value = DeviceType.CUDA), + patch( + "torch.cuda.current_device", + side_effect = RuntimeError("CUDA init failed"), + ), + ): + result = get_gpu_memory_info() + assert result["available"] is False + assert "error" in result + + +# ========== log_gpu_memory() ========== + + +class TestLogGpuMemory: + def test_does_not_raise(self): + log_gpu_memory("test") + + def test_logs_gpu_info_when_available(self, caplog): + fake_info = { + "available": True, + "backend": "cuda", + "device_name": "FakeGPU", + "allocated_gb": 2.0, + "total_gb": 16.0, + "utilization_pct": 12.5, + "free_gb": 14.0, + } + import structlog + from loggers import get_logger + + with ( + patch( + "utils.hardware.hardware.get_gpu_memory_info", return_value = fake_info + ), + caplog.at_level(logging.INFO, logger = "utils.hardware.hardware"), + ): + log_gpu_memory("unit-test") + + assert "unit-test" in caplog.text + assert "CUDA" in caplog.text + assert "FakeGPU" in caplog.text + + def test_logs_cpu_fallback_when_no_gpu(self, caplog): + fake_info = {"available": False, "backend": "cpu"} + import structlog + from loggers import get_logger + + with ( + patch( + "utils.hardware.hardware.get_gpu_memory_info", return_value = fake_info + ), + caplog.at_level(logging.INFO, logger = "utils.hardware.hardware"), + ): + log_gpu_memory("cpu-test") + + assert "No GPU available" in caplog.text + + +# ========== format_error_message() ========== + + +class TestFormatErrorMessage: + def test_not_found(self): + err = Exception("Repository not found for unsloth/test") + msg = format_error_message(err, "unsloth/test") + assert "not found" in msg.lower() + assert "test" in msg + + def test_unauthorized(self): + err = Exception("401 Unauthorized") + msg = format_error_message(err, "some/model") + assert "authentication" in msg.lower() or "unauthorized" in msg.lower() + + def test_gated_model(self): + err = Exception("Access to model requires authentication") + msg = format_error_message(err, "meta/llama") + assert "authentication" in msg.lower() + + def test_invalid_token(self): + err = Exception("Invalid user token") + msg = format_error_message(err, "any/model") + assert "invalid" in msg.lower() + + # --- OOM on CUDA --- + + @needs_torch + def test_cuda_oom(self): + err = Exception("CUDA out of memory") + with patch("utils.hardware.get_device", return_value = DeviceType.CUDA): + msg = format_error_message(err, "big/model") + assert "GPU" in msg + assert "big/model" not in msg + assert "model" in msg + + # --- OOM on MLX --- + + @needs_mlx + def test_mlx_oom(self): + err = Exception("MLX backend out of memory") + with patch("utils.hardware.get_device", return_value = DeviceType.MLX): + msg = format_error_message(err, "unsloth/huge-model") + assert "Apple Silicon" in msg + + # --- OOM on CPU --- + + def test_cpu_oom(self): + err = Exception("not enough memory to allocate") + with patch("utils.hardware.get_device", return_value = DeviceType.CPU): + msg = format_error_message(err, "any/model") + assert "system" in msg.lower() + + # --- Generic fallback --- + + def test_generic_error(self): + err = Exception("Something completely unexpected") + msg = format_error_message(err, "any/model") + assert msg == "Something completely unexpected" diff --git a/studio/backend/utils/.gitkeep b/studio/backend/utils/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/studio/backend/utils/__init__.py b/studio/backend/utils/__init__.py new file mode 100644 index 0000000000..32014236c6 --- /dev/null +++ b/studio/backend/utils/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 diff --git a/studio/backend/utils/cache_cleanup.py b/studio/backend/utils/cache_cleanup.py new file mode 100644 index 0000000000..b3ffbcfc05 --- /dev/null +++ b/studio/backend/utils/cache_cleanup.py @@ -0,0 +1,35 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Utility for cleaning up the Unsloth compiled cache directory. + +The unsloth_compiled_cache is created by unsloth_zoo/compiler.py during +FastModel.from_pretrained() and contains model-type-specific compiled Python +files. It should be cleared between model loads to avoid stale artefacts. +""" + +import shutil +import structlog +from loggers import get_logger +from pathlib import Path + +logger = get_logger(__name__) + +# Possible locations where unsloth_compiled_cache may appear +_BACKEND_DIR = Path(__file__).resolve().parent.parent # studio/backend +_PROJECT_ROOT = _BACKEND_DIR.parent.parent # repo root + +_CACHE_DIRS = [ + _BACKEND_DIR / "unsloth_compiled_cache", + _PROJECT_ROOT / "unsloth_compiled_cache", + _PROJECT_ROOT / "studio" / "tmp" / "unsloth_compiled_cache", +] + + +def clear_unsloth_compiled_cache() -> None: + """Remove every known unsloth_compiled_cache directory (idempotent).""" + for cache_dir in _CACHE_DIRS: + if cache_dir.exists(): + logger.info(f"Removing unsloth compiled cache: {cache_dir}") + shutil.rmtree(cache_dir, ignore_errors = True) diff --git a/studio/backend/utils/datasets/__init__.py b/studio/backend/utils/datasets/__init__.py new file mode 100644 index 0000000000..c9237d83c1 --- /dev/null +++ b/studio/backend/utils/datasets/__init__.py @@ -0,0 +1,105 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Dataset utilities package. + +This package provides utilities for dataset format detection, conversion, +and processing for LLM and VLM fine-tuning workflows. + +Modules: +- format_detection: Detect dataset formats (Alpaca, ShareGPT, ChatML) +- format_conversion: Convert between dataset formats +- chat_templates: Apply chat templates to datasets +- vlm_processing: Vision-Language Model processing utilities +- data_collators: Custom data collators for training +- model_mappings: Model-to-template mapping constants +""" + +# Format detection +from .format_detection import ( + detect_dataset_format, + detect_custom_format_heuristic, + detect_multimodal_dataset, + detect_vlm_dataset_structure, +) + +# Format conversion +from .format_conversion import ( + standardize_chat_format, + convert_chatml_to_alpaca, + convert_alpaca_to_chatml, + convert_to_vlm_format, + convert_llava_to_vlm_format, + convert_sharegpt_with_images_to_vlm_format, +) + +# Chat templates +from .chat_templates import ( + apply_chat_template_to_dataset, + get_dataset_info_summary, + get_tokenizer_chat_template, + DEFAULT_ALPACA_TEMPLATE, +) + +# VLM processing +from .vlm_processing import ( + generate_smart_vlm_instruction, +) + +# Data collators +from .data_collators import ( + DataCollatorSpeechSeq2SeqWithPadding, + DeepSeekOCRDataCollator, + VLMDataCollator, +) + +# Model mappings (constants) +from .model_mappings import ( + TEMPLATE_TO_MODEL_MAPPER, + MODEL_TO_TEMPLATE_MAPPER, + TEMPLATE_TO_RESPONSES_MAPPER, +) + +# Legacy imports from the original dataset_utils.py for backward compatibility +# These functions have not yet been refactored into separate modules +from .dataset_utils import ( + check_dataset_format, + format_and_template_dataset, + format_dataset, +) + +# Public API +__all__ = [ + # Detection + "detect_dataset_format", + "detect_custom_format_heuristic", + "detect_multimodal_dataset", + "detect_vlm_dataset_structure", + # Conversion + "standardize_chat_format", + "convert_chatml_to_alpaca", + "convert_alpaca_to_chatml", + "convert_to_vlm_format", + "convert_llava_to_vlm_format", + "convert_sharegpt_with_images_to_vlm_format", + # Templates + "apply_chat_template_to_dataset", + "get_dataset_info_summary", + "get_tokenizer_chat_template", + "DEFAULT_ALPACA_TEMPLATE", + # VLM + "generate_smart_vlm_instruction", + # Collators + "DataCollatorSpeechSeq2SeqWithPadding", + "DeepSeekOCRDataCollator", + "VLMDataCollator", + # Mappings + "TEMPLATE_TO_MODEL_MAPPER", + "MODEL_TO_TEMPLATE_MAPPER", + "TEMPLATE_TO_RESPONSES_MAPPER", + # Main entry points + "check_dataset_format", + "format_and_template_dataset", + "format_dataset", +] diff --git a/studio/backend/utils/datasets/chat_templates.py b/studio/backend/utils/datasets/chat_templates.py new file mode 100644 index 0000000000..23fc856772 --- /dev/null +++ b/studio/backend/utils/datasets/chat_templates.py @@ -0,0 +1,395 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Chat template application utilities for dataset processing. + +This module contains functions for applying chat templates to datasets +and generating dataset info summaries. +""" + +from torch.utils.data import IterableDataset + +from .format_detection import detect_dataset_format, detect_multimodal_dataset, detect_custom_format_heuristic +from .model_mappings import MODEL_TO_TEMPLATE_MAPPER +from loggers import get_logger +logger = get_logger(__name__) + + + + +DEFAULT_ALPACA_TEMPLATE = """Below is an instruction that describes a task, paired with an input that provides further context. Write a response that appropriately completes the request. + +### Instruction: +{} + +### Input: +{} + +### Response: +{}""" + + +def get_tokenizer_chat_template(tokenizer, model_name): + """ + Gets appropriate chat template for tokenizer based on model. + Uses Unsloth's get_chat_template if model is in the mapper. + + Args: + tokenizer: HuggingFace tokenizer + model_name: Model class name (e.g., "Gemma3ForCausalLM") + + Returns: + tokenizer: Tokenizer with appropriate chat template applied + """ + try: + from unsloth.chat_templates import get_chat_template + except ImportError: + # Unsloth not available, return tokenizer as-is + return tokenizer + + # Normalize model_name to lowercase for matching + model_name_lower = model_name.lower() + + # Check if model matches any template in mapper + matched_template = None + + # Direct match in MODEL_TO_TEMPLATE_MAPPER + if model_name_lower in MODEL_TO_TEMPLATE_MAPPER: + matched_template = MODEL_TO_TEMPLATE_MAPPER[model_name_lower] + logger.info(f"📝 Applying Unsloth chat template: {matched_template}") + try: + tokenizer = get_chat_template( + tokenizer, + chat_template = matched_template, + ) + except Exception as e: + logger.info(f"⚠️ Failed to apply Unsloth template '{matched_template}': {e}") + logger.info(f" Falling back to tokenizer's default chat template") + else: + # Check if tokenizer actually has a chat_template set + has_chat_template = ( + hasattr(tokenizer, 'chat_template') + and tokenizer.chat_template is not None + ) + if has_chat_template: + logger.info(f"📝 Using tokenizer's own chat template (no Unsloth template match)") + else: + # Base model with no chat template — apply default ChatML + logger.info(f"📝 No chat template found — applying default ChatML template (base model)") + try: + tokenizer = get_chat_template( + tokenizer, + chat_template = "chatml", + ) + except Exception as e: + logger.info(f"⚠️ Failed to apply default ChatML template: {e}") + logger.info(f" Falling back to tokenizer as-is") + + return tokenizer + + +def get_dataset_info_summary(dataset_info): + """ + Returns a human-readable summary for UI display. + """ + detected_format = dataset_info["detected_format"] + final_format = dataset_info["final_format"] + + format_descriptions = { + "alpaca": "Alpaca format (instruction/input/output)", + "sharegpt": "ShareGPT format (needs standardization)", + "chatml_messages": "ChatML format (messages column) - OpenAI compatible", + "chatml_conversations": "ChatML format (conversations column) - HuggingFace standard", + "unknown": "Unknown format" + } + + return { + "detected_format": detected_format, + "final_format": final_format, + "detected_description": format_descriptions.get(detected_format, "Unknown"), + "final_description": format_descriptions.get(final_format, "Unknown"), + "chat_column": dataset_info["chat_column"], + "is_standardized": dataset_info["is_standardized"], + "warnings": dataset_info.get("warnings", []), + "ready_for_training": dataset_info["is_standardized"] and final_format != "unknown" + } + + +def apply_chat_template_to_dataset( + dataset_info, + tokenizer, + model_name = None, + custom_prompt_template = None, + add_eos_token = False, + remove_bos_prefix = False, + custom_format_mapping = None, + auto_detect_mapping = True, + batch_size = 1000, + num_proc = None, +): + """ + Applies chat template to dataset based on its format. + + Args: + dataset_info: Output from format_dataset() with metadata + tokenizer: Tokenizer with chat template + custom_prompt_template: Optional string template for custom formatting + add_eos_token: If True, appends tokenizer.eos_token to each text + remove_bos_prefix: If True, removes '' prefix (for Gemma, etc.) + custom_format_mapping: Dict mapping custom columns to standard format + batch_size: Batch size for processing + num_proc: Number of processes + + Returns: + dict with dataset, success status, warnings, and errors + """ + dataset = dataset_info["dataset"] + final_format = dataset_info["final_format"] + chat_column = dataset_info["chat_column"] + is_standardized = dataset_info["is_standardized"] + + warnings = list(dataset_info.get("warnings", [])) + errors = [] + + # Get EOS token if needed + eos_token = "" + if add_eos_token: + if hasattr(tokenizer, 'eos_token') and tokenizer.eos_token: + eos_token = tokenizer.eos_token + else: + warnings.append("add_eos_token=True but tokenizer has no eos_token") + + # CUSTOM FORMAT MAPPING (for non-standard datasets) + if final_format == "unknown": + # Try auto-detection if no custom mapping provided + if custom_format_mapping is None and auto_detect_mapping: + # Check if format_dataset already tried and failed + if not dataset_info.get("auto_detection_attempted", False): + custom_format_mapping = detect_custom_format_heuristic(dataset) + if custom_format_mapping: + warnings.append(f"Auto-detected column mapping: {custom_format_mapping}") + else: + errors.append("Could not auto-detect format mapping") + return { + "dataset": dataset, + "success": False, + "warnings": warnings, + "errors": errors + } + else: + # Already failed once in format_dataset, don't retry + errors.append( + "Format remains unknown after detection attempts. " + "Please provide custom_format_mapping to specify column roles manually." + ) + return { + "dataset": dataset, + "success": False, + "warnings": warnings, + "errors": errors + } + + if custom_format_mapping: + warnings.append(f"Applying custom format mapping: {custom_format_mapping}") + is_user_provided = dataset_info.get("custom_format_mapping") is not None + + def _apply_custom_mapping(examples): + conversations = [] + num_examples = len(examples[list(examples.keys())[0]]) + + # Only preserve unmapped columns if auto-detected + preserved_columns = {} + if not is_user_provided: + all_columns = set(examples.keys()) + mapped_columns = set(custom_format_mapping.keys()) + non_mapped_columns = all_columns - mapped_columns + + for col in non_mapped_columns: + preserved_columns[col] = examples[col] + + for i in range(num_examples): + convo = [] + role_order = ['system', 'user', 'assistant'] + + for target_role in role_order: + for col_name, role in custom_format_mapping.items(): + if role == target_role and col_name in examples: + content = examples[col_name][i] + + if is_user_provided: + # User explicitly mapped - include even if empty + convo.append({"role": role, "content": str(content) if content else ""}) + else: + # Auto-detected - skip empty + if content and str(content).strip(): + convo.append({"role": role, "content": str(content)}) + + conversations.append(convo) + + result = {"conversations": conversations} + if not is_user_provided: + result.update(preserved_columns) + return result + + try: + dataset = dataset.map(_apply_custom_mapping, batched = True, batch_size = batch_size) + # Update to use conversations format + final_format = "chatml_conversations" + chat_column = "conversations" + is_standardized = True + warnings.append("Successfully converted to ChatML format via custom mapping") + except Exception as e: + errors.append(f"Custom format mapping failed: {e}") + return { + "dataset": dataset, + "success": False, + "warnings": warnings, + "errors": errors + } + + # ALPACA FORMAT + if final_format == "alpaca": + + # Set alpaca chat template on tokenizer for saving (if not already set) + # This ensures the template is saved with the model for inference + if not (hasattr(tokenizer, 'chat_template') and tokenizer.chat_template): + try: + from unsloth.chat_templates import get_chat_template + tokenizer = get_chat_template(tokenizer, chat_template = "alpaca") + logger.info(f"📝 Set alpaca chat template on tokenizer for model saving") + except Exception as e: + logger.info(f"⚠️ Could not set alpaca template on tokenizer: {e}") + + # Use custom template if provided + def _format_alpaca_custom(examples): + texts = [] + for i in range(len(examples["instruction"])): + fields = { + "instruction": examples["instruction"][i], + "input": examples.get("input", [""] * len(examples["instruction"]))[i], + "output": examples["output"][i] + } + + try: + text = DEFAULT_ALPACA_TEMPLATE.format(fields["instruction"], fields["input"], fields["output"]) + text += eos_token + texts.append(text) + except KeyError as e: + errors.append(f"Custom template missing field: {e}") + texts.append("") + + return {"text": texts} + + formatted_fn = _format_alpaca_custom + + try: + dataset_map_kwargs = { + 'batched': True, + 'batch_size': batch_size, + } + + if not isinstance(dataset, IterableDataset): + from utils.hardware import safe_num_proc + if num_proc is None or type(num_proc) is not int: + num_proc = safe_num_proc() + else: + num_proc = safe_num_proc(num_proc) + dataset_map_kwargs['num_proc'] = num_proc + dataset_map_kwargs['desc'] = "Applying template to Alpaca format" + + formatted_dataset = dataset.map(formatted_fn, **dataset_map_kwargs) + + return { + "dataset": formatted_dataset, + "success": True, + "warnings": warnings, + "errors": errors + } + except Exception as e: + errors.append(f"Failed to format Alpaca dataset: {e}") + return { + "dataset": dataset, + "success": False, + "warnings": warnings, + "errors": errors + } + + # CHATML FORMATS + elif final_format in ["chatml_messages", "chatml_conversations"]: + + if not is_standardized: + warnings.append("Dataset may not be fully standardized") + + # Apply Unsloth chat template if model matches + if model_name: + tokenizer = get_tokenizer_chat_template(tokenizer, model_name) + + def _format_chatml(examples): + convos = examples[chat_column] + texts = [] + + for convo in convos: + try: + text = tokenizer.apply_chat_template( + convo, + tokenize = False, + add_generation_prompt = False + ) + + if remove_bos_prefix: + text = text.removeprefix('') + text += eos_token + + texts.append(text) + except Exception as e: + if len(texts) == 0: + warnings.append(f"Chat template failed: {e}") + texts.append("") + + return {"text": texts} + + try: + dataset_map_kwargs = { + 'batched': True, + 'batch_size': batch_size, + } + + if not isinstance(dataset, IterableDataset): + from utils.hardware import safe_num_proc + if num_proc is None or type(num_proc) is not int: + num_proc = safe_num_proc() + else: + num_proc = safe_num_proc(num_proc) + dataset_map_kwargs['num_proc'] = num_proc + dataset_map_kwargs['desc'] = f"Applying chat template to {final_format}" + + formatted_dataset = dataset.map(_format_chatml, **dataset_map_kwargs) + + return { + "dataset": formatted_dataset, + "success": True, + "warnings": warnings, + "errors": errors + } + except Exception as e: + errors.append(f"Failed to format ChatML dataset: {e}") + return { + "dataset": dataset, + "success": False, + "warnings": warnings, + "errors": errors + } + + # UNKNOWN FORMAT + else: + errors.append( + f"Cannot apply chat template to format: {final_format}. " + f"This should not happen after custom mapping." + ) + return { + "dataset": dataset, + "success": False, + "warnings": warnings, + "errors": errors + } diff --git a/studio/backend/utils/datasets/data_collators.py b/studio/backend/utils/datasets/data_collators.py new file mode 100644 index 0000000000..04b24bbae3 --- /dev/null +++ b/studio/backend/utils/datasets/data_collators.py @@ -0,0 +1,203 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Data collators for dataset processing. + +This module contains custom data collators for training, +particularly for VLM/OCR processing. +""" + +import torch +from dataclasses import dataclass +from typing import Any, List, Optional, Union +from loggers import get_logger + +logger = get_logger(__name__) + + +@dataclass +class DataCollatorSpeechSeq2SeqWithPadding: + """ + Data collator for Whisper speech-to-text training. + + Pads input features (audio) and label sequences (text) separately, + masks padding in labels with -100, and strips leading BOS token. + Mirrors the collator from the Whisper.ipynb notebook. + """ + + processor: Any + + def __call__(self, features: List[dict]) -> dict: + input_features = [ + {"input_features": feature["input_features"]} for feature in features + ] + batch = self.processor.feature_extractor.pad( + input_features, return_tensors = "pt" + ) + + label_features = [{"input_ids": feature["labels"]} for feature in features] + labels_batch = self.processor.tokenizer.pad(label_features, return_tensors = "pt") + + labels = labels_batch["input_ids"].masked_fill( + labels_batch.attention_mask.ne(1), -100 + ) + + if (labels[:, 0] == self.processor.tokenizer.bos_token_id).all().cpu().item(): + labels = labels[:, 1:] + + batch["labels"] = labels + return batch + + +@dataclass +class DeepSeekOCRDataCollator: + """ + Data collator for DeepSeek OCR VLM training. + + Handles: + - Image processing via processor + - Text tokenization + - Proper label masking for instruction fine-tuning + """ + + processor: Any # Qwen2VLProcessor or similar + max_length: int = 2048 + ignore_index: int = -100 + + def __call__(self, batch: List[dict]) -> dict: + """ + Collate a batch of samples. + + Args: + batch: List of dicts, each with 'messages' containing + [{'role': 'user', 'content': [...]}, {'role': 'assistant', 'content': [...]}] + + Returns: + dict with input_ids, attention_mask, labels, pixel_values, etc. + """ + from PIL import Image + + # Extract messages and images + all_messages = [] + all_images = [] + + for sample in batch: + messages = sample["messages"] + all_messages.append(messages) + + # Extract PIL images from content + for msg in messages: + content = msg.get("content", []) + if isinstance(content, list): + for item in content: + if isinstance(item, dict) and item.get("type") == "image": + img = item.get("image") + if img is not None and hasattr(img, "size"): # PIL Image + all_images.append(img) + + # Process with the VL processor + try: + # Qwen2VL style processing + texts = [ + self.processor.apply_chat_template( + msgs, tokenize = False, add_generation_prompt = False + ) + for msgs in all_messages + ] + + # Process with images + inputs = self.processor( + text = texts, + images = all_images if all_images else None, + return_tensors = "pt", + padding = True, + truncation = True, + max_length = self.max_length, + ) + + # Create labels (mask input, keep output) + labels = inputs["input_ids"].clone() + + # Simple masking: mask padding tokens + labels[labels == self.processor.tokenizer.pad_token_id] = self.ignore_index + + inputs["labels"] = labels + + return inputs + + except Exception as e: + logger.info(f"⚠️ DeepSeekOCRDataCollator error: {e}") + raise + + +@dataclass +class VLMDataCollator: + """ + Generic VLM data collator that works with various processors. + + Supports: + - Qwen2VL + - LLaVA + - Other VL models with compatible processors + """ + + processor: Any + max_length: int = 2048 + ignore_index: int = -100 + mask_input_tokens: bool = True # Whether to mask user tokens in labels + + def __call__(self, batch: List[dict]) -> dict: + """ + Collate a batch of VLM samples. + """ + all_messages = [] + all_images = [] + + for sample in batch: + messages = sample.get("messages", []) + all_messages.append(messages) + + # Extract images + for msg in messages: + content = msg.get("content", []) + if isinstance(content, list): + for item in content: + if isinstance(item, dict): + img = item.get("image") + if img is not None: + all_images.append(img) + + # Apply chat template + texts = [ + self.processor.apply_chat_template( + msgs, tokenize = False, add_generation_prompt = False + ) + for msgs in all_messages + ] + + # Process inputs + inputs = self.processor( + text = texts, + images = all_images if all_images else None, + return_tensors = "pt", + padding = True, + truncation = True, + max_length = self.max_length, + ) + + # Create labels + labels = inputs["input_ids"].clone() + + # Mask padding + if hasattr(self.processor, "tokenizer"): + pad_token_id = self.processor.tokenizer.pad_token_id + else: + pad_token_id = self.processor.pad_token_id + + if pad_token_id is not None: + labels[labels == pad_token_id] = self.ignore_index + + inputs["labels"] = labels + + return inputs diff --git a/studio/backend/utils/datasets/dataset_utils.py b/studio/backend/utils/datasets/dataset_utils.py new file mode 100644 index 0000000000..9d15b86ca1 --- /dev/null +++ b/studio/backend/utils/datasets/dataset_utils.py @@ -0,0 +1,1159 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Dataset utilities for format detection, conversion, and template application. + +This module provides the main entry points for dataset processing: +- check_dataset_format: Lightweight check if manual mapping is needed (for frontend) +- format_dataset: Detects and normalizes dataset formats +- format_and_template_dataset: End-to-end processing with chat template application + +All internal utilities have been moved to separate modules: +- format_detection: detect_dataset_format, detect_multimodal_dataset, etc. +- format_conversion: standardize_chat_format, convert_chatml_to_alpaca, etc. +- chat_templates: apply_chat_template_to_dataset, get_tokenizer_chat_template, etc. +- vlm_processing: generate_smart_vlm_instruction +- data_collators: DeepSeekOCRDataCollator, VLMDataCollator +- model_mappings: TEMPLATE_TO_MODEL_MAPPER +""" + +import json + +# Import from modular files +from .format_detection import ( + detect_dataset_format, + detect_multimodal_dataset, + detect_vlm_dataset_structure, + detect_custom_format_heuristic, +) +from .format_conversion import ( + standardize_chat_format, + convert_chatml_to_alpaca, + convert_alpaca_to_chatml, + convert_to_vlm_format, + convert_llava_to_vlm_format, + convert_sharegpt_with_images_to_vlm_format, +) +from .chat_templates import ( + apply_chat_template_to_dataset, + get_dataset_info_summary, + get_tokenizer_chat_template, + DEFAULT_ALPACA_TEMPLATE, +) +from .vlm_processing import generate_smart_vlm_instruction +from .data_collators import DeepSeekOCRDataCollator, VLMDataCollator +from .model_mappings import TEMPLATE_TO_MODEL_MAPPER +from loggers import get_logger + +logger = get_logger(__name__) + + +def check_dataset_format(dataset, is_vlm: bool = False) -> dict: + """ + Lightweight format check without processing - for frontend validation. + + Use this to quickly determine if user needs to manually map columns + before calling the full format_and_template_dataset(). + + Args: + dataset: HuggingFace dataset + is_vlm: Whether this is a Vision-Language Model dataset + + Returns: + dict: { + "requires_manual_mapping": bool - True if user must map columns, + "detected_format": str - The detected format, + "columns": list - Available column names for mapping UI, + "suggested_mapping": dict or None - Auto-detected mapping if available, + "detected_image_column": str or None - For VLM only, + "detected_text_column": str or None - For VLM only, + } + """ + columns = ( + list(dataset.column_names) + if hasattr(dataset, "column_names") + else list(next(iter(dataset)).keys()) + ) + + # Auto-detect multimodal data regardless of is_vlm flag + multimodal_info = detect_multimodal_dataset(dataset) + is_audio = multimodal_info.get("is_audio", False) + + if multimodal_info["is_image"]: + is_vlm = True # Route to VLM detection for image datasets + + # Common audio fields for all return paths + audio_fields = { + "is_audio": is_audio, + "detected_audio_column": multimodal_info.get("detected_audio_column"), + "detected_speaker_column": multimodal_info.get("detected_speaker_column"), + } + + if is_vlm: + vlm_structure = detect_vlm_dataset_structure(dataset) + requires_mapping = vlm_structure["format"] == "unknown" + + warning = None + if requires_mapping: + img_col = vlm_structure.get("image_column") + txt_col = vlm_structure.get("text_column") + missing = [] + if not img_col: + missing.append("image") + if not txt_col: + missing.append("text") + if missing: + warning = ( + f"Could not auto-detect {' or '.join(missing)} column. " + "Please assign image and text columns manually." + ) + + return { + "requires_manual_mapping": requires_mapping, + "detected_format": vlm_structure["format"], + "columns": columns, + "suggested_mapping": None, + "detected_image_column": vlm_structure.get("image_column"), + "detected_text_column": vlm_structure.get("text_column"), + "is_image": multimodal_info["is_image"], + "multimodal_columns": multimodal_info.get("multimodal_columns"), + "warning": warning, + **audio_fields, + } + + if is_audio: + # Audio dataset — require manual mapping only when columns can't be auto-detected + detected_audio = multimodal_info.get("detected_audio_column") + detected_text = multimodal_info.get("detected_text_column") + needs_mapping = not detected_audio or not detected_text + return { + "requires_manual_mapping": needs_mapping, + "detected_format": "audio", + "columns": columns, + "suggested_mapping": None, + "detected_image_column": None, + "detected_text_column": multimodal_info.get("detected_text_column"), + "is_image": False, + "multimodal_columns": multimodal_info.get("audio_columns"), + **audio_fields, + } + + # Text / LLM flow + detected = detect_dataset_format(dataset) + + # If format is unknown, try heuristic detection + if detected["format"] == "unknown": + heuristic_mapping = detect_custom_format_heuristic(dataset) + if heuristic_mapping: + return { + "requires_manual_mapping": False, + "detected_format": "custom_heuristic", + "columns": columns, + "suggested_mapping": heuristic_mapping, + "detected_image_column": None, + "detected_text_column": None, + "is_image": False, + "multimodal_columns": None, + **audio_fields, + } + else: + # Heuristic failed — user must map manually (or use AI Assist) + return { + "requires_manual_mapping": True, + "detected_format": "unknown", + "columns": columns, + "suggested_mapping": None, + "detected_image_column": None, + "detected_text_column": None, + "is_image": False, + "multimodal_columns": None, + "warning": ( + f"Could not auto-detect column roles for columns: {columns}. " + "Please assign roles manually, or use AI Assist." + ), + **audio_fields, + } + + # Known format detected + return { + "requires_manual_mapping": False, + "detected_format": detected["format"], + "columns": columns, + "suggested_mapping": None, + "detected_image_column": None, + "detected_text_column": None, + "is_image": False, + "multimodal_columns": None, + **audio_fields, + } + + +# Normalise any format-specific role to canonical chatml (user/assistant/system) +_TO_CHATML = { + "user": "user", + "human": "user", + "instruction": "user", + "assistant": "assistant", + "gpt": "assistant", + "output": "assistant", + "system": "system", + "input": "system", +} +_CHATML_ROLE_ORDER = ("system", "user", "assistant") +_CHATML_TO_ALPACA = {"user": "instruction", "system": "input", "assistant": "output"} + + +def _apply_user_mapping(dataset, mapping: dict, batch_size: int = 1000): + """ + Apply user-provided column mapping to convert dataset to conversations format. + + Accepts chatml (user/assistant/system), sharegpt (human/gpt/system), and + alpaca (instruction/input/output) role names — all normalised to chatml output. + + If the mapping contains ``__``-prefixed metadata keys (from the conversion + advisor), routes to template-based conversion instead of simple role mapping. + + Returns: + Dataset with single 'conversations' column + """ + # Split metadata from column roles + meta = {k: v for k, v in mapping.items() if k.startswith("__")} + column_roles = {k: v for k, v in mapping.items() if not k.startswith("__")} + + if meta: + return _apply_template_mapping(dataset, column_roles, meta, batch_size) + + # ── Simple mode (original logic) ── + # Pre-compute: group columns by canonical chatml role + role_groups: dict[str, list[str]] = {r: [] for r in _CHATML_ROLE_ORDER} + for col_name, role in column_roles.items(): + canonical = _TO_CHATML.get(role) + if canonical: + role_groups[canonical].append(col_name) + + def _convert(examples): + num = len(next(iter(examples.values()))) + conversations = [] + for i in range(num): + convo = [] + for chatml_role in _CHATML_ROLE_ORDER: + for col in role_groups[chatml_role]: + if col in examples: + content = examples[col][i] + convo.append( + { + "role": chatml_role, + "content": str(content) if content else "", + } + ) + conversations.append(convo) + return {"conversations": conversations} + + return dataset.map( + _convert, + batched = True, + batch_size = batch_size, + remove_columns = dataset.column_names, + ) + + +def _extract_column_value(val, col: str, label_mapping: dict) -> str: + """Extract a string value from a column, handling complex types and label mapping.""" + # Handle complex types (dicts, lists) — extract useful text instead of raw repr + if isinstance(val, dict): + # Common pattern: {"text": [...]} in QA datasets + if "text" in val: + inner = val["text"] + str_val = inner[0] if isinstance(inner, list) and inner else str(inner) + else: + str_val = json.dumps(val, ensure_ascii = False) + elif isinstance(val, list): + str_val = val[0] if len(val) == 1 else ", ".join(str(v) for v in val) + else: + str_val = str(val) if val is not None else "" + + # Apply label mapping if this column has one + if col in label_mapping and isinstance(label_mapping[col], dict): + str_val = label_mapping[col].get(str_val, str_val) + + return str_val + + +def _apply_template_mapping( + dataset, column_roles: dict, meta: dict, batch_size: int = 1000 +): + """ + Apply advisor-driven mapping for non-conversational datasets. + + Groups columns by their assigned role (user/assistant), concatenates + values within each role into a single message, and injects an optional + system prompt. Label mapping is applied to convert integer labels + to human-readable strings. + + Returns: + Dataset with single 'conversations' column + """ + system_prompt = meta.get("__system_prompt", "") + label_mapping = meta.get("__label_mapping", {}) # {col: {int_str: label_str}} + + # Group columns by canonical chatml role + role_groups: dict[str, list[str]] = {"user": [], "assistant": []} + for col, role in column_roles.items(): + canonical = _TO_CHATML.get(role, role) + if canonical in role_groups: + role_groups[canonical].append(col) + + import logging as _log + + _log.getLogger(__name__).info( + f"Applying role mapping: sys={bool(system_prompt)}, " + f"user_cols={role_groups['user']}, asst_cols={role_groups['assistant']}, " + f"label_map={list(label_mapping.keys())}" + ) + + def _convert(examples): + num = len(next(iter(examples.values()))) + conversations = [] + for i in range(num): + convo = [] + + # System prompt (generated, static across all rows) + if system_prompt: + convo.append({"role": "system", "content": system_prompt}) + + # User message: concatenate all user-role column values + user_parts = [] + for col in role_groups["user"]: + if col in examples: + user_parts.append( + _extract_column_value(examples[col][i], col, label_mapping) + ) + if user_parts: + convo.append({"role": "user", "content": "\n".join(user_parts)}) + + # Assistant message: concatenate all assistant-role column values + asst_parts = [] + for col in role_groups["assistant"]: + if col in examples: + asst_parts.append( + _extract_column_value(examples[col][i], col, label_mapping) + ) + if asst_parts: + convo.append({"role": "assistant", "content": "\n".join(asst_parts)}) + + conversations.append(convo) + return {"conversations": conversations} + + return dataset.map( + _convert, + batched = True, + batch_size = batch_size, + remove_columns = dataset.column_names, + ) + + +def _apply_user_mapping_alpaca(dataset, mapping: dict, batch_size: int = 1000): + """ + Apply user-provided column mapping to convert dataset to Alpaca format. + + Accepts any format's role names — normalises via _TO_CHATML, then maps + user → instruction, system → input, assistant → output. + + Returns: + Dataset with instruction/input/output columns + """ + col_for: dict[str, str | None] = { + "instruction": None, + "input": None, + "output": None, + } + for col_name, role in mapping.items(): + canonical = _TO_CHATML.get(role) + alpaca_field = _CHATML_TO_ALPACA.get(canonical) if canonical else None + if alpaca_field: + col_for[alpaca_field] = col_name + + def _convert(examples): + num = len(next(iter(examples.values()))) + instructions, inputs, outputs = [], [], [] + for i in range(num): + for field, dest in ( + ("instruction", instructions), + ("input", inputs), + ("output", outputs), + ): + col = col_for[field] + val = ( + str(examples[col][i]) + if col and col in examples and examples[col][i] + else "" + ) + dest.append(val) + return {"instruction": instructions, "input": inputs, "output": outputs} + + return dataset.map( + _convert, + batched = True, + batch_size = batch_size, + remove_columns = dataset.column_names, + ) + + +def format_dataset( + dataset, + format_type = "auto", + tokenizer = None, + aliases_for_system = [ + "system", + ], + aliases_for_user = [ + "user", + "human", + "input", + ], + aliases_for_assistant = [ + "gpt", + "assistant", + "output", + ], + batch_size = 1000, + num_proc = None, + auto_detect_custom = True, + custom_format_mapping = None, +): + """ + Formats dataset and returns metadata. + + Returns: + dict: { + "dataset": processed dataset, + "detected_format": original format detected, + "final_format": final format after processing, + "chat_column": column name with chat data, + "is_standardized": whether role names are standardized, + "requires_manual_mapping": True if format detection failed and user must map columns, + "warnings": list of warning messages + } + """ + + # Detect multimodal first (needed for all flows) + multimodal_info = detect_multimodal_dataset(dataset) + + # If user provided explicit mapping, skip detection and apply in the requested format + if custom_format_mapping: + try: + if format_type == "alpaca": + mapped_dataset = _apply_user_mapping_alpaca( + dataset, custom_format_mapping, batch_size + ) + final_format = "alpaca" + chat_column = None + else: + # auto / chatml / sharegpt / conversational — all produce chatml conversations + # (sharegpt is always standardized to role/content internally) + mapped_dataset = _apply_user_mapping( + dataset, custom_format_mapping, batch_size + ) + final_format = "chatml_conversations" + chat_column = "conversations" + + return { + "dataset": mapped_dataset, + "detected_format": "user_mapped", + "final_format": final_format, + "chat_column": chat_column, + "is_standardized": True, + "requires_manual_mapping": False, + "is_image": multimodal_info["is_image"], + "multimodal_info": multimodal_info, + "warnings": [ + f"Applied user-provided column mapping ({format_type}): {custom_format_mapping}" + ], + } + except Exception as e: + return { + "dataset": dataset, + "detected_format": "user_mapped", + "final_format": "unknown", + "chat_column": None, + "is_standardized": False, + "requires_manual_mapping": True, + "is_image": multimodal_info["is_image"], + "multimodal_info": multimodal_info, + "warnings": [f"Failed to apply user mapping: {e}"], + } + + # Detect current format + detected = detect_dataset_format(dataset) + warnings = [] + + # Add multimodal warning if detected + if multimodal_info["is_image"]: + warnings.append( + f"Multimodal dataset detected. Found columns: {multimodal_info['multimodal_columns']}" + ) + + # AUTO MODE: Keep format but standardize if needed + if format_type == "auto": + # Alpaca - keep as is + if detected["format"] == "alpaca": + return { + "dataset": dataset, + "detected_format": "alpaca", + "final_format": "alpaca", + "chat_column": None, + "is_standardized": True, + "requires_manual_mapping": False, + "is_image": multimodal_info["is_image"], + "multimodal_info": multimodal_info, + "warnings": [], + } + + # ShareGPT - needs standardization + elif detected["format"] == "sharegpt": + try: + standardized = standardize_chat_format( + dataset, + tokenizer, + aliases_for_system, + aliases_for_user, + aliases_for_assistant, + batch_size, + num_proc, + ) + return { + "dataset": standardized, + "detected_format": "sharegpt", + "final_format": f"chatml_{detected['chat_column']}", + "chat_column": detected["chat_column"], + "is_standardized": True, + "requires_manual_mapping": False, + "is_image": multimodal_info["is_image"], + "multimodal_info": multimodal_info, + "warnings": [], + } + except Exception as e: + warnings.append(f"Failed to standardize ShareGPT format: {e}") + return { + "dataset": dataset, + "detected_format": "sharegpt", + "final_format": "sharegpt", + "chat_column": detected["chat_column"], + "is_standardized": False, + "requires_manual_mapping": True, + "is_image": multimodal_info["is_image"], + "multimodal_info": multimodal_info, + "warnings": warnings, + } + + elif detected["format"] == "chatml" and detected["chat_column"] in [ + "conversations", + "messages", + "texts", + ]: + return { + "dataset": dataset, + "detected_format": f"chatml_{detected['chat_column']}", + "final_format": f"chatml_{detected['chat_column']}", + "chat_column": detected["chat_column"], + "is_standardized": True, + "requires_manual_mapping": False, + "is_image": multimodal_info["is_image"], + "multimodal_info": multimodal_info, + "warnings": warnings, + } + + # Unknown - try standardization, if fails pass as is + else: + warnings.append( + f"Unknown format detected. Keys found: {detected['sample_keys']}" + ) + + # NEW: Try heuristic detection + if auto_detect_custom: + custom_mapping = detect_custom_format_heuristic(dataset) + if custom_mapping: + warnings.append(f"Auto-detected column mapping: {custom_mapping}") + + def _apply_auto_mapping(examples): + conversations = [] + num_examples = len(examples[list(examples.keys())[0]]) + + # Preserve non-mapped columns + all_columns = set(examples.keys()) + mapped_columns = set(custom_mapping.keys()) + preserved_columns = { + col: examples[col] for col in all_columns - mapped_columns + } + + for i in range(num_examples): + convo = [] + for target_role in ["system", "user", "assistant"]: + for col_name, role in custom_mapping.items(): + if role == target_role and col_name in examples: + content = examples[col_name][i] + if content and str(content).strip(): + convo.append( + {"role": role, "content": str(content)} + ) + conversations.append(convo) + + return {"conversations": conversations, **preserved_columns} + + try: + dataset = dataset.map( + _apply_auto_mapping, batched = True, batch_size = batch_size + ) + return { + "dataset": dataset, + "detected_format": "unknown", + "final_format": "chatml_conversations", + "chat_column": "conversations", + "is_standardized": True, + "requires_manual_mapping": False, + "is_image": multimodal_info["is_image"], + "multimodal_info": multimodal_info, + "warnings": warnings, + } + except Exception as e: + warnings.append(f"Auto-detection failed: {e}") + + # Try standardization as a last resort + if detected["chat_column"]: + try: + standardized = standardize_chat_format( + dataset, + tokenizer, + aliases_for_system, + aliases_for_user, + aliases_for_assistant, + batch_size, + num_proc, + ) + warnings.append("Successfully standardized unknown format") + return { + "dataset": standardized, + "detected_format": "unknown", + "final_format": f"chatml_{detected['chat_column']}", + "chat_column": detected["chat_column"], + "is_standardized": True, + "requires_manual_mapping": False, + "is_image": multimodal_info["is_image"], + "multimodal_info": multimodal_info, + "warnings": warnings, + } + except Exception as e: + warnings.append( + f"Could not standardize: {e}. Passing dataset as-is." + ) + + # Return as-is with warnings + return { + "dataset": dataset, + "detected_format": "unknown", + "final_format": "unknown", + "chat_column": detected["chat_column"], + "is_standardized": False, + "requires_manual_mapping": True, + "is_image": multimodal_info["is_image"], + "multimodal_info": multimodal_info, + "warnings": warnings, + } + + # ALPACA MODE: Convert to Alpaca + elif format_type == "alpaca": + if detected["format"] == "alpaca": + return { + "dataset": dataset, + "detected_format": "alpaca", + "final_format": "alpaca", + "chat_column": None, + "is_standardized": True, + "requires_manual_mapping": False, + "is_image": multimodal_info["is_image"], + "multimodal_info": multimodal_info, + "warnings": [], + } + + elif detected["format"] in ["sharegpt", "chatml"]: + # First standardize if ShareGPT + if detected["format"] == "sharegpt": + dataset = standardize_chat_format( + dataset, + tokenizer, + aliases_for_system, + aliases_for_user, + aliases_for_assistant, + batch_size, + num_proc, + ) + + # Then convert to Alpaca + converted = convert_chatml_to_alpaca(dataset, batch_size, num_proc) + return { + "dataset": converted, + "detected_format": detected["format"], + "final_format": "alpaca", + "chat_column": None, + "is_standardized": True, + "requires_manual_mapping": False, + "is_image": multimodal_info["is_image"], + "multimodal_info": multimodal_info, + "warnings": [], + } + + else: + warnings.append(f"Cannot convert unknown format to Alpaca") + return { + "dataset": dataset, + "detected_format": "unknown", + "final_format": "unknown", + "chat_column": detected["chat_column"], + "is_standardized": False, + "requires_manual_mapping": True, + "is_image": multimodal_info["is_image"], + "multimodal_info": multimodal_info, + "warnings": warnings, + } + + # CHATML MODE: Convert to ChatML + elif format_type in ["chatml", "conversational", "sharegpt"]: + if detected["format"] == "alpaca": + converted = convert_alpaca_to_chatml(dataset, batch_size, num_proc) + return { + "dataset": converted, + "detected_format": "alpaca", + "final_format": "chatml_conversations", + "chat_column": "conversations", + "is_standardized": True, + "requires_manual_mapping": False, + "is_image": multimodal_info["is_image"], + "multimodal_info": multimodal_info, + "warnings": [], + } + + elif detected["format"] == "sharegpt": + standardized = standardize_chat_format( + dataset, + tokenizer, + aliases_for_system, + aliases_for_user, + aliases_for_assistant, + batch_size, + num_proc, + ) + return { + "dataset": standardized, + "detected_format": "sharegpt", + "final_format": f"chatml_{detected['chat_column']}", + "chat_column": detected["chat_column"], + "is_standardized": True, + "requires_manual_mapping": False, + "is_image": multimodal_info["is_image"], + "multimodal_info": multimodal_info, + "warnings": [], + } + + elif detected["format"] == "chatml": + return { + "dataset": dataset, + "detected_format": f"chatml_{detected['chat_column']}", + "final_format": f"chatml_{detected['chat_column']}", + "chat_column": detected["chat_column"], + "is_standardized": True, + "requires_manual_mapping": False, + "is_image": multimodal_info["is_image"], + "multimodal_info": multimodal_info, + "warnings": [], + } + + else: + warnings.append(f"Unknown format, attempting standardization") + if detected["chat_column"]: + try: + standardized = standardize_chat_format( + dataset, + tokenizer, + aliases_for_system, + aliases_for_user, + aliases_for_assistant, + batch_size, + num_proc, + ) + return { + "dataset": standardized, + "detected_format": "unknown", + "final_format": f"chatml_{detected['chat_column']}", + "chat_column": detected["chat_column"], + "is_standardized": True, + "requires_manual_mapping": False, + "is_image": multimodal_info["is_image"], + "multimodal_info": multimodal_info, + "warnings": warnings, + } + except Exception as e: + warnings.append(f"Standardization failed: {e}") + + return { + "dataset": dataset, + "detected_format": "unknown", + "final_format": "unknown", + "chat_column": detected["chat_column"], + "is_standardized": False, + "requires_manual_mapping": True, + "is_image": multimodal_info["is_image"], + "multimodal_info": multimodal_info, + "warnings": warnings, + } + + else: + raise ValueError(f"Unknown format_type: {format_type}") + + +def format_and_template_dataset( + dataset, + model_name, + tokenizer, + is_vlm = False, + format_type = "auto", + # VLM-specific parameters + vlm_instruction = None, # Now optional - will auto-generate + vlm_text_column = None, + vlm_image_column = None, + dataset_name = None, + custom_prompt_template = None, + add_eos_token = False, + remove_bos_prefix = False, + custom_format_mapping = None, + auto_detect_custom = True, + auto_detect_mapping = True, + aliases_for_system = [ + "system", + ], + aliases_for_user = [ + "user", + "human", + "input", + ], + aliases_for_assistant = [ + "gpt", + "assistant", + "output", + ], + batch_size = 1000, + num_proc = None, + progress_callback = None, +): + """ + Convenience function that combines format_dataset and apply_chat_template_to_dataset. + Perfect for UI workflows - one function does everything! + + Returns: + dict: { + "dataset": Final dataset with 'text' column, + "detected_format": Original format, + "final_format": Format after processing, + "success": Whether template application succeeded, + "requires_manual_mapping": True if format detection failed and user must map columns, + "warnings": List of warnings, + "errors": List of errors, + "summary": Human-readable summary + } + """ + + # VLM FLOW + if is_vlm: + warnings = [] + errors = [] + + multimodal_info = detect_multimodal_dataset(dataset) + + # NEW: If user provided explicit mapping for VLM, use it directly + if custom_format_mapping: + # Expect mapping like: {"image_col": "image", "caption_col": "text"} + user_vlm_image_column = None + user_vlm_text_column = None + + for col, role in custom_format_mapping.items(): + if role == "image": + user_vlm_image_column = col + elif role in ["text", "user", "caption", "assistant"]: + user_vlm_text_column = col + + if user_vlm_image_column and user_vlm_text_column: + try: + dataset = convert_to_vlm_format( + dataset, + instruction = vlm_instruction, + text_column = user_vlm_text_column, + image_column = user_vlm_image_column, + dataset_name = dataset_name, + progress_callback = progress_callback, + ) + warnings.append( + f"Applied user VLM mapping: image='{user_vlm_image_column}', text='{user_vlm_text_column}'" + ) + + return { + "dataset": dataset, + "detected_format": "user_mapped", + "final_format": "vlm_messages", + "chat_column": "messages", + "is_vlm": True, + "is_image": True, + "multimodal_info": multimodal_info, + "success": True, + "requires_manual_mapping": False, + "warnings": warnings, + "errors": [], + } + except Exception as e: + # User mapping failed — fall back to auto-detection instead + # of giving up (handles stale cached mappings gracefully) + warnings.append( + f"User VLM mapping (image='{user_vlm_image_column}', " + f"text='{user_vlm_text_column}') failed: {e} — " + f"falling back to auto-detection" + ) + logger.info( + f"⚠️ User VLM mapping failed, falling back to auto-detection..." + ) + custom_format_mapping = None # clear so auto-detection runs below + else: + errors.append( + f"Invalid VLM mapping: need 'image' and 'text' roles. Got: {custom_format_mapping}" + ) + return { + "dataset": dataset, + "detected_format": "user_mapped", + "final_format": "vlm_unknown", + "is_vlm": True, + "success": False, + "requires_manual_mapping": True, + "warnings": warnings, + "errors": errors, + } + + # Auto-detect VLM structure + vlm_structure = detect_vlm_dataset_structure(dataset) + + # Handle Llava format + if vlm_structure["format"] == "vlm_messages_llava": + try: + dataset = convert_llava_to_vlm_format(dataset) + warnings.append( + "Converted from Llava format (image indices) to standard VLM format" + ) + except Exception as e: + errors.append(f"Failed to convert Llava format: {e}") + import traceback + + traceback.print_exc() + + return { + "dataset": dataset, + "detected_format": "vlm_messages_llava", + "final_format": "vlm_conversion_failed", + "is_vlm": True, + "success": False, + "requires_manual_mapping": True, + "warnings": warnings, + "errors": errors, + } + + # Handle ShareGPT/ChatML + image column (e.g. ShareGPT4V, LLaVA-style) + elif vlm_structure["format"] == "sharegpt_with_images": + try: + dataset = convert_sharegpt_with_images_to_vlm_format( + dataset, + image_column = vlm_structure["image_column"], + messages_column = vlm_structure["messages_column"], + dataset_name = dataset_name, + progress_callback = progress_callback, + ) + warnings.append( + "Converted from ShareGPT+image format to standard VLM format" + ) + except Exception as e: + errors.append(f"Failed to convert ShareGPT+image format: {e}") + import traceback + + traceback.print_exc() + + return { + "dataset": dataset, + "detected_format": "sharegpt_with_images", + "final_format": "vlm_conversion_failed", + "is_vlm": True, + "success": False, + "requires_manual_mapping": True, + "warnings": warnings, + "errors": errors, + } + + # Handle simple format + elif vlm_structure["needs_conversion"]: + if vlm_text_column is None: + vlm_text_column = vlm_structure["text_column"] + if vlm_image_column is None: + vlm_image_column = vlm_structure["image_column"] + + if vlm_text_column is None or vlm_image_column is None: + columns = list(next(iter(dataset)).keys()) if dataset else [] + issues = [ + f"Could not auto-detect image and text columns from: {columns}", + f"VLM structure detected: {vlm_structure.get('format', 'unknown')}", + ] + friendly = None + try: + from .llm_assist import llm_generate_dataset_warning + + friendly = llm_generate_dataset_warning( + issues, + dataset_name = dataset_name, + modality = "vision", + column_names = columns, + ) + except Exception: + pass + errors.append( + friendly + or f"Could not auto-detect image/text columns. Found: {vlm_structure}. " + ) + return { + "dataset": dataset, + "detected_format": "vlm_unknown", + "final_format": "vlm_unknown", + "is_vlm": True, + "success": False, + "requires_manual_mapping": True, + "warnings": warnings, + "errors": errors, + } + + try: + dataset = convert_to_vlm_format( + dataset, + instruction = vlm_instruction, + text_column = vlm_text_column, + image_column = vlm_image_column, + dataset_name = dataset_name, + progress_callback = progress_callback, + ) + + if vlm_instruction: + warnings.append( + f"Using user-provided instruction: '{vlm_instruction}'" + ) + else: + warnings.append( + "Auto-generated instruction based on dataset analysis" + ) + + except Exception as e: + errors.append(f"Failed to convert to VLM format: {e}") + import traceback + + traceback.print_exc() + + return { + "dataset": dataset, + "detected_format": vlm_structure["format"], + "final_format": "vlm_conversion_failed", + "is_vlm": True, + "success": False, + "requires_manual_mapping": True, + "warnings": warnings, + "errors": errors, + } + + # Already in standard VLM format + elif vlm_structure["format"] == "vlm_messages": + dataset = [sample for sample in dataset] + warnings.append("Dataset already in standard VLM messages format") + + # Return as list + return { + "dataset": dataset, + "detected_format": vlm_structure["format"], + "final_format": "vlm_messages", + "chat_column": "messages", + "is_vlm": True, + "is_image": multimodal_info["is_image"], + "multimodal_info": multimodal_info, + "vlm_structure": vlm_structure, + "success": True, + "requires_manual_mapping": False, + "warnings": warnings, + "errors": errors, + } + + # LLM FLOW (Existing code) + else: + # Step 1: Format the dataset + dataset_info = format_dataset( + dataset, + format_type = format_type, + tokenizer = tokenizer, + auto_detect_custom = auto_detect_custom, + custom_format_mapping = custom_format_mapping, + aliases_for_system = aliases_for_system, + aliases_for_user = aliases_for_user, + aliases_for_assistant = aliases_for_assistant, + batch_size = batch_size, + num_proc = num_proc, + ) + + # Step 2: Apply chat template + # Gemma emits a leading that must be stripped for text-only chatml/sharegpt. + is_alpaca = format_type == "alpaca" or ( + format_type == "auto" and dataset_info["detected_format"] == "alpaca" + ) + is_gemma = "gemma" in model_name.lower() + if is_gemma and not dataset_info["is_image"] and not is_alpaca: + remove_bos_prefix = True + template_result = apply_chat_template_to_dataset( + dataset_info = dataset_info, + tokenizer = tokenizer, + model_name = model_name, + custom_prompt_template = custom_prompt_template, + add_eos_token = add_eos_token, + remove_bos_prefix = remove_bos_prefix, + custom_format_mapping = custom_format_mapping, + auto_detect_mapping = auto_detect_mapping, + batch_size = batch_size, + num_proc = num_proc, + ) + + # Step 3: Generate summary + summary = get_dataset_info_summary(dataset_info) + + # Combine results + all_warnings = dataset_info.get("warnings", []) + template_result.get( + "warnings", [] + ) + all_errors = template_result.get("errors", []) + + # If format_dataset returned "unknown" but apply_chat_template rescued + # it via heuristic detection, update final_format to reflect reality. + final_format = dataset_info["final_format"] + requires_manual = dataset_info.get("requires_manual_mapping", False) + if final_format == "unknown" and template_result["success"]: + out_ds = template_result["dataset"] + if hasattr(out_ds, "column_names") and "text" in out_ds.column_names: + final_format = "chatml_conversations" + requires_manual = False + + return { + "dataset": template_result["dataset"], + "detected_format": dataset_info["detected_format"], + "final_format": final_format, + "chat_column": dataset_info.get("chat_column"), + "is_vlm": False, # This is LLM flow + "success": template_result["success"], + "requires_manual_mapping": requires_manual, + "warnings": all_warnings, + "errors": all_errors, + "summary": summary, + } diff --git a/studio/backend/utils/datasets/format_conversion.py b/studio/backend/utils/datasets/format_conversion.py new file mode 100644 index 0000000000..264789c41c --- /dev/null +++ b/studio/backend/utils/datasets/format_conversion.py @@ -0,0 +1,892 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Format conversion utilities for dataset processing. + +This module contains functions for converting between dataset formats +(Alpaca, ShareGPT, ChatML) and standardizing chat formats. +""" + +import os + +from datasets import IterableDataset +from loggers import get_logger + +logger = get_logger(__name__) + + +def standardize_chat_format( + dataset, + tokenizer = None, + aliases_for_system = [ + "system", + ], + aliases_for_user = [ + "user", + "human", + "input", + ], + aliases_for_assistant = [ + "gpt", + "assistant", + "output", + ], + batch_size = 1000, + num_proc = None, +): + """ + Our own standardization function that handles BOTH messages and conversations. + Converts non-standard role names and keys to standard format. + """ + import collections + import itertools + from datasets import IterableDataset + + # Check if vision tokenizer is used + is_vlm = False + if tokenizer is not None: + if hasattr(tokenizer, "image_processor") or hasattr(tokenizer, "tokenizer"): + is_vlm = True + + column_names = set(next(iter(dataset)).keys()) + + # Check for both 'conversations' and 'messages' + chat_column = None + if "conversations" in column_names: + chat_column = "conversations" + elif "messages" in column_names: + chat_column = "messages" + elif "texts" in column_names: + chat_column = "texts" + else: + return dataset # No chat column found + + # Inspect structure + examples = itertools.islice(dataset, 10) + uniques = collections.defaultdict(list) + for example in examples: + for message in example[chat_column]: + for key, value in message.items(): + if type(value) is not str: + continue # Skip non-string values + uniques[key].append(value) + + if len(uniques.keys()) != 2: + return dataset # Unexpected structure + + keys = list(uniques.keys()) + length_first = len(set(uniques[keys[0]])) + length_second = len(set(uniques[keys[1]])) + + # Determine which is role and which is content + if length_first < length_second: + role_key = keys[0] + content_key = keys[1] + else: + role_key = keys[1] + content_key = keys[0] + + # Mapping for aliases + aliases_mapping = {} + for x in aliases_for_system: + aliases_mapping[x] = "system" + for x in aliases_for_user: + aliases_mapping[x] = "user" + for x in aliases_for_assistant: + aliases_mapping[x] = "assistant" + + def _standardize_dataset(examples): + convos = examples[chat_column] + all_convos = [] + for convo in convos: + new_convo = [] + for message in convo: + # Get original role and content + original_role = message.get(role_key, "") + original_content = message.get(content_key, "") + + # Map to standard role name + standard_role = aliases_mapping.get(original_role, original_role) + + # Handle VLM format + if is_vlm: + original_content = [{"type": "text", "text": original_content}] + + # Create dict with EXPLICIT ORDER + new_message = {"role": standard_role, "content": original_content} + new_convo.append(new_message) + + all_convos.append(new_convo) + + return {chat_column: all_convos} + + dataset_map_kwargs = { + "batched": True, + "batch_size": batch_size, + } + + if not isinstance(dataset, IterableDataset): + from utils.hardware import safe_num_proc + + if num_proc is None or type(num_proc) is not int: + num_proc = safe_num_proc() + else: + num_proc = safe_num_proc(num_proc) + + dataset_map_kwargs["num_proc"] = num_proc + dataset_map_kwargs["desc"] = "Standardizing chat format" + + return dataset.map(_standardize_dataset, **dataset_map_kwargs) + + +def convert_chatml_to_alpaca(dataset, batch_size = 1000, num_proc = None): + """ + Converts ChatML format (messages OR conversations) to Alpaca format. + Handles both standardized and ShareGPT formats. + + Supports: + - "messages" or "conversations" column + - "role"/"content" (standard) or "from"/"value" (ShareGPT) + """ + from torch.utils.data import IterableDataset + + def _convert(examples): + # Auto-detect which column name is used + chatml_data = ( + examples.get("messages") + or examples.get("conversations") + or examples.get("texts") + ) + + if chatml_data is None: + raise ValueError( + "No 'messages' or 'conversations' or 'texts' column found." + ) + + instructions = [] + outputs = [] + inputs = [] + + for convo in chatml_data: + instruction = "" + output = "" + + for msg in convo: + # Handle both standard and ShareGPT formats + role = msg.get("role") or msg.get("from") + content = msg.get("content") or msg.get("value") + + # Get first user message as instruction + if role in ["user", "human", "input"] and not instruction: + instruction = content + # Get first assistant message as output + elif role in ["assistant", "gpt", "output"] and not output: + output = content + break # Stop after first assistant response + + instructions.append(instruction) + inputs.append("") # Alpaca typically has empty input + outputs.append(output) + + return {"instruction": instructions, "input": inputs, "output": outputs} + + dataset_map_kwargs = { + "batched": True, + "batch_size": batch_size, + } + + if not isinstance(dataset, IterableDataset): + from utils.hardware import safe_num_proc + + if num_proc is None or type(num_proc) is not int: + num_proc = safe_num_proc() + else: + num_proc = safe_num_proc(num_proc) + + dataset_map_kwargs["num_proc"] = num_proc + dataset_map_kwargs["desc"] = "Converting ChatML to Alpaca format" + + return dataset.map(_convert, **dataset_map_kwargs) + + +def convert_alpaca_to_chatml(dataset, batch_size = 1000, num_proc = None): + """ + Converts Alpaca format to ChatML format. + + Output format: Uses 'conversations' column with standard 'role'/'content' structure. + """ + from torch.utils.data import IterableDataset + + def _convert(examples): + conversations = [] + + for i in range(len(examples["instruction"])): + instruction = examples["instruction"][i] + input_text = examples.get("input", [""] * len(examples["instruction"]))[i] + output = examples["output"][i] + + # Combine instruction and input (if exists) for user message + if input_text and input_text.strip(): + user_content = f"{instruction}\n\n{input_text}".strip() + else: + user_content = instruction + + # Build conversation in standard ChatML format + convo = [ + {"role": "user", "content": user_content}, + {"role": "assistant", "content": output}, + ] + conversations.append(convo) + + return {"conversations": conversations} + + dataset_map_kwargs = { + "batched": True, + "batch_size": batch_size, + } + + if not isinstance(dataset, IterableDataset): + from utils.hardware import safe_num_proc + + if num_proc is None or type(num_proc) is not int: + num_proc = safe_num_proc() + else: + num_proc = safe_num_proc(num_proc) + + dataset_map_kwargs["num_proc"] = num_proc + dataset_map_kwargs["desc"] = "Converting Alpaca to ChatML format" + + return dataset.map(_convert, **dataset_map_kwargs) + + +def _format_eta(seconds): + """Format seconds into a human-readable ETA string.""" + if seconds < 60: + return f"{seconds:.0f}s" + elif seconds < 3600: + m, s = divmod(int(seconds), 60) + return f"{m}m {s}s" + else: + h, remainder = divmod(int(seconds), 3600) + m, _ = divmod(remainder, 60) + return f"{h}h {m}m" + + +def convert_to_vlm_format( + dataset, + instruction = None, + text_column = "text", + image_column = "image", + dataset_name = None, + progress_callback = None, +): + """ + Converts simple {image, text} format to VLM messages format. + + Returns a LIST, not a HuggingFace Dataset (to preserve PIL Images). + + For URL-based image datasets, runs a 200-sample parallel probe first to + estimate download speed and failure rate, then reports time estimate or + warning through progress_callback before proceeding with the full conversion. + + Args: + progress_callback: Optional callable(status_message=str) to report + progress to the training overlay. + + Returns: + list: List of dicts with 'messages' field + """ + from PIL import Image + from .vlm_processing import generate_smart_vlm_instruction + + def _notify(msg): + """Send status update to the training overlay if callback is available.""" + if progress_callback: + progress_callback(status_message = msg) + + # Generate smart instruction if not provided + if instruction is None: + instruction_info = generate_smart_vlm_instruction( + dataset, + text_column = text_column, + image_column = image_column, + dataset_name = dataset_name, + ) + + instruction = instruction_info["instruction"] + instruction_column = instruction_info.get("instruction_column") + uses_dynamic = instruction_info["uses_dynamic_instruction"] + + logger.info( + f"📝 Auto-detected instruction type: {instruction_info['instruction_type']}" + ) + logger.info(f"📝 Confidence: {instruction_info['confidence']:.2f}") + if not uses_dynamic: + logger.info(f"📝 Using instruction: '{instruction}'") + else: + logger.info( + f"📝 Using dynamic instructions from column: '{instruction_column}'" + ) + else: + instruction_column = None + uses_dynamic = False + + def _convert_single_sample(sample): + """Convert a single sample to VLM format.""" + # Get image (might be PIL Image, local path, URL, or bare filename) + image_data = sample[image_column] + + if isinstance(image_data, str): + if image_data.startswith(("http://", "https://")): + import fsspec + from io import BytesIO + + with fsspec.open(image_data, "rb", expand = True) as f: + image_data = Image.open(BytesIO(f.read())).convert("RGB") + elif _image_lookup is not None and image_data in _image_lookup: + # Bare filename → resolve via HF repo lookup + from huggingface_hub import hf_hub_download + + local_path = hf_hub_download( + dataset_name, + _image_lookup[image_data], + repo_type = "dataset", + ) + image_data = Image.open(local_path).convert("RGB") + else: + image_data = Image.open(image_data).convert("RGB") + + # Get text (if list of strings, pick a random one — e.g. multiple captions) + text_data = sample[text_column] + if isinstance(text_data, list) and len(text_data) > 0: + import random + + text_data = random.choice(text_data) + + # Get instruction (static or dynamic) + if uses_dynamic and instruction_column: + current_instruction = sample[instruction_column] + else: + current_instruction = instruction + + # Build VLM messages - simple structure + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": current_instruction}, + {"type": "image", "image": image_data}, # PIL object + ], + }, + {"role": "assistant", "content": [{"type": "text", "text": text_data}]}, + ] + + # Return dict with messages + return {"messages": messages} + + total = len(dataset) + first_image = next(iter(dataset))[image_column] + has_urls = isinstance(first_image, str) and first_image.startswith( + ("http://", "https://") + ) + + # ── Bare-filename detection: images stored as filenames (e.g. "img_001.png") + # that don't exist locally. Build a basename→repo_path lookup so we can + # resolve them via hf_hub_download during conversion. + _image_lookup = None + _IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tiff") + if ( + not has_urls + and isinstance(first_image, str) + and not os.path.exists(first_image) + and dataset_name + ): + try: + from huggingface_hub import HfApi + + _notify("Resolving image filenames from HF repo...") + logger.info( + f"🔍 Image column contains bare filenames (e.g. '{first_image}') — building repo lookup..." + ) + repo_files = HfApi().list_repo_files(dataset_name, repo_type = "dataset") + _image_lookup = { + os.path.basename(f): f + for f in repo_files + if any(f.lower().endswith(ext) for ext in _IMAGE_EXTS) + } + if first_image in _image_lookup: + logger.info( + f"✅ Matched {len(_image_lookup)} image files in repo (e.g. '{first_image}' → '{_image_lookup[first_image]}')" + ) + else: + logger.info( + f"⚠️ Built lookup with {len(_image_lookup)} images but '{first_image}' not found — falling back to local open" + ) + _image_lookup = None + except Exception as e: + logger.info(f"⚠️ Failed to build HF repo image lookup: {e}") + _image_lookup = None + + # ── URL probe: 200 samples with parallel workers to estimate speed + failure rate ── + PROBE_SIZE = 200 + MAX_FAIL_RATE = 0.3 + + if has_urls and total > PROBE_SIZE: + import time + from concurrent.futures import ThreadPoolExecutor, as_completed + from utils.hardware import safe_num_proc + + num_workers = safe_num_proc() + _notify(f"Probing {PROBE_SIZE} image URLs with {num_workers} workers...") + logger.info( + f"🔍 Probing {PROBE_SIZE}/{total} image URLs with {num_workers} workers..." + ) + + probe_samples = [dataset[i] for i in range(PROBE_SIZE)] + probe_ok = 0 + probe_fail = 0 + probe_start = time.time() + + with ThreadPoolExecutor(max_workers = num_workers) as executor: + futures = { + executor.submit(_convert_single_sample, s): s for s in probe_samples + } + for future in as_completed(futures): + try: + future.result() + probe_ok += 1 + except Exception: + probe_fail += 1 + + probe_elapsed = time.time() - probe_start + probe_total = probe_ok + probe_fail + fail_rate = probe_fail / probe_total if probe_total > 0 else 0 + throughput = probe_total / probe_elapsed if probe_elapsed > 0 else 0 + + if fail_rate >= MAX_FAIL_RATE: + issues = [ + f"{fail_rate:.0%} of the first {PROBE_SIZE} image URLs failed to download ({probe_fail}/{probe_total})", + "Images are external URLs, not embedded in the dataset", + ] + # Try LLM-friendly warning + friendly = None + try: + from .llm_assist import llm_generate_dataset_warning + + friendly = llm_generate_dataset_warning( + issues, + dataset_name = dataset_name, + modality = "vision", + column_names = [image_column, text_column], + ) + except Exception: + pass + msg = friendly or ( + f"⚠️ {fail_rate:.0%} of the first {PROBE_SIZE} images failed to download " + f"({probe_fail}/{probe_total}). " + "This dataset has too many broken or unreachable image URLs. " + "Consider using a dataset with embedded images instead." + ) + logger.info(msg) + _notify(msg) + raise ValueError(msg) + + # Estimate total time for remaining samples + remaining = total - PROBE_SIZE + estimated_seconds = remaining / throughput if throughput > 0 else 0 + eta_str = _format_eta(estimated_seconds) + + info_msg = ( + f"Downloading {total:,} images ({num_workers} workers, ~{throughput:.1f} img/s). " + f"Estimated time: ~{eta_str}" + ) + if probe_fail > 0: + info_msg += f" | {fail_rate:.0%} broken URLs will be skipped" + + logger.info( + f"✅ Probe passed: {probe_ok}/{probe_total} ok, {probe_fail} failed ({fail_rate:.0%}), {throughput:.1f} img/s" + ) + logger.info(f"⏱️ Estimated time for {total:,} samples: ~{eta_str}") + _notify(info_msg) + + # ── Full conversion with progress ── + from tqdm import tqdm + + logger.info(f"🔄 Converting {total} samples to VLM format...") + converted_list = [] + failed_count = 0 + + if has_urls: + # Parallel conversion for URL-based datasets + import time + from concurrent.futures import ThreadPoolExecutor, as_completed + from utils.hardware import safe_num_proc + + num_workers = safe_num_proc() + batch_size = 500 + start_time = time.time() + + for batch_start in range(0, total, batch_size): + batch_end = min(batch_start + batch_size, total) + batch_samples = [dataset[i] for i in range(batch_start, batch_end)] + + with ThreadPoolExecutor(max_workers = num_workers) as executor: + futures = { + executor.submit(_convert_single_sample, s): i + for i, s in enumerate(batch_samples) + } + batch_results = [None] * len(batch_samples) + for future in as_completed(futures): + idx = futures[future] + try: + batch_results[idx] = future.result() + except Exception as e: + failed_count += 1 + if failed_count == 1: + print( + f"⚠️ First VLM conversion failure: {type(e).__name__}: {e}" + ) + if failed_count == 1: + logger.info( + f"⚠️ First VLM conversion failure: {type(e).__name__}: {e}" + ) + + converted_list.extend(r for r in batch_results if r is not None) + + # Progress update every batch + elapsed = time.time() - start_time + done = batch_end + rate = done / elapsed if elapsed > 0 else 0 + remaining_time = (total - done) / rate if rate > 0 else 0 + eta_str = _format_eta(remaining_time) + progress_msg = f"Downloading images: {done:,}/{total:,} ({done*100//total}%) | ~{eta_str} remaining | {failed_count} skipped" + logger.info( + f" [{done}/{total}] {rate:.1f} img/s, {failed_count} failed, ETA {eta_str}" + ) + _notify(progress_msg) + else: + # Sequential conversion for local/embedded images (fast, no I/O bottleneck) + pbar = tqdm(dataset, total = total, desc = "Converting VLM samples", unit = "sample") + for sample in pbar: + try: + converted_list.append(_convert_single_sample(sample)) + except Exception as e: + failed_count += 1 + if failed_count == 1: + # Log the first failure to aid debugging + print(f"⚠️ First VLM conversion failure: {type(e).__name__}: {e}") + if failed_count == 1: + # Log the first failure to aid debugging + logger.info( + f"⚠️ First VLM conversion failure: {type(e).__name__}: {e}" + ) + pbar.set_postfix(ok = len(converted_list), failed = failed_count, refresh = False) + pbar.close() + + if failed_count > 0: + fail_rate = failed_count / total + logger.info( + f"⚠️ Skipped {failed_count}/{total} ({fail_rate:.0%}) samples with broken/unreachable images" + ) + # For datasets that skipped the probe (small URL datasets), check fail rate now + if has_urls and fail_rate >= MAX_FAIL_RATE: + issues = [ + f"{fail_rate:.0%} of images failed to download ({failed_count}/{total})", + "Images are external URLs, not embedded in the dataset", + ] + friendly = None + try: + from .llm_assist import llm_generate_dataset_warning + + friendly = llm_generate_dataset_warning( + issues, + dataset_name = dataset_name, + modality = "vision", + column_names = [image_column, text_column], + ) + except Exception: + pass + msg = friendly or ( + f"⚠️ {fail_rate:.0%} of images failed to download ({failed_count}/{total}). " + "This dataset has too many broken or unreachable image URLs. " + "Consider using a dataset with embedded images instead." + ) + _notify(msg) + raise ValueError(msg) + + if len(converted_list) == 0: + issues = [ + f"All {total} samples failed during VLM conversion — no usable images found", + f"Image column '{image_column}' may contain URLs that are no longer accessible, " + "or local file paths that don't exist", + ] + friendly = None + try: + from .llm_assist import llm_generate_dataset_warning + + friendly = llm_generate_dataset_warning( + issues, + dataset_name = dataset_name, + modality = "vision", + column_names = [image_column, text_column], + ) + except Exception: + pass + raise ValueError( + friendly + or ( + f"All {total} samples failed during VLM conversion — no usable images found. " + "This dataset may contain only image URLs that are no longer accessible." + ) + ) + + logger.info(f"✅ Converted {len(converted_list)}/{total} samples") + _notify(f"Converted {len(converted_list):,}/{total:,} images successfully") + + # Return list, NOT Dataset + return converted_list + + +def convert_sharegpt_with_images_to_vlm_format( + dataset, + image_column = "image", + messages_column = "conversations", + dataset_name = None, + progress_callback = None, +): + """ + Converts ShareGPT/ChatML datasets that have a separate image column and + ```` placeholders inside the conversation text. + + Example input:: + + { + "image": "sam/images/sa_545504.jpg", + "conversations": [ + {"from": "human", "value": "\\nWhat is this photo about?"}, + {"from": "gpt", "value": "The image captures..."} + ] + } + + Returns a list of dicts in standard VLM messages format (PIL Images inline). + """ + from PIL import Image + from tqdm import tqdm + + _IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tiff") + _ROLE_MAP = { + "human": "user", + "user": "user", + "gpt": "assistant", + "assistant": "assistant", + "system": "system", + } + + def _notify(msg): + if progress_callback: + progress_callback(status_message = msg) + + # ── Resolve image loading strategy (same 3-tier as convert_to_vlm_format) ── + total = len(dataset) + first_image = next(iter(dataset))[image_column] + + _image_lookup = None + if ( + isinstance(first_image, str) + and not first_image.startswith(("http://", "https://")) + and not os.path.exists(first_image) + and dataset_name + ): + try: + from huggingface_hub import HfApi + + _notify("Resolving image filenames from HF repo...") + logger.info( + f"🔍 Image column contains bare filenames (e.g. '{first_image}') — building repo lookup..." + ) + repo_files = HfApi().list_repo_files(dataset_name, repo_type = "dataset") + _image_lookup = { + os.path.basename(f): f + for f in repo_files + if any(f.lower().endswith(ext) for ext in _IMAGE_EXTS) + } + # Also add the full relative paths as keys (for paths like "sam/images/sa_545504.jpg") + for f in repo_files: + if any(f.lower().endswith(ext) for ext in _IMAGE_EXTS): + _image_lookup[f] = f + if first_image in _image_lookup: + logger.info( + f"✅ Matched {len(_image_lookup)} image files in repo (e.g. '{first_image}' → '{_image_lookup[first_image]}')" + ) + else: + logger.info( + f"⚠️ Built lookup with {len(_image_lookup)} images but '{first_image}' not found — falling back to local open" + ) + _image_lookup = None + except Exception as e: + logger.info(f"⚠️ Failed to build HF repo image lookup: {e}") + _image_lookup = None + + def _resolve_image(image_data): + """Resolve image data to a PIL Image object.""" + if hasattr(image_data, "size") and hasattr(image_data, "mode"): + return image_data # Already PIL + if isinstance(image_data, str): + if image_data.startswith(("http://", "https://")): + import fsspec + from io import BytesIO + + with fsspec.open(image_data, "rb", expand = True) as f: + return Image.open(BytesIO(f.read())).convert("RGB") + elif _image_lookup is not None and image_data in _image_lookup: + from huggingface_hub import hf_hub_download + + local_path = hf_hub_download( + dataset_name, + _image_lookup[image_data], + repo_type = "dataset", + ) + return Image.open(local_path).convert("RGB") + else: + return Image.open(image_data).convert("RGB") + if isinstance(image_data, dict) and ( + "bytes" in image_data or "path" in image_data + ): + if image_data.get("bytes"): + from io import BytesIO + + return Image.open(BytesIO(image_data["bytes"])).convert("RGB") + if image_data.get("path"): + return Image.open(image_data["path"]).convert("RGB") + raise ValueError(f"Cannot resolve image: {type(image_data)}") + + def _convert_single_sample(sample): + """Convert a single ShareGPT+image sample to standard VLM format.""" + pil_image = _resolve_image(sample[image_column]) + conversation = sample[messages_column] + + new_messages = [] + for msg in conversation: + role_raw = msg.get("from") or msg.get("role", "user") + role = _ROLE_MAP.get(role_raw.lower(), role_raw.lower()) + text = msg.get("value") or msg.get("content") or "" + + # Split on to interleave text and image content blocks + if "" in text: + parts = text.split("") + content = [] + for i, part in enumerate(parts): + part = part.strip() + if part: + content.append({"type": "text", "text": part}) + if i < len(parts) - 1: + content.append({"type": "image", "image": pil_image}) + # If was the entire text, content might just be the image + if not content: + content.append({"type": "image", "image": pil_image}) + else: + content = [{"type": "text", "text": text}] + + new_messages.append({"role": role, "content": content}) + + return {"messages": new_messages} + + # ── Full conversion with progress ── + logger.info(f"🔄 Converting {total} samples from ShareGPT+image format...") + converted_list = [] + failed_count = 0 + + pbar = tqdm(dataset, total = total, desc = "Converting ShareGPT+image", unit = "sample") + for sample in pbar: + try: + converted_list.append(_convert_single_sample(sample)) + except Exception as e: + failed_count += 1 + if failed_count == 1: + logger.info(f"⚠️ First conversion failure: {type(e).__name__}: {e}") + pbar.set_postfix(ok = len(converted_list), failed = failed_count, refresh = False) + pbar.close() + + if failed_count > 0: + logger.info( + f"⚠️ Skipped {failed_count}/{total} ({failed_count*100//total}%) samples" + ) + + if len(converted_list) == 0: + raise ValueError( + f"All {total} samples failed during ShareGPT+image conversion — " + "no usable samples found." + ) + + logger.info(f"✅ Converted {len(converted_list)}/{total} samples") + _notify(f"Converted {len(converted_list):,}/{total:,} samples successfully") + return converted_list + + +def convert_llava_to_vlm_format(dataset): + """ + Converts Llava format to standard VLM format. + + Llava format: + - messages: [{'content': [{'type': 'image', 'index': 0}, {'type': 'text', 'text': '...'}]}] + - images: [PIL_Image1, PIL_Image2, ...] + + Standard VLM format: + - messages: [{'content': [{'type': 'image', 'image': PIL_Image}, {'type': 'text', 'text': '...'}]}] + """ + from PIL import Image + + logger.info( + f"🔄 Converting {len(dataset)} samples from Llava format to standard VLM format..." + ) + + def _convert_single_sample(sample): + """Convert a single llava sample to standard VLM format.""" + messages = sample["messages"] + images = sample.get("images", []) + + # Process each message + new_messages = [] + for msg in messages: + new_content = [] + + for item in msg["content"]: + if item["type"] == "image": + # Replace index with actual PIL image + if "index" in item and item["index"] is not None: + img_idx = item["index"] + if img_idx < len(images): + pil_image = images[img_idx] + # Ensure it's PIL + if isinstance(pil_image, str): + pil_image = Image.open(pil_image).convert("RGB") + + new_content.append( + { + "type": "image", + "image": pil_image, # Actual PIL object + } + ) + else: + # No index, try to use first image + if len(images) > 0: + pil_image = images[0] + if isinstance(pil_image, str): + pil_image = Image.open(pil_image).convert("RGB") + + new_content.append({"type": "image", "image": pil_image}) + + elif item["type"] == "text": + # Keep text as-is (only type + text) + new_content.append({"type": "text", "text": item.get("text", "")}) + + new_messages.append({"role": msg["role"], "content": new_content}) + + return {"messages": new_messages} + + # Convert using list comprehension + converted_list = [_convert_single_sample(sample) for sample in dataset] + + logger.info(f"✅ Converted {len(converted_list)} samples") + return converted_list diff --git a/studio/backend/utils/datasets/format_detection.py b/studio/backend/utils/datasets/format_detection.py new file mode 100644 index 0000000000..7b70ff3a76 --- /dev/null +++ b/studio/backend/utils/datasets/format_detection.py @@ -0,0 +1,931 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Format detection utilities for dataset processing. + +This module contains functions for detecting dataset formats (Alpaca, ShareGPT, ChatML), +detecting multimodal/VLM dataset structures, and heuristic-based column mapping. +""" + +import re + + +def _keyword_in_column(keyword: str, col_name: str) -> bool: + """Word-boundary keyword match to avoid false positives like 'pic' in 'topic'.""" + return ( + re.search(r"\b" + re.escape(keyword) + r"\b", col_name, re.IGNORECASE) + is not None + ) + + +def detect_dataset_format(dataset): + """ + Detects dataset format by inspecting structure. + + Returns: + dict: { + "format": "alpaca" | "sharegpt" | "chatml" | "unknown", + "chat_column": "messages" | "conversations" | None, + "needs_standardization": bool, + "sample_keys": list of keys found in messages (for debugging) + } + """ + column_names = set(next(iter(dataset)).keys()) + + # Check for Alpaca + alpaca_columns = {"instruction", "output"} + if alpaca_columns.issubset(column_names): + return { + "format": "alpaca", + "chat_column": None, + "needs_standardization": False, + "sample_keys": [], + } + + # Check for chat-based formats (messages or conversations) + chat_column = None + if "messages" in column_names: + chat_column = "messages" + elif "conversations" in column_names: + chat_column = "conversations" + elif "texts" in column_names: + chat_column = "texts" + + if chat_column: + # Inspect the structure to determine if ShareGPT or ChatML + try: + sample = next(iter(dataset)) + chat_data = sample[chat_column] + + if chat_data and len(chat_data) > 0: + first_msg = chat_data[0] + msg_keys = set(first_msg.keys()) + + # ShareGPT uses "from" and "value" + if "from" in msg_keys or "value" in msg_keys: + return { + "format": "sharegpt", + "chat_column": chat_column, + "needs_standardization": True, + "sample_keys": list(msg_keys), + } + + # ChatML uses "role" and "content" + elif "role" in msg_keys and "content" in msg_keys: + return { + "format": "chatml", + "chat_column": chat_column, + "needs_standardization": False, + "sample_keys": list(msg_keys), + } + + # Unknown structure but has chat column + else: + return { + "format": "unknown", + "chat_column": chat_column, + "needs_standardization": None, + "sample_keys": list(msg_keys), + } + except Exception as e: + return { + "format": "unknown", + "chat_column": chat_column, + "needs_standardization": None, + "sample_keys": [], + "error": str(e), + } + + # No recognized format + return { + "format": "unknown", + "chat_column": None, + "needs_standardization": None, + "sample_keys": [], + } + + +def detect_custom_format_heuristic(dataset): + """ + Smart detection with priority scoring. + + Strategy for ambiguous keywords like 'task': + 1. Detect assistant first (unambiguous) + 2. Detect user using high-priority keywords first + 3. Check REMAINING columns for system keywords (including 'task') + 4. Only if no system match, use 'task' as fallback user + """ + sample = next(iter(dataset)) + all_columns = list(sample.keys()) + + mapping = {} + + # Keywords + assistant_words = [ + "output", + "answer", + "response", + "assistant", + "completion", + "expected", + "recommendation", + "reply", + "result", + "target", + "solution", + "explanation", + "solve", + ] + + # Split into high/low priority + user_words_high_priority = [ + "input", + "question", + "query", + "prompt", + "instruction", + "request", + "snippet", + "user", + "text", + "problem", + "exercise", + ] + user_words_low_priority = ["task"] # Ambiguous - can be user OR system + user_words = user_words_high_priority + user_words_low_priority + + system_words = [ + "system", + "context", + "description", + "persona", + "role", + "template", + "task", # Also in system + ] + + # Metadata columns to ignore + metadata_exact_match = { + "id", + "idx", + "index", + "key", + "timestamp", + "date", + "metadata", + "source", + "kind", + "type", + "category", + "score", + "label", + "tag", + "inference_mode", + } + + metadata_prefix_patterns = [ + "problem_type", + "problem_source", + "generation_model", + "pass_rate", + ] + + priority_patterns = { + "generated": 100, + "gen_": 90, + "model_": 80, + "predicted": 70, + "completion": 60, + } + + def has_keyword(col_name, keywords): + """Check if any keyword appears in column name.""" + col_lower = col_name.lower() + col_normalized = col_lower.replace("_", "").replace("-", "").replace(" ", "") + + for keyword in keywords: + if keyword in col_lower or keyword in col_normalized: + return True + return False + + def is_metadata(col_name): + """Check if column is likely metadata.""" + col_lower = col_name.lower() + + if col_lower in metadata_exact_match: + return True + + if col_lower in metadata_prefix_patterns: + return True + + for pattern in metadata_prefix_patterns: + if ( + col_lower.startswith(pattern.split("_")[0] + "_") + and col_lower != pattern + ): + if "_" in col_lower: + prefix = col_lower.split("_")[0] + if prefix in ["generation", "pass", "inference"]: + return True + + if len(col_lower) <= 2 and not col_lower in ["qa", "q", "a"]: + return True + + return False + + def get_priority_score(col_name): + """Calculate priority score based on column name patterns.""" + col_lower = col_name.lower() + score = 0 + + for pattern, pattern_score in priority_patterns.items(): + if pattern in col_lower: + score += pattern_score + + return score + + def get_content_length(col_name): + """Get average content length for this column.""" + try: + if col_name in sample and sample[col_name]: + content = str(sample[col_name]) + return len(content) + return 0 + except: + return 0 + + def score_column(col_name, keywords, role_type, num_candidates): + """Score a column for how likely it is to be a particular role.""" + if not has_keyword(col_name, keywords): + return 0 + + score = 0 + score += 10 + + # Penalize ambiguous keywords when scoring for user + if role_type == "user": + col_lower = col_name.lower() + # If column is ONLY "task" (or task_xxx), give it lower priority for user role + if "task" in col_lower and not any( + kw in col_lower for kw in user_words_high_priority + ): + score -= 15 # Significant penalty so other user columns win + + priority_bonus = get_priority_score(col_name) + score += priority_bonus + + if role_type in ["assistant", "user"]: + avg_length = get_content_length(col_name) + + if num_candidates > 1: + if avg_length > 1000: + score += 50 + elif avg_length > 200: + score += 30 + elif avg_length > 50: + score += 10 + elif avg_length < 50: + score -= 20 + else: + if avg_length > 1000: + score += 50 + elif avg_length > 200: + score += 30 + elif avg_length > 50: + score += 10 + + return score + + # Filter out metadata columns + content_columns = [col for col in all_columns if not is_metadata(col)] + + # Count candidates first + assistant_potential = [ + col for col in content_columns if has_keyword(col, assistant_words) + ] + user_potential = [col for col in content_columns if has_keyword(col, user_words)] + + # STEP 1: Find best ASSISTANT column + assistant_candidates = [] + for col in assistant_potential: + score = score_column( + col, assistant_words, "assistant", len(assistant_potential) + ) + if score > 0: + assistant_candidates.append((col, score)) + + if assistant_candidates: + assistant_candidates.sort(key = lambda x: x[1], reverse = True) + assistant_col = assistant_candidates[0][0] + mapping[assistant_col] = "assistant" + else: + assistant_col = None + + # STEP 2: Find best USER column (with penalty for ambiguous keywords) + user_candidates = [] + for col in user_potential: + if col == assistant_col: + continue + score = score_column(col, user_words, "user", len(user_potential)) + if score > 0: + user_candidates.append((col, score)) + + if user_candidates: + user_candidates.sort(key = lambda x: x[1], reverse = True) + user_col = user_candidates[0][0] + mapping[user_col] = "user" + else: + user_col = None + + # STEP 3: Check ALL remaining columns for SYSTEM matches (priority check) + remaining_columns = [col for col in content_columns if col not in mapping] + + system_col = None + for col in remaining_columns: + if has_keyword(col, system_words): + # Found a system match in remaining columns + mapping[col] = "system" + system_col = col + break + + # STEP 4: Handle any additional remaining columns + if system_col: + remaining_columns = [col for col in remaining_columns if col != system_col] + + if len(remaining_columns) >= 1: + remaining_col = remaining_columns[0] + + # If no strong keyword match, decide based on what's missing + if not has_keyword(remaining_col, user_words + assistant_words): + mapping[remaining_col] = "system" + elif user_col is None: + # No user column yet, assign this as user + mapping[remaining_col] = "user" + else: + # Already have user + assistant, treat as system context + mapping[remaining_col] = "system" + + # VALIDATION: Ensure we have at least user + assistant + has_user = any(role == "user" for role in mapping.values()) + has_assistant = any(role == "assistant" for role in mapping.values()) + + if not has_user and len(remaining_columns) > 0: + for col in remaining_columns: + if col not in mapping: + mapping[col] = "user" + has_user = True + break + + if has_user and has_assistant: + return mapping + + return None + + +def detect_multimodal_dataset(dataset): + """ + Detects if dataset contains multimodal data (images and/or audio). + + Two-pass approach for each modality: + 1. Column-name heuristic (fast): checks for keywords. + 2. Value-type inspection (reliable): checks actual sample values. + + Returns: + dict: { + "is_image": bool, + "multimodal_columns": list of column names containing image data, + "modality_types": list of detected types (e.g., ["image", "audio"]), + "is_audio": bool, + "audio_columns": list of column names containing audio data, + "detected_audio_column": str or None, + "detected_text_column": str or None, + } + """ + sample = next(iter(dataset)) + column_names = list(sample.keys()) + + # Keywords that indicate image data + image_keywords = [ + "image", + "img", + "pixel", + "jpg", + "jpeg", + "png", + "webp", + "bmp", + "gif", + "tiff", + "svg", + "photo", + "pic", + "picture", + "visual", + "file_name", + "filename", + ] + + # Keywords that indicate audio data + audio_keywords = ["audio", "speech", "wav", "waveform", "sound"] + + multimodal_columns = [] + audio_columns = [] + modality_types = set() + + # ── Image detection ───────────────────────────────────── + # Pass 1: column-name heuristic (word-boundary match to avoid + # false positives like 'pic' in 'topic') + for col_name in column_names: + for keyword in image_keywords: + if _keyword_in_column(keyword, col_name): + multimodal_columns.append(col_name) + modality_types.add(keyword) + break + + # Pass 2: inspect actual values + already_detected = set(multimodal_columns) + for col_name in column_names: + if col_name in already_detected: + continue + value = sample[col_name] + if _is_image_value(value): + multimodal_columns.append(col_name) + modality_types.add("image") + + # ── Audio detection ───────────────────────────────────── + # Pass 1: column-name heuristic (word-boundary match) + for col_name in column_names: + for keyword in audio_keywords: + if _keyword_in_column(keyword, col_name): + audio_columns.append(col_name) + modality_types.add("audio") + break + + # Pass 2: inspect actual values (catches non-obvious column names) + already_audio = set(audio_columns) + for col_name in column_names: + if col_name in already_audio: + continue + value = sample[col_name] + if _is_audio_value(value): + audio_columns.append(col_name) + modality_types.add("audio") + + # Filter out columns that are actually audio from the image list + # (e.g. a column named "audio" with {"bytes", "path"} could match _is_image_value) + if audio_columns: + audio_set = set(audio_columns) + multimodal_columns = [c for c in multimodal_columns if c not in audio_set] + + # Detect text column for audio datasets + detected_text_col = None + if audio_columns: + text_keywords = ["text", "sentence", "transcript", "transcription", "label"] + for col_name in column_names: + if col_name.lower() in text_keywords: + detected_text_col = col_name + break + + is_audio = len(audio_columns) > 0 + + # Detect speaker_id column for TTS datasets (CSM, Orpheus, Spark) + detected_speaker_col = None + if audio_columns: + speaker_keywords = ["source", "speaker", "speaker_id"] + for col_name in column_names: + if col_name.lower() in speaker_keywords: + detected_speaker_col = col_name + break + + return { + "is_image": len(multimodal_columns) > 0, + "multimodal_columns": multimodal_columns, + "modality_types": list(modality_types), + "is_audio": is_audio, + "audio_columns": audio_columns, + "detected_audio_column": audio_columns[0] if audio_columns else None, + "detected_text_column": detected_text_col, + "detected_speaker_column": detected_speaker_col, + } + + +def _is_image_value(value) -> bool: + """Check if a single sample value looks like image data.""" + if value is None: + return False + + # PIL Image instance + try: + from PIL.Image import Image as PILImage + + if isinstance(value, PILImage): + return True + except ImportError: + pass + + # HF datasets Image feature stores decoded images as PIL or dicts with + # {"bytes": b"...", "path": "..."} when not yet decoded. + # Exclude audio dicts (decoded audio has "array" + "sampling_rate"). + if isinstance(value, dict): + if "array" in value and "sampling_rate" in value: + return False # This is audio, not image + if "bytes" in value and "path" in value: + # Check path extension to exclude audio files + path = value.get("path") or "" + if isinstance(path, str) and any( + path.lower().endswith(ext) for ext in _AUDIO_EXTENSIONS + ): + return False + return True + + # Raw bytes with a known image magic header + if isinstance(value, (bytes, bytearray)): + return _has_image_header(value) + + # String that looks like an image file path or URL + _IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tiff", ".svg") + if isinstance(value, str) and len(value) < 1000: + lower = value.strip().lower() + # Image URL (http://... ending in image extension) + if lower.startswith(("http://", "https://")) and any( + lower.split("?")[0].endswith(ext) for ext in _IMAGE_EXTS + ): + return True + # Image file path (relative or absolute path ending in image extension) + if any(lower.endswith(ext) for ext in _IMAGE_EXTS): + return True + + return False + + +_AUDIO_EXTENSIONS = ( + ".wav", + ".mp3", + ".flac", + ".ogg", + ".opus", + ".m4a", + ".aac", + ".wma", + ".webm", +) + + +def _is_audio_value(value) -> bool: + """Check if a single sample value looks like audio data.""" + if value is None: + return False + + # HF datasets Audio feature: decoded → {"array": np.ndarray, "sampling_rate": int} + if isinstance(value, dict): + if "array" in value and "sampling_rate" in value: + return True + # Undecoded/streaming → {"bytes": b"...", "path": "some.wav"} + if "bytes" in value or "path" in value: + path = value.get("path") or "" + if isinstance(path, str) and any( + path.lower().endswith(ext) for ext in _AUDIO_EXTENSIONS + ): + return True + + return False + + +def _has_image_header(data: bytes) -> bool: + """Quick magic-byte check for common image formats.""" + if len(data) < 4: + return False + # JPEG + if data[:2] == b"\xff\xd8": + return True + # PNG + if data[:4] == b"\x89PNG": + return True + # GIF + if data[:3] == b"GIF": + return True + # WebP + if data[:4] == b"RIFF" and len(data) >= 12 and data[8:12] == b"WEBP": + return True + # BMP + if data[:2] == b"BM": + return True + return False + + +def detect_vlm_dataset_structure(dataset): + """ + Detects if VLM dataset is: + - Standard VLM messages format (image objects in content) + - Llava format (image indices + separate images column) + - Simple format needing conversion (image + text columns) + """ + try: + sample = next(iter(dataset)) + except StopIteration: + return { + "format": "unknown", + "needs_conversion": None, + "image_column": None, + "text_column": None, + "messages_column": None, + } + + column_names = set(sample.keys()) + + # Check if has messages column + if "messages" in column_names: + messages = sample["messages"] + + if messages and len(messages) > 0: + first_msg = messages[0] + if "content" in first_msg: + content = first_msg["content"] + + if isinstance(content, list) and len(content) > 0: + if isinstance(content[0], dict) and "type" in content[0]: + # Check for llava format + has_index = any( + "index" in item + for item in content + if isinstance(item, dict) + ) + has_images_column = "images" in column_names + + if has_index and has_images_column: + return { + "format": "vlm_messages_llava", + "needs_conversion": True, + "messages_column": "messages", + "image_column": "images", + "text_column": None, + } + + # Standard VLM format + has_image = any( + "image" in item + for item in content + if isinstance(item, dict) + ) + if has_image: + return { + "format": "vlm_messages", + "needs_conversion": False, + "messages_column": "messages", + "image_column": None, + "text_column": None, + } + + # Check for ShareGPT/ChatML conversations with placeholder + companion image column + # (e.g. Lin-Chen/ShareGPT4V, LLaVA-style datasets) + for chat_col in ("conversations", "messages"): + if chat_col not in column_names: + continue + chat_data = sample[chat_col] + if not isinstance(chat_data, list) or len(chat_data) == 0: + continue + first_msg = chat_data[0] + if not isinstance(first_msg, dict): + continue + # Detect ShareGPT (from/value) or ChatML (role/content) keys + msg_text = first_msg.get("value") or first_msg.get("content") + if not isinstance(msg_text, str): + continue + # Check for placeholder anywhere in the conversation + has_image_placeholder = any( + "" in str(m.get("value", "") or m.get("content", "")) + for m in chat_data + if isinstance(m, dict) + ) + if not has_image_placeholder: + continue + # Find companion image column + image_col = None + for col in column_names: + if col == chat_col: + continue + if _keyword_in_column("image", col) or _keyword_in_column("img", col): + image_col = col + break + if image_col: + return { + "format": "sharegpt_with_images", + "needs_conversion": True, + "image_column": image_col, + "text_column": None, + "messages_column": chat_col, + } + + # Find image and text columns using metadata filtering + + # Define metadata patterns to EXCLUDE + metadata_patterns = { + "suffixes": [ + "_id", + "_url", + "_name", + "_filename", + "_uri", + "_link", + "_key", + "_index", + ], + "prefixes": [ + "id_", + "url_", + "name_", + "filename_", + "uri_", + "link_", + "key_", + "index_", + ], + } + + # Image-related keywords + image_keywords = [ + "image", + "img", + "photo", + "picture", + "pic", + "visual", + "scan", + "file_name", + "filename", + ] + + # Text-related keywords + text_keywords = [ + "text", + "caption", + "captions", + "description", + "answer", + "output", + "response", + "label", + ] + + def is_metadata_column(col_name): + """Check if column name looks like metadata.""" + col_lower = col_name.lower() + + # Check suffixes + if any(col_lower.endswith(suffix) for suffix in metadata_patterns["suffixes"]): + return True + + # Check prefixes + if any( + col_lower.startswith(prefix) for prefix in metadata_patterns["prefixes"] + ): + return True + + return False + + def _score_image_candidate(col, sample_value): + """Score a candidate image column by how resolvable its value is.""" + # PIL Image object (highest priority - already loaded) + if hasattr(sample_value, "size") and hasattr(sample_value, "mode"): + return 100 + + # Dict with image data (bytes/path from HF Image feature) + if isinstance(sample_value, dict) and ( + "bytes" in sample_value or "path" in sample_value + ): + return 75 + + if isinstance(sample_value, str): + # URL strings + if sample_value.startswith(("http://", "https://")): + return 70 if not is_metadata_column(col) else 55 + # Bare file path + if is_metadata_column(col): + return 30 + return 50 + + return 0 + + def _probe_image_candidate(col, sample_value): + """Quick probe to check if an image candidate is actually reachable. + Returns True if likely valid, False if definitely broken.""" + import os + + # PIL / dict — already loaded, always valid + if not isinstance(sample_value, str): + return True + + # Local file — check it exists + if not sample_value.startswith(("http://", "https://")): + return os.path.exists( + sample_value + ) # bare filenames return False here, that's OK + + # URL — quick HEAD request with short timeout + try: + import urllib.request + + req = urllib.request.Request(sample_value, method = "HEAD") + resp = urllib.request.urlopen(req, timeout = 3) + return resp.status < 400 + except Exception: + return False + + def find_image_column(): + """Find image column by keyword match + value-based fallback. + When multiple candidates exist, probes them to find one that works.""" + candidates = [] + + # Pass 1: keyword-matched columns + for col in column_names: + if any(_keyword_in_column(keyword, col) for keyword in image_keywords): + sample_value = sample[col] + score = _score_image_candidate(col, sample_value) + if score > 0: + candidates.append((col, score)) + + # Pass 2: value-based fallback — find columns with image URLs/paths + # even if the column name doesn't match image keywords + already = {c[0] for c in candidates} + for col in column_names: + if col in already: + continue + sample_value = sample[col] + if _is_image_value(sample_value): + score = _score_image_candidate(col, sample_value) + # Slightly penalise non-keyword columns so keyword matches win on ties + candidates.append((col, max(score - 5, 1))) + + if not candidates: + return None + + candidates.sort(key = lambda x: x[1], reverse = True) + + # Single candidate or top candidate is PIL/dict — no probing needed + if len(candidates) == 1 or candidates[0][1] >= 75: + return candidates[0][0] + + # Multiple string-based candidates — probe to find one that actually works + for col, score in candidates: + sample_value = sample[col] + if _probe_image_candidate(col, sample_value): + return col + + # Nothing probed successfully — return highest-scored anyway and let + # conversion handle the error (it may still resolve via hf_hub_download) + return candidates[0][0] + + def find_text_column(): + """Find text column by filtering out metadata and checking keywords.""" + candidates = [] + + for col in column_names: + # Skip metadata columns + if is_metadata_column(col): + continue + + # Check if contains text keywords (word-boundary match) + if any(_keyword_in_column(keyword, col) for keyword in text_keywords): + # Verify it's actually text + sample_value = sample[col] + + if isinstance(sample_value, str) and len(sample_value) > 0: + # Longer text = higher priority (likely content, not just a label) + priority = min(len(sample_value), 1000) # Cap at 1000 + candidates.append((col, priority)) + elif ( + isinstance(sample_value, list) + and len(sample_value) > 0 + and isinstance(sample_value[0], str) + ): + # List of strings (e.g. captions list) — lower priority than plain strings + priority = min(len(sample_value[0]), 1000) // 2 + candidates.append((col, priority)) + + # Return highest priority candidate + if candidates: + candidates.sort(key = lambda x: x[1], reverse = True) + return candidates[0][0] + + return None + + found_image = find_image_column() + found_text = find_text_column() + + if found_image and found_text: + return { + "format": "simple_image_text", + "needs_conversion": True, + "image_column": found_image, + "text_column": found_text, + "messages_column": None, + } + + return { + "format": "unknown", + "needs_conversion": None, + "image_column": found_image, + "text_column": found_text, + "messages_column": None, + } diff --git a/studio/backend/utils/datasets/llm_assist.py b/studio/backend/utils/datasets/llm_assist.py new file mode 100644 index 0000000000..e78c462a6e --- /dev/null +++ b/studio/backend/utils/datasets/llm_assist.py @@ -0,0 +1,862 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +LLM-assisted dataset analysis using an ephemeral GGUF helper model. + +Complements heuristic-based detection in format_detection.py and +vlm_processing.py. Only invoked when heuristics are uncertain. + +Architecture: + - Instantiates LlamaCppBackend, loads model, runs completion(s), unloads. + - Not kept warm — VRAM is freed immediately after use. + - Gracefully degrades: returns None when unavailable (no binary, OOM, disabled). +""" + +import json +import logging +import os +import re +import textwrap +import time +from itertools import islice +from typing import Any, Optional + +logger = logging.getLogger(__name__) + +DEFAULT_HELPER_MODEL_REPO = "Qwen/Qwen2.5-7B-Instruct-GGUF" +DEFAULT_HELPER_MODEL_VARIANT = "Q8_0" + +README_MAX_CHARS = 1500 + + +def precache_helper_gguf(): + """ + Pre-download the helper GGUF to HF cache. + + Called on FastAPI startup in a background thread so subsequent + ``_run_with_helper()`` calls skip the download and only pay for + llama-server startup. No-op if already cached or disabled. + """ + if os.environ.get("UNSLOTH_HELPER_MODEL_DISABLE", "").strip() in ("1", "true"): + return + + repo = os.environ.get("UNSLOTH_HELPER_MODEL_REPO", DEFAULT_HELPER_MODEL_REPO) + variant = os.environ.get( + "UNSLOTH_HELPER_MODEL_VARIANT", DEFAULT_HELPER_MODEL_VARIANT + ) + + try: + from huggingface_hub import HfApi, hf_hub_download + + # Find the GGUF file matching the variant + api = HfApi() + files = api.list_repo_files(repo, repo_type = "model") + gguf_files = [f for f in files if f.endswith(".gguf")] + + # Find all GGUF files matching the variant (may be split into shards) + variant_lower = variant.lower().replace("-", "_") + matching = sorted( + f for f in gguf_files if variant_lower in f.lower().replace("-", "_") + ) + + if matching: + logger.info( + f"Pre-caching helper GGUF: {repo}/{matching[0]}" + + (f" (+{len(matching) - 1} shards)" if len(matching) > 1 else "") + ) + for target in matching: + hf_hub_download(repo_id = repo, filename = target) + logger.info(f"Helper GGUF cached: {len(matching)} file(s)") + else: + logger.warning(f"No GGUF matching variant '{variant}' in {repo}") + except Exception as e: + logger.warning(f"Failed to pre-cache helper GGUF: {e}") + + +def _run_with_helper(prompt: str, max_tokens: int = 256) -> Optional[str]: + """ + Load helper model, run one chat completion, unload. + + Returns the completion text, or None on any failure. + """ + if os.environ.get("UNSLOTH_HELPER_MODEL_DISABLE", "").strip() in ("1", "true"): + return None + + repo = os.environ.get("UNSLOTH_HELPER_MODEL_REPO", DEFAULT_HELPER_MODEL_REPO) + variant = os.environ.get( + "UNSLOTH_HELPER_MODEL_VARIANT", DEFAULT_HELPER_MODEL_VARIANT + ) + + backend = None + try: + from core.inference.llama_cpp import LlamaCppBackend + + backend = LlamaCppBackend() + logger.info(f"Loading helper model: {repo} ({variant})") + print(f"🤖 Loading helper model: {repo} ({variant})...") + + ok = backend.load_model( + hf_repo = repo, + hf_variant = variant, + model_identifier = f"helper:{repo}:{variant}", + is_vision = False, + n_ctx = 2048, + n_gpu_layers = -1, + ) + if not ok: + logger.warning("Helper model failed to start") + return None + + messages = [{"role": "user", "content": prompt}] + cumulative = "" + for text in backend.generate_chat_completion( + messages = messages, + temperature = 0.1, + top_p = 0.9, + top_k = 20, + max_tokens = max_tokens, + repetition_penalty = 1.0, + ): + cumulative = text # cumulative — last value is full text + + result = cumulative.strip() + logger.info(f"Helper model response ({len(result)} chars)") + return result if result else None + + except Exception as e: + logger.warning(f"Helper model failed: {e}") + return None + + finally: + if backend is not None: + try: + backend.unload_model() + print("🤖 Helper model unloaded") + except Exception: + pass + + +# ─── Public API ─────────────────────────────────────────────────────── + + +def llm_generate_vlm_instruction( + column_names: list[str], + samples: list[dict], + dataset_name: Optional[str] = None, +) -> Optional[dict]: + """ + Ask a helper LLM to generate a task-specific VLM instruction. + + Called when heuristic instruction generation returns low confidence + or falls back to generic. + + Args: + column_names: Column names in the dataset. + samples: 3-5 sample rows with text values (images replaced by ""). + dataset_name: Optional HF dataset identifier for context. + + Returns: + {"instruction": str, "confidence": 0.85} or None. + """ + # Format samples for the prompt + formatted = "" + for i, row in enumerate(samples[:5], 1): + parts = [] + for col in column_names: + val = str(row.get(col, ""))[:300] + parts.append(f" {col}: {val}") + formatted += f"Sample {i}:\n" + "\n".join(parts) + "\n\n" + + prompt = ( + "You are a dataset analyst. Given a vision-language dataset, generate ONE " + "instruction sentence that describes what the model should do with each image.\n\n" + f"Dataset: {dataset_name or 'unknown'}\n" + f"Columns: {column_names}\n\n" + f"{formatted}" + "Write ONE instruction sentence. Examples:\n" + '- "Solve the math problem shown in the image and explain your reasoning."\n' + '- "Transcribe all text visible in this image."\n' + '- "Answer the question about this image."\n\n' + "Respond with ONLY the instruction sentence, nothing else." + ) + + result = _run_with_helper(prompt, max_tokens = 100) + if not result: + return None + + # Clean up: strip quotes, ensure it's a single sentence + instruction = result.strip().strip('"').strip("'").strip() + # Reject obviously bad outputs (too short, too long, or multi-line) + if len(instruction) < 10 or len(instruction) > 200 or "\n" in instruction: + logger.warning(f"Helper model returned unusable instruction: {instruction!r}") + return None + + print(f"🤖 LLM-generated instruction: {instruction}") + return { + "instruction": instruction, + "confidence": 0.85, + } + + +def llm_classify_columns( + column_names: list[str], + samples: list[dict], +) -> Optional[dict[str, str]]: + """ + Ask a helper LLM to classify dataset columns into roles. + + Called when heuristic column detection fails (returns None). + + Args: + column_names: Column names in the dataset. + samples: 3-5 sample rows with values truncated to 200 chars. + + Returns: + Dict mapping column_name → role ("user"|"assistant"|"system"|"metadata"), + or None on failure. + """ + formatted = "" + for i, row in enumerate(samples[:5], 1): + parts = [] + for col in column_names: + val = str(row.get(col, ""))[:200] + parts.append(f" {col}: {val}") + formatted += f"Sample {i}:\n" + "\n".join(parts) + "\n\n" + + prompt = ( + "Classify each column in this dataset into one of these roles:\n" + "- user: The input/question/prompt from the human\n" + "- assistant: The expected output/answer/response from the AI\n" + "- system: Context, persona, or task description\n" + "- metadata: IDs, scores, labels, timestamps — not part of conversation\n\n" + f"Columns: {column_names}\n\n" + f"{formatted}" + "Respond with ONLY a JSON object mapping column names to roles.\n" + 'Example: {"question": "user", "answer": "assistant", "id": "metadata"}' + ) + + result = _run_with_helper(prompt, max_tokens = 200) + if not result: + return None + + # Parse JSON from response (may have markdown fences) + text = result.strip() + if text.startswith("```"): + # Strip markdown code fence + lines = text.split("\n") + text = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:]) + text = text.strip() + + try: + mapping = json.loads(text) + except json.JSONDecodeError: + # Try to find JSON object in the response + import re + + match = re.search(r"\{[^}]+\}", text) + if match: + try: + mapping = json.loads(match.group()) + except json.JSONDecodeError: + logger.warning(f"Could not parse helper model JSON: {text!r}") + return None + else: + logger.warning(f"No JSON found in helper model response: {text!r}") + return None + + if not isinstance(mapping, dict): + return None + + # Validate: all values must be valid roles + valid_roles = {"user", "assistant", "system", "metadata"} + cleaned = {} + for col, role in mapping.items(): + if ( + col in column_names + and isinstance(role, str) + and role.lower() in valid_roles + ): + cleaned[col] = role.lower() + + if not cleaned: + return None + + # Must have at least user + assistant + roles_present = set(cleaned.values()) + if "user" not in roles_present or "assistant" not in roles_present: + logger.warning(f"Helper model mapping missing user/assistant: {cleaned}") + return None + + print(f"🤖 LLM-classified columns: {cleaned}") + return cleaned + + +def llm_generate_dataset_warning( + issues: list[str], + dataset_name: Optional[str] = None, + modality: str = "text", + column_names: Optional[list[str]] = None, +) -> Optional[str]: + """ + Ask the helper LLM to turn technical dataset issues into a user-friendly warning. + + Works for all modalities (text, vision, audio). + + Args: + issues: List of technical issue descriptions found during analysis. + dataset_name: Optional HF dataset name. + modality: "text", "vision", or "audio". + column_names: Optional list of column names for context. + + Returns: + A human-friendly warning string, or None on failure. + """ + if not issues: + return None + + issues_text = "\n".join(f"- {issue}" for issue in issues) + cols_text = f"\nColumns: {column_names}" if column_names else "" + + prompt = ( + "You are a helpful assistant. A user is trying to fine-tune a model on a dataset.\n" + "The following issues were found during dataset analysis:\n\n" + f"{issues_text}\n\n" + f"Dataset: {dataset_name or 'unknown'}\n" + f"Modality: {modality}" + f"{cols_text}\n\n" + "Write a brief, friendly explanation of what's wrong and what the user can do about it.\n" + "Keep it under 3 sentences. Be specific about the dataset." + ) + + result = _run_with_helper(prompt, max_tokens = 200) + if not result: + return None + + warning = result.strip() + # Reject obviously bad outputs + if len(warning) < 10 or len(warning) > 500: + return None + + print(f"🤖 LLM-generated warning: {warning}") + return warning + + +# ─── Dataset Conversion Advisor ────────────────────────────────────── + + +def _parse_json_response(text: str) -> Optional[dict]: + """Parse JSON from LLM response, handling markdown fences and noise.""" + if not text: + return None + + cleaned = text.strip() + + # Strip markdown code fences + if cleaned.startswith("```"): + lines = cleaned.split("\n") + end = -1 if lines[-1].strip().startswith("```") else len(lines) + cleaned = "\n".join(lines[1:end]).strip() + + # Try direct parse + try: + obj = json.loads(cleaned) + if isinstance(obj, dict): + return obj + except json.JSONDecodeError: + pass + + # Greedy match for outermost {...} + match = re.search(r"\{.*\}", cleaned, re.DOTALL) + if match: + try: + obj = json.loads(match.group()) + if isinstance(obj, dict): + return obj + except json.JSONDecodeError: + pass + + return None + + +def _generate_with_backend(backend, messages: list[dict], max_tokens: int = 512) -> str: + """Run one chat completion on an already-loaded backend. Returns raw text.""" + cumulative = "" + for text in backend.generate_chat_completion( + messages = messages, + temperature = 0.1, + top_p = 0.9, + top_k = 20, + max_tokens = max_tokens, + repetition_penalty = 1.0, + ): + cumulative = text + return cumulative.strip() + + +def fetch_hf_dataset_card( + dataset_name: str, hf_token: Optional[str] = None +) -> tuple[Optional[str], Optional[dict]]: + """ + Fetch HF dataset card (README) and metadata. + + Returns: + (readme_text, metadata_dict) or (None, None) on failure. + """ + try: + from huggingface_hub import DatasetCard + + card = DatasetCard.load(dataset_name, token = hf_token) + readme = card.text or "" + + # Truncate at sentence boundary + if len(readme) > README_MAX_CHARS: + cut = readme[:README_MAX_CHARS].rfind(".") + if cut > README_MAX_CHARS // 2: + readme = readme[: cut + 1] + "\n[...truncated]" + else: + readme = readme[:README_MAX_CHARS] + "\n[...truncated]" + + # Extract metadata from YAML frontmatter + metadata = {} + if card.data: + for key in ( + "task_categories", + "task_ids", + "language", + "size_categories", + "tags", + "license", + "pretty_name", + ): + val = getattr(card.data, key, None) + if val is not None: + metadata[key] = val + + logger.info( + f"Fetched dataset card: {len(readme)} chars, {len(metadata)} metadata fields" + ) + return readme, metadata + + except Exception as e: + logger.warning(f"Could not fetch dataset card for {dataset_name}: {e}") + return None, None + + +def _run_multi_pass_advisor( + columns: list[str], + samples: list[dict], + dataset_name: Optional[str] = None, + dataset_card: Optional[str] = None, + dataset_metadata: Optional[dict] = None, + model_name: Optional[str] = None, + model_type: Optional[str] = None, + hf_token: Optional[str] = None, +) -> Optional[dict[str, Any]]: + """ + Multi-pass LLM analysis: classify → convert → validate. + + Keeps model loaded across all passes. Returns combined result dict or None. + """ + if os.environ.get("UNSLOTH_HELPER_MODEL_DISABLE", "").strip() in ("1", "true"): + return None + + repo = os.environ.get("UNSLOTH_HELPER_MODEL_REPO", DEFAULT_HELPER_MODEL_REPO) + variant = os.environ.get( + "UNSLOTH_HELPER_MODEL_VARIANT", DEFAULT_HELPER_MODEL_VARIANT + ) + + backend = None + try: + from core.inference.llama_cpp import LlamaCppBackend + + backend = LlamaCppBackend() + print(f"🤖 Loading advisor model: {repo} ({variant})...") + t0 = time.monotonic() + + ok = backend.load_model( + hf_repo = repo, + hf_variant = variant, + model_identifier = f"advisor:{repo}:{variant}", + is_vision = False, + n_ctx = 2048, + n_gpu_layers = -1, + ) + if not ok: + logger.warning("Advisor model failed to start") + return None + + print(f"🤖 Advisor model loaded in {time.monotonic() - t0:.1f}s") + + # ── Format samples ── + samples_text = "" + for i, row in enumerate(samples[:5], 1): + parts = [f" {col}: {str(row.get(col, ''))[:200]}" for col in columns] + samples_text += f"Row {i}:\n" + "\n".join(parts) + "\n" + + metadata_str = ( + json.dumps(dataset_metadata, indent = 2, default = str)[:500] + if dataset_metadata + else "N/A" + ) + card_excerpt = (dataset_card or "")[:1200] or "N/A" + + # ── Target Model Hints ── + target_hints = "" + is_gemma_3n = False + if model_name: + try: + from utils.models.model_config import load_model_config + + config = load_model_config(model_name, use_auth = True, token = hf_token) + archs = getattr(config, "architectures", []) + if archs and "Gemma3nForConditionalGeneration" in archs: + is_gemma_3n = True + except Exception: + is_gemma_3n = "gemma-3n" in model_name.lower() + + if model_type == "audio" and not is_gemma_3n: + target_hints = ( + "\n\nHINT: The user is training an AUDIO model. The dataset MUST contain " + "a column with audio files/paths. Ensure one such column is selected " + "as part of the input." + ) + elif model_type == "embeddings": + target_hints = ( + "\n\nHINT: The user is training an EMBEDDING model. These models typically " + "do not use standard conversational input/output formats but instead use " + "specific formats like:\n" + "- Pairs of texts for Semantic Textual Similarity (STS)\n" + "- Premise, hypothesis, and label for Natural Language Inference (NLI)\n" + "- Queries and positive/negative documents for information retrieval\n" + "Ensure the dataset format mapped reflects these specialized tasks." + ) + + # ── Pass 1: Classify ── + print("🤖 Pass 1: Classifying dataset...", flush = True) + t1 = time.monotonic() + messages1 = [ + { + "role": "system", + "content": ( + "You are a dataset analyst. Your job is to look at a HuggingFace dataset " + "and figure out what kind of data it contains and whether it is already in " + "a conversational format suitable for LLM fine-tuning. A dataset is " + '"conversational" if it already has columns like "messages", "conversations", ' + 'or multiturn "user"/"assistant" pairs. Some datasets are NOT conversational ' + "— they are things like summarization, question answering, translation, " + "classification, etc. Those need conversion. You must respond with ONLY a " + "valid JSON object. Do not write any explanation before or after the JSON." + f"{target_hints}" + ), + }, + { + "role": "user", + "content": textwrap.dedent(f"""\ + Look at this HuggingFace dataset and classify it. + + DATASET CARD (excerpt): + {card_excerpt} + + METADATA: + {metadata_str} + + COLUMNS: {columns} + + SAMPLE DATA (first 3 rows): + {samples_text} + + Based on the above, respond with this exact JSON structure: + {{ + "dataset_type": "", + "is_conversational": , + "needs_conversion": , + "description": "", + "task_description": "" + }} + + Respond with ONLY the JSON object. No markdown, no explanation."""), + }, + ] + raw1 = _generate_with_backend(backend, messages1, max_tokens = 256) + pass1 = _parse_json_response(raw1) + print(f"🤖 Pass 1 done ({time.monotonic() - t1:.1f}s): {pass1}", flush = True) + + if not pass1: + logger.warning(f"Advisor Pass 1 failed to produce JSON: {raw1[:200]}") + return None + + # If dataset is already conversational, skip passes 2-3 + if pass1.get("is_conversational") and not pass1.get("needs_conversion"): + return { + "success": True, + "dataset_type": pass1.get("dataset_type"), + "is_conversational": True, + "user_notification": ( + "This dataset is already in conversational format. " + "No conversion needed — columns can be mapped directly." + ), + } + + # ── Pass 2: Map columns to roles ── + print("🤖 Pass 2: Mapping columns to roles...", flush = True) + t2 = time.monotonic() + messages2 = [ + { + "role": "system", + "content": ( + "You are a data preparation assistant. Your job is to assign each column " + "in a dataset to a conversation role for LLM fine-tuning. There are exactly " + "two roles:\n" + '- "user" = This column contains INPUT that the model will receive as a prompt.\n' + '- "assistant" = This column contains OUTPUT that the model should learn to generate.\n\n' + "CRITICAL RULES:\n" + '1. There MUST be at least one column assigned to "user" AND at least one ' + 'column assigned to "assistant". Never assign all columns to the same role.\n' + "2. The column that contains the TARGET or OUTPUT or ANSWER or LABEL must " + 'ALWAYS be assigned to "assistant". This is the thing the model should learn ' + "to produce.\n" + "3. The columns that contain the SOURCE or INPUT or CONTEXT or QUESTION must " + 'be assigned to "user". This is what the model receives.\n' + '4. Metadata columns like "id", "index", "source", "url", "date" should be ' + 'set to "skip".\n\n' + "You must respond with ONLY a valid JSON object." + f"{target_hints}" + ), + }, + { + "role": "user", + "content": textwrap.dedent(f"""\ + Here is a dataset that has been classified: + + CLASSIFICATION: + {json.dumps(pass1, indent = 2)} + + COLUMNS AVAILABLE: {columns} + + SAMPLE DATA (first 3 rows): + {samples_text} + + Your task: assign each column to either "user", "assistant", or "skip". + + Here are worked examples to guide you: + + Example 1 — Summarization dataset with columns ["document", "summary"]: + "document" is the input text → "user" + "summary" is the output the model should generate → "assistant" + Result: {{"document": "user", "summary": "assistant"}} + + Example 2 — Question answering dataset with columns ["context", "question", "answer"]: + "context" is input → "user" + "question" is input → "user" + "answer" is what the model should generate → "assistant" + Result: {{"context": "user", "question": "user", "answer": "assistant"}} + + Example 3 — Classification dataset with columns ["text", "label"]: + "text" is input → "user" + "label" is the output the model should predict → "assistant" + Result: {{"text": "user", "label": "assistant"}} + + Example 4 — Translation dataset with columns ["en", "fr"]: + "en" is the source language (input) → "user" + "fr" is the target language (output) → "assistant" + Result: {{"en": "user", "fr": "assistant"}} + + Now apply this logic to the actual dataset columns listed above. + + Respond with this exact JSON structure: + {{ + "column_roles": {{ + "": "" + }}, + "label_mapping": , + "notes": "" + }} + + REMEMBER: There must be at least one "user" column AND at least one "assistant" column. If all columns are "user", you made a mistake — the output/target column should be "assistant". + + Respond with ONLY the JSON object."""), + }, + ] + raw2 = _generate_with_backend(backend, messages2, max_tokens = 512) + pass2 = _parse_json_response(raw2) + print(f"🤖 Pass 2 done ({time.monotonic() - t2:.1f}s): {pass2}", flush = True) + + if not pass2: + logger.warning(f"Advisor Pass 2 failed to produce JSON: {raw2[:200]}") + return None + + # ── Extract and validate column roles from Pass 2 ── + column_roles = pass2.get("column_roles", {}) + label_map = pass2.get("label_mapping") or {} # may be null + + # Validate: must have at least one user AND one assistant + roles_present = set(column_roles.values()) + if "user" not in roles_present or "assistant" not in roles_present: + print( + f"🤖 Pass 2 sanity fail: missing user or assistant role: {column_roles}", + flush = True, + ) + return None # triggers fallback to simple classification + + # ── Pass 3: System prompt (non-conversational datasets only) ── + sys_prompt = "" + dtype = pass1.get("dataset_type", "unknown") + is_conv = pass1.get("is_conversational", False) + + if not is_conv: + print("🤖 Pass 3: Generating system prompt...", flush = True) + t3 = time.monotonic() + + # Format label mapping info for the prompt + label_info = "" + if label_map: + for col, mapping in label_map.items(): + if isinstance(mapping, dict) and mapping: + pairs = ", ".join(f"{k} = {v}" for k, v in mapping.items()) + label_info += f"\nLabel mapping for '{col}': {pairs}" + + # Describe the role assignments for context + user_cols = [c for c, r in column_roles.items() if r == "user"] + asst_cols = [c for c, r in column_roles.items() if r == "assistant"] + task_desc = pass1.get("task_description") or pass1.get("description", "") + + messages3 = [ + { + "role": "user", + "content": textwrap.dedent(f"""\ + I am building a fine-tuning dataset for an LLM. I need you to write a \ + system prompt that will be included in every training example to tell \ + the model what task it is performing. + + Here is the task information: + - Dataset type: {dtype} + - Task description: {task_desc} + - The USER (input) columns are: {user_cols} + - The ASSISTANT (output) columns are: {asst_cols} + {label_info} + + Write a system prompt that: + 1. Explains what task the model is performing in plain language + 2. Describes what input it will receive + 3. Describes what output it should produce + 4. Is 2-4 sentences long + + Write ONLY the system prompt text. No quotes, no labels, no explanation around it."""), + }, + ] + raw3 = _generate_with_backend(backend, messages3, max_tokens = 256) + print( + f"🤖 Pass 3 done ({time.monotonic() - t3:.1f}s): {raw3[:200] if raw3 else None}", + flush = True, + ) + + if raw3: + # Pass 3 returns raw text, not JSON — clean it up + cleaned = raw3.strip().strip('"').strip("'").strip() + if len(cleaned) >= 20 and cleaned.lower() not in ("null", "none", ""): + sys_prompt = cleaned + + # Build suggested_mapping (column → role, for the frontend dropdowns) + suggested_mapping = {} + for col, role in column_roles.items(): + if col in columns and role in ("user", "assistant", "system"): + suggested_mapping[col] = role + + # Build user notification from Pass 1 classification + desc = pass1.get("task_description") or pass1.get("description", "") + note_parts = [f"This is a {dtype} dataset (not conversational)."] + if desc: + note_parts.append(desc) + note_parts.append( + "Columns have been mapped to conversation roles. You can adjust the mapping if needed." + ) + user_notification = " ".join(note_parts) + + total_time = time.monotonic() - t0 + print( + f"🤖 Advisor complete ({total_time:.1f}s): type={dtype}, " + f"mapping={suggested_mapping}, sys_prompt={bool(sys_prompt)}, label_map={bool(label_map)}", + flush = True, + ) + + return { + "success": True, + "suggested_mapping": suggested_mapping, + "system_prompt": sys_prompt, + "label_mapping": label_map if label_map else None, + "dataset_type": dtype, + "is_conversational": is_conv, + "user_notification": user_notification, + } + + except Exception as e: + logger.warning(f"Advisor multi-pass failed: {e}") + return None + + finally: + if backend is not None: + try: + backend.unload_model() + print("🤖 Advisor model unloaded") + except Exception: + pass + + +def llm_conversion_advisor( + column_names: list[str], + samples: list[dict], + dataset_name: Optional[str] = None, + hf_token: Optional[str] = None, + model_name: Optional[str] = None, + model_type: Optional[str] = None, +) -> Optional[dict[str, Any]]: + """ + Full conversion advisor: fetch HF card → multi-pass LLM analysis. + + Falls back to simple llm_classify_columns() if the multi-pass advisor fails. + + Returns: + Dict with keys: success, suggested_mapping, system_prompt, user_template, + assistant_template, label_mapping, dataset_type, is_conversational, + user_notification. Or None on complete failure. + """ + # Fetch HF dataset card if this looks like a HF dataset (has a slash) + dataset_card = None + dataset_metadata = None + if dataset_name and "/" in dataset_name: + dataset_card, dataset_metadata = fetch_hf_dataset_card(dataset_name, hf_token) + + # Try multi-pass advisor + result = _run_multi_pass_advisor( + columns = column_names, + samples = samples, + dataset_name = dataset_name, + dataset_card = dataset_card, + dataset_metadata = dataset_metadata, + model_name = model_name, + model_type = model_type, + hf_token = hf_token, + ) + + if result and result.get("success"): + print(f"🤖 Conversion advisor succeeded: type={result.get('dataset_type')}") + return result + + # Fallback: simple column classification + logger.info("Advisor failed, falling back to simple column classification") + simple_mapping = llm_classify_columns(column_names, samples) + if simple_mapping: + return { + "success": True, + "suggested_mapping": { + col: role + for col, role in simple_mapping.items() + if role in ("user", "assistant", "system") + }, + "dataset_type": None, + "is_conversational": None, + "user_notification": None, + } + + return None diff --git a/studio/backend/utils/datasets/model_mappings.py b/studio/backend/utils/datasets/model_mappings.py new file mode 100644 index 0000000000..96b0c50876 --- /dev/null +++ b/studio/backend/utils/datasets/model_mappings.py @@ -0,0 +1,529 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Model and template mappings for dataset processing. + +This module contains the mapping dictionaries that associate model names +with their corresponding chat templates and response markers. +""" + +TEMPLATE_TO_MODEL_MAPPER = { + "phi-3.5": ( + "unsloth/Phi-3.5-mini-instruct-bnb-4bit", + "unsloth/Phi-3.5-mini-instruct", + "microsoft/Phi-3.5-mini-instruct", + ), + "phi-3": ( + "unsloth/Phi-3-mini-4k-instruct-bnb-4bit", + "unsloth/Phi-3-mini-4k-instruct", + "microsoft/Phi-3-mini-4k-instruct", + "unsloth/Phi-3-medium-4k-instruct-bnb-4bit", + "unsloth/Phi-3-medium-4k-instruct", + "microsoft/Phi-3-medium-4k-instruct", + "unsloth/Phi-3-mini-4k-instruct-v0-bnb-4bit", + "unsloth/Phi-3-mini-4k-instruct-v0", + ), + "phi-4": ( + "unsloth/phi-4-unsloth-bnb-4bit", + "unsloth/phi-4", + "microsoft/phi-4", + "unsloth/phi-4-bnb-4bit", + "unsloth/phi-4-reasoning-unsloth-bnb-4bit", + "unsloth/phi-4-reasoning", + "microsoft/Phi-4-reasoning", + "unsloth/phi-4-reasoning-bnb-4bit", + "unsloth/phi-4-reasoning-plus-unsloth-bnb-4bit", + "unsloth/phi-4-reasoning-plus", + "microsoft/Phi-4-reasoning-plus", + "unsloth/phi-4-reasoning-plus-bnb-4bit", + "unsloth/phi-4-mini-reasoning-unsloth-bnb-4bit", + "unsloth/phi-4-mini-reasoning", + "microsoft/Phi-4-mini-reasoning", + "unsloth/phi-4-mini-reasoning-bnb-4bit", + "unsloth/Phi-4-mini-instruct-unsloth-bnb-4bit", + "unsloth/Phi-4-mini-instruct", + "microsoft/Phi-4-mini-instruct", + "unsloth/Phi-4-mini-instruct-bnb-4bit", + ), + "mistral": ( + "unsloth/mistral-7b-instruct-v0.1-bnb-4bit", + "unsloth/mistral-7b-instruct-v0.1", + "mistralai/Mistral-7B-Instruct-v0.1", + "unsloth/mistral-7b-instruct-v0.2-bnb-4bit", + "unsloth/mistral-7b-instruct-v0.2", + "mistralai/Mistral-7B-Instruct-v0.2", + "unsloth/mistral-7b-instruct-v0.3-bnb-4bit", + "unsloth/mistral-7b-instruct-v0.3", + "mistralai/Mistral-7B-Instruct-v0.3", + "unsloth/Mixtral-8x7B-Instruct-v0.1-unsloth-bnb-4bit", + "unsloth/Mixtral-8x7B-Instruct-v0.1", + "mistralai/Mixtral-8x7B-Instruct-v0.1", + "unsloth/Mixtral-8x7B-Instruct-v0.1-bnb-4bit", + "unsloth/Mistral-Nemo-Instruct-2407-bnb-4bit", + "unsloth/Mistral-Nemo-Instruct-2407", + "mistralai/Mistral-Nemo-Instruct-2407", + "unsloth/Mistral-Large-Instruct-2407-bnb-4bit", + "mistralai/Mistral-Large-Instruct-2407", + "unsloth/Mistral-Small-Instruct-2409-bnb-4bit", + "unsloth/Mistral-Small-Instruct-2409", + "mistralai/Mistral-Small-Instruct-2409", + "unsloth/Mistral-Small-24B-Instruct-2501-unsloth-bnb-4bit", + "unsloth/Mistral-Small-24B-Instruct-2501", + "mistralai/Mistral-Small-24B-Instruct-2501", + "unsloth/Mistral-Small-24B-Instruct-2501-bnb-4bit", + "unsloth/Mistral-Small-3.1-24B-Instruct-2503-unsloth-bnb-4bit", + "unsloth/Mistral-Small-3.1-24B-Instruct-2503", + "mistralai/Mistral-Small-3.1-24B-Instruct-2503", + "unsloth/Mistral-Small-3.1-24B-Instruct-2503-bnb-4bit", + "unsloth/Mistral-Small-3.2-24B-Instruct-2506-unsloth-bnb-4bit", + "unsloth/Mistral-Small-3.2-24B-Instruct-2506", + "mistralai/Mistral-Small-3.2-24B-Instruct-2506", + "unsloth/Mistral-Small-3.2-24B-Instruct-2506-bnb-4bit", + ), + "llama": ( + "meta-llama/Llama-2-13b-chat-hf", + "unsloth/llama-2-7b-chat-bnb-4bit", + "unsloth/llama-2-7b-chat", + "meta-llama/Llama-2-7b-chat-hf", + ), + "llama3": ( + "unsloth/llama-3-8b-Instruct-bnb-4bit", + "unsloth/llama-3-8b-Instruct", + "meta-llama/Meta-Llama-3-8B-Instruct", + "unsloth/llama-3-70b-Instruct-bnb-4bit", + "meta-llama/Meta-Llama-3-70B-Instruct", + ), + "llama-3.1": ( + "unsloth/Meta-Llama-3.1-8B-Instruct-unsloth-bnb-4bit", + "unsloth/Meta-Llama-3.1-8B-Instruct", + "meta-llama/Meta-Llama-3.1-8B-Instruct", + "unsloth/Meta-Llama-3.1-8B-Instruct-bnb-4bit", + "unsloth/Llama-3.1-8B-Instruct-unsloth-bnb-4bit", + "unsloth/Llama-3.1-8B-Instruct", + "meta-llama/Llama-3.1-8B-Instruct", + "unsloth/Llama-3.1-8B-Instruct-bnb-4bit", + "unsloth/Meta-Llama-3.1-405B-Instruct-bnb-4bit", + "meta-llama/Meta-Llama-3.1-405B-Instruct", + "unsloth/Meta-Llama-3.1-70B-Instruct-bnb-4bit", + "unsloth/Meta-Llama-3.1-70B-Instruct", + "meta-llama/Meta-Llama-3.1-70B-Instruct", + "unsloth/Llama-3.1-Storm-8B-bnb-4bit", + "unsloth/Llama-3.1-Storm-8B", + "akjindal53244/Llama-3.1-Storm-8B", + "unsloth/Hermes-3-Llama-3.1-8B-bnb-4bit", + "unsloth/Hermes-3-Llama-3.1-8B", + "NousResearch/Hermes-3-Llama-3.1-8B", + "unsloth/Hermes-3-Llama-3.1-70B-bnb-4bit", + "unsloth/Hermes-3-Llama-3.1-70B", + "NousResearch/Hermes-3-Llama-3.1-70B", + "unsloth/Hermes-3-Llama-3.1-405B-bnb-4bit", + "NousResearch/Hermes-3-Llama-3.1-405B", + "unsloth/Llama-3.1-Nemotron-70B-Instruct-bnb-4bit", + "unsloth/Llama-3.1-Nemotron-70B-Instruct", + "nvidia/Llama-3.1-Nemotron-70B-Instruct-HF", + "unsloth/Llama-3.1-Tulu-3-8B-bnb-4bit", + "unsloth/Llama-3.1-Tulu-3-8B", + "allenai/Llama-3.1-Tulu-3-8B", + "unsloth/Llama-3.1-Tulu-3-70B-bnb-4bit", + "unsloth/Llama-3.1-Tulu-3-70B", + "allenai/Llama-3.1-Tulu-3-70B", + ), + "llama-3.2": ( + "unsloth/Llama-3.2-1B-Instruct-unsloth-bnb-4bit", + "unsloth/Llama-3.2-1B-Instruct", + "meta-llama/Llama-3.2-1B-Instruct", + "unsloth/Llama-3.2-1B-Instruct-bnb-4bit", + "unsloth/Llama-3.2-3B-Instruct-unsloth-bnb-4bit", + "unsloth/Llama-3.2-3B-Instruct", + "meta-llama/Llama-3.2-3B-Instruct", + "unsloth/Llama-3.2-3B-Instruct-bnb-4bit", + "unsloth/Llama-3.2-11B-Vision-Instruct-unsloth-bnb-4bit", + "unsloth/Llama-3.2-11B-Vision-Instruct", + "meta-llama/Llama-3.2-11B-Vision-Instruct", + "unsloth/Llama-3.2-11B-Vision-Instruct-bnb-4bit", + "unsloth/Llama-3.2-90B-Vision-Instruct-bnb-4bit", + "unsloth/Llama-3.2-90B-Vision-Instruct", + "meta-llama/Llama-3.2-90B-Vision-Instruct", + ), + "llama-3.3": ( + "unsloth/Llama-3.3-70B-Instruct-bnb-4bit", + "unsloth/Llama-3.3-70B-Instruct", + "meta-llama/Llama-3.3-70B-Instruct", + ), + "gemma": ( + "unsloth/gemma-7b-it-bnb-4bit", + "unsloth/gemma-7b-it", + "google/gemma-7b-it", + "google/gemma-2b-it", + "unsloth/gemma-1.1-2b-it-bnb-4bit", + "unsloth/gemma-1.1-2b-it", + "google/gemma-1.1-2b-it", + "unsloth/gemma-1.1-7b-it-bnb-4bit", + "unsloth/gemma-1.1-7b-it", + "google/gemma-1.1-7b-it", + ), + "gemma2": ( + "unsloth/gemma-2-9b-it-bnb-4bit", + "unsloth/gemma-2-9b-it", + "google/gemma-2-9b-it", + "unsloth/gemma-2-27b-it-bnb-4bit", + "unsloth/gemma-2-27b-it", + "google/gemma-2-27b-it", + "unsloth/gemma-2-2b-it-bnb-4bit", + "unsloth/gemma-2-2b-it", + "google/gemma-2-2b-it", + ), + "gemma-3": ( + "unsloth/gemma-3-1b-it-unsloth-bnb-4bit", + "unsloth/gemma-3-1b-it", + "google/gemma-3-1b-it", + "unsloth/gemma-3-1b-it-bnb-4bit", + "unsloth/gemma-3-4b-it-unsloth-bnb-4bit", + "unsloth/gemma-3-4b-it", + "google/gemma-3-4b-it", + "unsloth/gemma-3-4b-it-bnb-4bit", + "unsloth/gemma-3-12b-it-unsloth-bnb-4bit", + "unsloth/gemma-3-12b-it", + "google/gemma-3-12b-it", + "unsloth/gemma-3-12b-it-bnb-4bit", + "unsloth/gemma-3-27b-it-unsloth-bnb-4bit", + "unsloth/gemma-3-27b-it", + "google/gemma-3-27b-it", + "unsloth/gemma-3-27b-it-bnb-4bit", + "unsloth/gemma-3-270m-it-unsloth-bnb-4bit", + "unsloth/gemma-3-270m-it", + "google/gemma-3-270m-it", + "unsloth/gemma-3-270m-it-bnb-4bit", + "unsloth/gemma-3-270m-unsloth-bnb-4bit", + "unsloth/medgemma-4b-it-unsloth-bnb-4bit", + "unsloth/medgemma-4b-it", + "google/medgemma-4b-it", + "unsloth/medgemma-4b-it-bnb-4bit", + "unsloth/medgemma-27b-text-it-unsloth-bnb-4bit", + "unsloth/medgemma-27b-text-it", + "google/medgemma-27b-text-it", + "unsloth/medgemma-27b-text-it-bnb-4bit", + ), + "gemma3n": ( + "unsloth/gemma-3n-E4B-it-unsloth-bnb-4bit", + "unsloth/gemma-3n-E4B-it", + "google/gemma-3n-E4B-it", + "unsloth/gemma-3n-E4B-it-unsloth-bnb-4bit", + "unsloth/gemma-3n-E2B-it-unsloth-bnb-4bit", + "unsloth/gemma-3n-E2B-it", + "google/gemma-3n-E2B-it", + "unsloth/gemma-3n-E2B-it-unsloth-bnb-4bit", + ), + "qwen2.5": ( + "unsloth/Qwen2.5-0.5B-Instruct-unsloth-bnb-4bit", + "unsloth/Qwen2.5-0.5B-Instruct", + "Qwen/Qwen2.5-0.5B-Instruct", + "unsloth/Qwen2.5-0.5B-Instruct-bnb-4bit", + "unsloth/Qwen2.5-1.5B-Instruct-unsloth-bnb-4bit", + "unsloth/Qwen2.5-1.5B-Instruct", + "Qwen/Qwen2.5-1.5B-Instruct", + "unsloth/Qwen2.5-1.5B-Instruct-bnb-4bit", + "unsloth/Qwen2.5-3B-Instruct-unsloth-bnb-4bit", + "unsloth/Qwen2.5-3B-Instruct", + "Qwen/Qwen2.5-3B-Instruct", + "unsloth/Qwen2.5-3B-Instruct-bnb-4bit", + "unsloth/Qwen2.5-7B-Instruct-unsloth-bnb-4bit", + "unsloth/Qwen2.5-7B-Instruct", + "Qwen/Qwen2.5-7B-Instruct", + "unsloth/Qwen2.5-7B-Instruct-bnb-4bit", + "unsloth/Qwen2.5-14B-Instruct-unsloth-bnb-4bit", + "unsloth/Qwen2.5-14B-Instruct", + "Qwen/Qwen2.5-14B-Instruct", + "unsloth/Qwen2.5-14B-Instruct-bnb-4bit", + "unsloth/Qwen2.5-32B-Instruct-bnb-4bit", + "unsloth/Qwen2.5-32B-Instruct", + "Qwen/Qwen2.5-32B-Instruct", + "unsloth/Qwen2.5-72B-Instruct-bnb-4bit", + "unsloth/Qwen2.5-72B-Instruct", + "Qwen/Qwen2.5-72B-Instruct", + "unsloth/Qwen2.5-0.5B-unsloth-bnb-4bit", + "unsloth/Qwen2.5-Math-1.5B-Instruct-bnb-4bit", + "unsloth/Qwen2.5-Math-1.5B-Instruct", + "Qwen/Qwen2.5-Math-1.5B-Instruct", + "unsloth/Qwen2.5-Math-7B-Instruct-bnb-4bit", + "unsloth/Qwen2.5-Math-7B-Instruct", + "Qwen/Qwen2.5-Math-7B-Instruct", + "unsloth/Qwen2.5-Math-72B-Instruct-bnb-4bit", + "unsloth/Qwen2.5-Math-72B-Instruct", + "Qwen/Qwen2.5-Math-72B-Instruct", + "unsloth/Qwen2.5-Coder-0.5B-Instruct-bnb-4bit", + "unsloth/Qwen2.5-Coder-0.5B-Instruct", + "Qwen/Qwen2.5-Coder-0.5B-Instruct", + "unsloth/Qwen2.5-Coder-1.5B-Instruct-bnb-4bit", + "unsloth/Qwen2.5-Coder-1.5B-Instruct", + "Qwen/Qwen2.5-Coder-1.5B-Instruct", + "unsloth/Qwen2.5-Coder-3B-Instruct-bnb-4bit", + "unsloth/Qwen2.5-Coder-3B-Instruct", + "Qwen/Qwen2.5-Coder-3B-Instruct", + "unsloth/Qwen2.5-Coder-7B-Instruct-bnb-4bit", + "unsloth/Qwen2.5-Coder-7B-Instruct", + "Qwen/Qwen2.5-Coder-7B-Instruct", + "unsloth/Qwen2.5-Coder-14B-Instruct-bnb-4bit", + "unsloth/Qwen2.5-Coder-14B-Instruct", + "Qwen/Qwen2.5-Coder-14B-Instruct", + "unsloth/Qwen2.5-Coder-32B-Instruct-bnb-4bit", + "unsloth/Qwen2.5-Coder-32B-Instruct", + "Qwen/Qwen2.5-Coder-32B-Instruct", + "unsloth/Qwen2.5-VL-3B-Instruct-unsloth-bnb-4bit", + "unsloth/Qwen2.5-VL-3B-Instruct", + "Qwen/Qwen2.5-VL-3B-Instruct", + "unsloth/Qwen2.5-VL-3B-Instruct-bnb-4bit", + "unsloth/Qwen2.5-VL-7B-Instruct-unsloth-bnb-4bit", + "unsloth/Qwen2.5-VL-7B-Instruct", + "Qwen/Qwen2.5-VL-7B-Instruct", + "unsloth/Qwen2.5-VL-7B-Instruct-bnb-4bit", + "unsloth/Qwen2.5-VL-32B-Instruct-unsloth-bnb-4bit", + "unsloth/Qwen2.5-VL-32B-Instruct", + "Qwen/Qwen2.5-VL-32B-Instruct", + "unsloth/Qwen2.5-VL-32B-Instruct-bnb-4bit", + "unsloth/Qwen2.5-VL-72B-Instruct-unsloth-bnb-4bit", + "unsloth/Qwen2.5-VL-72B-Instruct", + "Qwen/Qwen2.5-VL-72B-Instruct", + "unsloth/Qwen2.5-VL-72B-Instruct-bnb-4bit", + "unsloth/OpenThinker-7B-unsloth-bnb-4bit", + "unsloth/OpenThinker-7B", + "open-thoughts/OpenThinker-7B", + "unsloth/OpenThinker-7B-bnb-4bit", + ), + "qwen3": ( + "unsloth/Qwen3-0.6B-unsloth-bnb-4bit", + "unsloth/Qwen3-0.6B", + "Qwen/Qwen3-0.6B", + "unsloth/Qwen3-0.6B-bnb-4bit", + "unsloth/Qwen3-1.7B-unsloth-bnb-4bit", + "unsloth/Qwen3-1.7B", + "Qwen/Qwen3-1.7B", + "unsloth/Qwen3-1.7B-bnb-4bit", + "unsloth/Qwen3-4B-unsloth-bnb-4bit", + "unsloth/Qwen3-4B", + "Qwen/Qwen3-4B", + "unsloth/Qwen3-4B-bnb-4bit", + "unsloth/Qwen3-8B-unsloth-bnb-4bit", + "unsloth/Qwen3-8B", + "Qwen/Qwen3-8B", + "unsloth/Qwen3-8B-bnb-4bit", + "unsloth/Qwen3-14B-unsloth-bnb-4bit", + "unsloth/Qwen3-14B", + "Qwen/Qwen3-14B", + "unsloth/Qwen3-14B-bnb-4bit", + "unsloth/Qwen3-32B-unsloth-bnb-4bit", + "unsloth/Qwen3-32B", + "Qwen/Qwen3-32B", + "unsloth/Qwen3-32B-bnb-4bit", + "unsloth/Qwen3-30B-A3B-unsloth-bnb-4bit", + "unsloth/Qwen3-30B-A3B", + "Qwen/Qwen3-30B-A3B", + "unsloth/Qwen3-30B-A3B-bnb-4bit", + ), + "qwen3-instruct": ( + "unsloth/Qwen3-4B-Instruct-2507-unsloth-bnb-4bit", + "unsloth/Qwen3-4B-Instruct-2507", + "Qwen/Qwen3-4B-Instruct-2507", + "unsloth/Qwen3-4B-Instruct-2507-bnb-4bit", + "unsloth/Qwen3-30B-A3B-Instruct-2507", + "Qwen/Qwen3-30B-A3B-Instruct-2507", + "unsloth/Qwen3-Coder-30B-A3B-Instruct", + "Qwen/Qwen3-Coder-30B-A3B-Instruct", + "unsloth/Qwen3-4B-Instruct-2507-unsloth-bnb-4bit", + "unsloth/Qwen3-4B-Instruct-2507", + "Qwen/Qwen3-4B-Instruct-2507", + "unsloth/Qwen3-4B-Instruct-2507-bnb-4bit", + ), + "qwen3-thinking": ( + "unsloth/QwQ-32B-Preview-bnb-4bit", + "unsloth/QwQ-32B-Preview", + "Qwen/QwQ-32B-Preview", + "unsloth/QwQ-32B-unsloth-bnb-4bit", + "unsloth/QwQ-32B", + "Qwen/QwQ-32B", + "unsloth/QwQ-32B-bnb-4bit", + "unsloth/Qwen3-4B-Thinking-2507-unsloth-bnb-4bit", + "unsloth/Qwen3-4B-Thinking-2507", + "Qwen/Qwen3-4B-Thinking-2507", + "unsloth/Qwen3-4B-Thinking-2507-bnb-4bit", + "unsloth/Qwen3-30B-A3B-Thinking-2507", + "Qwen/Qwen3-30B-A3B-Thinking-2507", + ), + "qwen3.5": ( + "unsloth/Qwen3.5-0.8B", + "unsloth/Qwen3.5-2B", + "unsloth/Qwen3.5-4B", + "unsloth/Qwen3.5-27B", + "unsloth/Qwen3.5-35B-A3B", + ), + "zephyr": ( + "unsloth/zephyr-sft-bnb-4bit", + "unsloth/zephyr-sft", + "HuggingFaceH4/mistral-7b-sft-beta", + ), + "chatml": ( + "unsloth/yi-6b-bnb-4bit", + "unsloth/yi-6b", + "01-ai/Yi-6B", + "unsloth/Hermes-2-Pro-Mistral-7B-bnb-4bit", + "unsloth/Hermes-2-Pro-Mistral-7B", + "NousResearch/Hermes-2-Pro-Mistral-7B", + "unsloth/OpenHermes-2.5-Mistral-7B-bnb-4bit", + "unsloth/OpenHermes-2.5-Mistral-7B", + "teknium/OpenHermes-2.5-Mistral-7B", + ), + "gpt-oss": ( + "unsloth/gpt-oss-20b-unsloth-bnb-4bit", + "unsloth/gpt-oss-20b", + "openai/gpt-oss-20b", + "unsloth/gpt-oss-20b-unsloth-bnb-4bit", + "unsloth/gpt-oss-120b-unsloth-bnb-4bit", + "unsloth/gpt-oss-120b", + "openai/gpt-oss-120b", + "unsloth/gpt-oss-120b-unsloth-bnb-4bit", + ), + "starling": ( + "unsloth/Starling-LM-7B-beta-bnb-4bit", + "unsloth/Starling-LM-7B-beta", + "Nexusflow/Starling-LM-7B-beta", + ), + "yi-chat": ( + "unsloth/yi-34b-chat-bnb-4bit", + "01-ai/Yi-6B-Chat", + "01-ai/Yi-34B-Chat", + ), + "glm": ( + "unsloth/GLM-4.7-Flash-unsloth-bnb-4bit", + "unsloth/GLM-4.7-Flash", + "THUDM/GLM-4.7-Flash", + "unsloth/GLM-4.7-Flash-bnb-4bit", + ), +} + +MODEL_TO_TEMPLATE_MAPPER = {} + +for key, values in TEMPLATE_TO_MODEL_MAPPER.items(): + for value in values: + MODEL_TO_TEMPLATE_MAPPER[value] = key + + # Get lowercased + lowered_key = key.lower() + for value in values: + MODEL_TO_TEMPLATE_MAPPER[value.lower()] = lowered_key + + +TEMPLATE_TO_RESPONSES_MAPPER = { + "gemma-3": { + "instruction": "user\n", + "response": "model\n", + }, + "gemma3n": { + "instruction": "user\n", + "response": "model\n", + }, + "qwen3.5": { + "instruction": "<|im_start|>user\n", + "response": "<|im_start|>assistant\n\n", + }, + "qwen3-instruct": { + "instruction": "<|im_start|>user\n", + "response": "<|im_start|>assistant\n", + }, + "qwen3-thinking": { + "instruction": "<|im_start|>user\n", + "response": "<|im_start|>assistant\n\n", + }, + "qwen3": { + "instruction": "<|im_start|>user\n", + "response": "<|im_start|>assistant\n", + }, + "qwen2.5": { + "instruction": "<|im_start|>user\n", + "response": "<|im_start|>assistant\n", + }, + "llama-3.2": { + "instruction": "<|start_header_id|>user<|end_header_id|>\n\n", + "response": "<|start_header_id|>assistant<|end_header_id|>\n\n", + }, + "llama-3.3": { + "instruction": "<|start_header_id|>user<|end_header_id|>\n\n", + "response": "<|start_header_id|>assistant<|end_header_id|>\n\n", + }, + "llama-3.1": { + "instruction": "<|start_header_id|>user<|end_header_id|>\n\n", + "response": "<|start_header_id|>assistant<|end_header_id|>\n\n", + }, + "llama3": { + "instruction": "<|start_header_id|>user<|end_header_id|>\n\n", + "response": "<|start_header_id|>assistant<|end_header_id|>\n\n", + }, + "phi-3": { + "instruction": "<|user|>\n", + "response": "<|assistant|>\n", + }, + "phi-3.5": { + "instruction": "<|user|>\n", + "response": "<|assistant|>\n", + }, + "phi-4": { + "instruction": "<|im_start|>user<|im_sep|>", + "response": "<|im_start|>assistant<|im_sep|>", + }, + "mistral": { + "instruction": "[INST] ", + "response": " [/INST]", + }, + "llama": { + "instruction": "[INST] ", + "response": " [/INST]", + }, + "chatml": { + "instruction": "<|im_start|>user\n", + "response": "<|im_start|>assistant\n", + }, + "zephyr": { + "instruction": "<|user|>\n", + "response": "<|assistant|>\n", + }, + "unsloth": { + "instruction": ">>> User: ", + "response": ">>> Assistant: ", + }, + "vicuna": { + "instruction": "USER: ", + "response": "ASSISTANT: ", + }, + "alpaca": { + "instruction": "### Instruction:\n", + "response": "### Response:\n", + }, + "gemma": { + "instruction": "user\n", + "response": "model\n", + }, + "gemma2": { + "instruction": "user\n", + "response": "model\n", + }, + "gpt-oss": { + "instruction": "<|start|>user<|message|>", + "response": "<|start|>assistant<|channel|>final<|message|>", + }, + "lfm-2": { + "instruction": "<|im_start|>user\n", + "response": "<|im_start|>assistant\n", + }, + "starling": { + "instruction": "GPT4 Correct User: ", + "response": "GPT4 Correct Assistant: ", + }, + "yi-chat": { + "instruction": "<|im_start|>user\n", + "response": "<|im_start|>assistant\n", + }, + "glm": { + "instruction": "[gMASK]<|user|>", + "response": "<|assistant|>", + }, +} diff --git a/studio/backend/utils/datasets/vlm_processing.py b/studio/backend/utils/datasets/vlm_processing.py new file mode 100644 index 0000000000..7b63152ede --- /dev/null +++ b/studio/backend/utils/datasets/vlm_processing.py @@ -0,0 +1,233 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +VLM (Vision-Language Model) processing utilities. + +This module contains functions for generating smart instructions +for VLM datasets based on content analysis and heuristics. +""" + +import re +from itertools import islice + + +def generate_smart_vlm_instruction( + dataset, + text_column = "text", + image_column = "image", + dataset_name = None, +): + """ + Generate smart, context-aware instruction for VLM datasets using heuristics. + + Strategy: + 1. Check for explicit question/instruction columns → use that + 2. Infer from text column name + sample content + 3. Analyze dataset name for task hints + 4. Fall back to generic instruction + + Returns: + dict: { + "instruction": str or None, # None means use column content + "instruction_type": "explicit" | "inferred" | "generic", + "uses_dynamic_instruction": bool, # True if instruction varies per sample + "confidence": float, # 0.0 to 1.0 + } + """ + column_names = set(next(iter(dataset)).keys()) + sample = next(iter(dataset)) + + # ===== LEVEL 1: Explicit Instruction Columns ===== + # Check for columns that contain per-sample instructions + question_columns = ["question", "query", "prompt", "instruction", "user_prompt"] + + for col in question_columns: + if col in column_names: + # Check if this column has varied content (not just empty/same) + sample_content = sample[col] + if sample_content and str(sample_content).strip(): + return { + "instruction": None, # Signal to use column content + "instruction_column": col, + "instruction_type": "explicit", + "uses_dynamic_instruction": True, + "confidence": 1.0, + } + + # ===== LEVEL 2: Infer from Column Names + Content ===== + text_col_lower = text_column.lower() + + # Sample the text content to detect patterns + text_sample = str(sample.get(text_column, ""))[:500] # First 500 chars + + # Task-specific keywords and their instructions + task_patterns = { + # OCR / Transcription + "ocr": { + "keywords": ["ocr", "transcribe", "transcript"], + "content_hints": [ + r"[A-Za-z\u0600-\u06FF]{10,}" + ], # Long text passages (Latin/Arabic) + "instruction": "Transcribe all the text shown in this image.", + "confidence": 0.9, + }, + # LaTeX / Math + "latex": { + "keywords": ["latex", "math", "formula", "equation"], + "content_hints": [r"\\[a-z]+\{", r"\^", r"_", r"\\frac"], # LaTeX commands + "instruction": "Convert this image to LaTeX notation.", + "confidence": 0.95, + }, + # Caption / Description + "caption": { + "keywords": ["caption", "description", "describe"], + "content_hints": [], + "instruction": "Provide a detailed description of this image.", + "confidence": 0.85, + }, + # Medical / Radiology + "medical": { + "keywords": [ + "medical", + "radiology", + "xray", + "ct", + "mri", + "scan", + "diagnosis", + ], + "content_hints": [r"\b(lesion|radiograph|patient|diagnosis|findings)\b"], + "instruction": "Analyze this medical image and describe the key findings.", + "confidence": 0.9, + }, + # Code / Programming + "code": { + "keywords": ["code", "program", "function", "algorithm"], + "content_hints": [r"def |class |function|import |return "], + "instruction": "Explain what this code visualization shows.", + "confidence": 0.85, + }, + # Chart / Graph + "chart": { + "keywords": ["chart", "graph", "plot", "visualization", "diagram"], + "content_hints": [r"\b(axis|legend|bar|line|pie|scatter)\b"], + "instruction": "Describe this chart or graph, including key data points and trends.", + "confidence": 0.85, + }, + # Document / Text Recognition + "document": { + "keywords": ["document", "page", "paragraph", "article"], + "content_hints": [r"\n.*\n.*\n"], # Multi-line text + "instruction": "Extract and transcribe the text from this document image.", + "confidence": 0.85, + }, + } + + # Check column name matches + best_match = None + best_score = 0.0 + + for task_name, task_info in task_patterns.items(): + score = 0.0 + + # Check column name + if any(keyword in text_col_lower for keyword in task_info["keywords"]): + score += 0.5 + + # Check dataset name if provided + if dataset_name and any( + keyword in dataset_name.lower() for keyword in task_info["keywords"] + ): + score += 0.3 + + # Check content patterns + for pattern in task_info["content_hints"]: + if re.search(pattern, text_sample, re.IGNORECASE): + score += 0.4 + break + + if score > best_score: + best_score = score + best_match = task_info + + if best_match and best_score > 0.5: # Confidence threshold + return { + "instruction": best_match["instruction"], + "instruction_column": None, + "instruction_type": "inferred", + "uses_dynamic_instruction": False, + "confidence": min(best_score, best_match["confidence"]), + } + + # ===== LEVEL 3: Analyze Dataset Name ===== + if dataset_name: + name_lower = dataset_name.lower() + + # Common dataset name patterns + if "vqa" in name_lower or "question" in name_lower: + return { + "instruction": "Answer the question about this image.", + "instruction_column": None, + "instruction_type": "inferred", + "uses_dynamic_instruction": False, + "confidence": 0.75, + } + + if "coco" in name_lower or "flickr" in name_lower: + return { + "instruction": "Provide a detailed caption for this image.", + "instruction_column": None, + "instruction_type": "inferred", + "uses_dynamic_instruction": False, + "confidence": 0.75, + } + + # ===== LEVEL 4: LLM-Assisted Instruction Generation ===== + try: + from .llm_assist import llm_generate_vlm_instruction + + sample_rows = [] + for s in islice(dataset, 5): + row = {} + for col in s: + val = s[col] + if hasattr(val, "size") and hasattr(val, "mode"): # PIL Image + row[col] = "" + elif isinstance(val, list): + row[col] = str(val)[:300] + else: + row[col] = str(val)[:300] + sample_rows.append(row) + + llm_result = llm_generate_vlm_instruction( + column_names = list(column_names), + samples = sample_rows, + dataset_name = dataset_name, + ) + if llm_result and llm_result.get("instruction"): + print( + f"\n[DEBUG] LLM-assisted VLM instruction generated: " + f"'{llm_result['instruction']}' (confidence={llm_result.get('confidence', 'N/A')})\n", + flush = True, + ) + return { + "instruction": llm_result["instruction"], + "instruction_column": None, + "instruction_type": "llm_assisted", + "uses_dynamic_instruction": False, + "confidence": llm_result.get("confidence", 0.85), + } + except Exception as e: + import logging + + logging.getLogger(__name__).debug(f"LLM-assisted instruction skipped: {e}") + + # ===== LEVEL 5: Generic Fallback ===== + return { + "instruction": "Describe this image in detail.", + "instruction_column": None, + "instruction_type": "generic", + "uses_dynamic_instruction": False, + "confidence": 0.5, + } diff --git a/studio/backend/utils/hardware/__init__.py b/studio/backend/utils/hardware/__init__.py new file mode 100644 index 0000000000..c1f8b62010 --- /dev/null +++ b/studio/backend/utils/hardware/__init__.py @@ -0,0 +1,38 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Hardware detection and GPU utilities +""" + +from .hardware import ( + DeviceType, + DEVICE, + detect_hardware, + get_device, + is_apple_silicon, + clear_gpu_cache, + get_gpu_memory_info, + log_gpu_memory, + get_gpu_summary, + get_package_versions, + get_gpu_utilization, + get_physical_gpu_count, + safe_num_proc, +) + +__all__ = [ + "DeviceType", + "DEVICE", + "detect_hardware", + "get_device", + "is_apple_silicon", + "clear_gpu_cache", + "get_gpu_memory_info", + "log_gpu_memory", + "get_gpu_summary", + "get_package_versions", + "get_gpu_utilization", + "get_physical_gpu_count", + "safe_num_proc", +] diff --git a/studio/backend/utils/hardware/hardware.py b/studio/backend/utils/hardware/hardware.py new file mode 100644 index 0000000000..c743dcd897 --- /dev/null +++ b/studio/backend/utils/hardware/hardware.py @@ -0,0 +1,488 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Hardware detection — run once at startup, read everywhere. + +Usage: + # At FastAPI lifespan startup: + from utils.hardware import detect_hardware + detect_hardware() + + # Anywhere else: + from utils.hardware import DEVICE, DeviceType, is_apple_silicon + if DEVICE == DeviceType.CUDA: + import torch + ... +""" + +import platform +import structlog +from loggers import get_logger +from enum import Enum +from typing import Optional, Dict, Any + +logger = get_logger(__name__) + + +# ========== Device Enum ========== + + +class DeviceType(str, Enum): + """Supported compute backends. Inherits from str so it serializes cleanly in JSON.""" + + CUDA = "cuda" + MLX = "mlx" + CPU = "cpu" + + +# ========== Global State (set once by detect_hardware) ========== + +DEVICE: Optional[DeviceType] = None + + +# ========== Detection ========== + + +def is_apple_silicon() -> bool: + """Check if running on Apple Silicon hardware (pure platform check, no ML imports).""" + return platform.system() == "Darwin" and platform.machine() == "arm64" + + +def _has_torch() -> bool: + """Check if PyTorch is importable.""" + try: + import torch + + return True + except ImportError: + return False + + +def _has_mlx() -> bool: + """Check if MLX is importable.""" + try: + import mlx.core + + return True + except ImportError: + return False + + +def detect_hardware() -> DeviceType: + """ + Detect the best available compute device and set the module-level DEVICE global. + + Should be called exactly once during FastAPI lifespan startup. + Safe to call multiple times (idempotent). + + Detection order: + 1. CUDA (NVIDIA GPU, requires torch) + 2. MLX (Apple Silicon via MLX framework) + 3. CPU (fallback) + """ + global DEVICE + + # --- CUDA: try PyTorch --- + if _has_torch(): + import torch + + if torch.cuda.is_available(): + DEVICE = DeviceType.CUDA + device_name = torch.cuda.get_device_properties(0).name + print(f"Hardware detected: CUDA — {device_name}") + return DEVICE + + # --- MLX: Apple Silicon --- + if is_apple_silicon() and _has_mlx(): + DEVICE = DeviceType.MLX + chip = platform.processor() or platform.machine() + print(f"Hardware detected: MLX — Apple Silicon ({chip})") + return DEVICE + + # --- Fallback --- + DEVICE = DeviceType.CPU + print("Hardware detected: CPU (no GPU backend available)") + return DEVICE + + +# ========== Convenience helpers ========== + + +def get_device() -> DeviceType: + """ + Return the detected device. Auto-detects if detect_hardware() hasn't been called yet. + Prefer calling detect_hardware() explicitly at startup instead. + """ + global DEVICE + if DEVICE is None: + detect_hardware() + return DEVICE + + +def clear_gpu_cache(): + """ + Clear GPU memory cache for the current device. + Safe to call on any platform — no-ops gracefully. + """ + import gc + + gc.collect() + + device = get_device() + + if device == DeviceType.CUDA: + import torch + + torch.cuda.synchronize() + torch.cuda.empty_cache() + torch.cuda.ipc_collect() + elif device == DeviceType.MLX: + # MLX manages memory automatically; no explicit cache clear needed. + # mlx.core has no empty_cache equivalent — gc.collect() above is enough. + pass + + +def get_gpu_memory_info() -> Dict[str, Any]: + """ + Get GPU memory information. + Supports CUDA (NVIDIA), MLX (Apple Silicon), and CPU-only environments. + """ + device = get_device() + + # ---- CUDA path ---- + if device == DeviceType.CUDA: + try: + import torch + + idx = torch.cuda.current_device() + props = torch.cuda.get_device_properties(idx) + + total = props.total_memory + allocated = torch.cuda.memory_allocated(idx) + reserved = torch.cuda.memory_reserved(idx) + + return { + "available": True, + "backend": device.value, + "device": idx, + "device_name": props.name, + "total_gb": total / (1024**3), + "allocated_gb": allocated / (1024**3), + "reserved_gb": reserved / (1024**3), + "free_gb": (total - allocated) / (1024**3), + "utilization_pct": (allocated / total) * 100, + } + except Exception as e: + logger.error(f"Error getting CUDA GPU info: {e}") + return {"available": False, "backend": device.value, "error": str(e)} + + # ---- MLX path (Apple Silicon) ---- + if device == DeviceType.MLX: + try: + import mlx.core as mx + import psutil + + # MLX uses unified memory — report system memory as the pool + total = psutil.virtual_memory().total + # MLX doesn't expose per-process GPU allocation; report 0 as allocated + allocated = 0 + + return { + "available": True, + "backend": device.value, + "device": 0, + "device_name": f"Apple Silicon ({platform.processor() or platform.machine()})", + "total_gb": total / (1024**3), + "allocated_gb": allocated / (1024**3), + "reserved_gb": 0, + "free_gb": (total - allocated) / (1024**3), + "utilization_pct": (allocated / total) * 100 if total else 0, + } + except Exception as e: + logger.error(f"Error getting MLX GPU info: {e}") + return {"available": False, "backend": device.value, "error": str(e)} + + # ---- CPU-only ---- + return {"available": False, "backend": "cpu"} + + +def log_gpu_memory(context: str): + """Log GPU memory usage with context.""" + memory_info = get_gpu_memory_info() + if memory_info.get("available"): + backend = memory_info.get("backend", "unknown").upper() + device_name = memory_info.get("device_name", "") + label = f"{backend}" + (f" ({device_name})" if device_name else "") + logger.info( + f"GPU Memory [{context}] {label}: " + f"{memory_info['allocated_gb']:.2f}GB/{memory_info['total_gb']:.2f}GB " + f"({memory_info['utilization_pct']:.1f}% used, " + f"{memory_info['free_gb']:.2f}GB free)" + ) + else: + logger.info(f"GPU Memory [{context}]: No GPU available (CPU-only)") + + +# ========== GPU Summary & Package Versions ========== + + +def get_gpu_summary() -> Dict[str, Any]: + """ + Return a compact summary of the primary GPU. + + Returns dict with keys: + gpu_name – e.g. "NVIDIA L4" (or None) + vram_total_gb – e.g. 22.17 (or None) + """ + mem = get_gpu_memory_info() + if mem.get("available"): + return { + "gpu_name": mem.get("device_name"), + "vram_total_gb": round(mem.get("total_gb", 0), 2), + } + return {"gpu_name": None, "vram_total_gb": None} + + +def get_package_versions() -> Dict[str, Optional[str]]: + """ + Return the installed versions of key ML packages. + + Uses importlib.metadata (stdlib) so no subprocess is needed. + CUDA version comes from torch.version.cuda. + + Returns dict with keys: unsloth, torch, transformers, cuda. + Missing packages yield None. + """ + from importlib.metadata import version as pkg_version, PackageNotFoundError + + packages = ("unsloth", "torch", "transformers") + versions: Dict[str, Optional[str]] = {} + + for name in packages: + try: + versions[name] = pkg_version(name) + except PackageNotFoundError: + versions[name] = None + + # CUDA toolkit version bundled with torch + try: + import torch + + versions["cuda"] = getattr(torch.version, "cuda", None) + except Exception: + versions["cuda"] = None + + return versions + + +# ========== Live GPU Utilization (nvidia-smi) ========== + + +def get_gpu_utilization() -> Dict[str, Any]: + """ + Return a live snapshot of GPU utilization via ``nvidia-smi``. + + Designed to be polled by the frontend during training (not streaming). + Uses ``nvidia-smi --query-gpu`` which is the most accurate source for + utilization %, temperature, and power draw – stats that PyTorch does + not expose. + + Returns dict with keys: + available – bool, whether stats could be retrieved + gpu_utilization_pct – GPU core utilization % + temperature_c – GPU temperature in °C + vram_used_gb – VRAM currently used (GiB) + vram_total_gb – VRAM total (GiB) + vram_utilization_pct – VRAM used / total * 100 + power_draw_w – current power draw (W) + power_limit_w – power limit (W) + power_utilization_pct – power draw / limit * 100 + """ + device = get_device() + + if device != DeviceType.CUDA: + return {"available": False, "backend": device.value} + + def _parse_smi_value(raw: str): + """Parse a single nvidia-smi CSV value. Returns float or None for [N/A].""" + raw = raw.strip() + if not raw or raw == "[N/A]": + return None + try: + return float(raw) + except (ValueError, TypeError): + return None + + # ── nvidia-smi (most complete source) ─────────────────────── + smi_data = {} + try: + import subprocess + + result = subprocess.run( + [ + "nvidia-smi", + "--query-gpu=utilization.gpu,temperature.gpu," + "memory.used,memory.total,power.draw,power.limit", + "--format=csv,noheader,nounits", + ], + capture_output = True, + text = True, + timeout = 5, + ) + + if result.returncode == 0 and result.stdout.strip(): + # nvidia-smi outputs one line per GPU; take GPU 0 + first_line = result.stdout.strip().splitlines()[0] + parts = [p.strip() for p in first_line.split(",")] + if len(parts) >= 6: + smi_data = { + "gpu_util": _parse_smi_value(parts[0]), + "temp": _parse_smi_value(parts[1]), + "vram_used_mb": _parse_smi_value(parts[2]), + "vram_total_mb": _parse_smi_value(parts[3]), + "power_draw": _parse_smi_value(parts[4]), + "power_limit": _parse_smi_value(parts[5]), + } + + except FileNotFoundError: + logger.debug("nvidia-smi not found, falling back to torch.cuda") + except Exception as e: + logger.warning(f"nvidia-smi query failed: {e}") + + # ── Backfill VRAM from torch.cuda if nvidia-smi returned [N/A] ── + vram_used_mb = smi_data.get("vram_used_mb") + vram_total_mb = smi_data.get("vram_total_mb") + + if vram_used_mb is None or vram_total_mb is None: + try: + import torch + + idx = torch.cuda.current_device() + props = torch.cuda.get_device_properties(idx) + if vram_total_mb is None: + vram_total_mb = props.total_memory / (1024**2) # bytes → MiB + if vram_used_mb is None: + vram_used_mb = torch.cuda.memory_allocated(idx) / (1024**2) + except Exception as e: + logger.debug(f"torch.cuda VRAM backfill failed: {e}") + + # ── Build response ────────────────────────────────────────── + gpu_util = smi_data.get("gpu_util") + temp = smi_data.get("temp") + power_draw = smi_data.get("power_draw") + power_limit = smi_data.get("power_limit") + + vram_used_gb = round(vram_used_mb / 1024, 2) if vram_used_mb is not None else None + vram_total_gb = ( + round(vram_total_mb / 1024, 2) if vram_total_mb is not None else None + ) + vram_pct = ( + round((vram_used_mb / vram_total_mb) * 100, 1) + if vram_used_mb is not None and vram_total_mb and vram_total_mb > 0 + else None + ) + power_pct = ( + round((power_draw / power_limit) * 100, 1) + if power_draw is not None and power_limit and power_limit > 0 + else None + ) + + # If we got at least something useful, report available + has_any = any(v is not None for v in [gpu_util, temp, vram_used_gb, power_draw]) + if not has_any: + return {"available": False, "backend": device.value} + + return { + "available": True, + "backend": device.value, + "gpu_utilization_pct": gpu_util, + "temperature_c": temp, + "vram_used_gb": vram_used_gb, + "vram_total_gb": vram_total_gb, + "vram_utilization_pct": vram_pct, + "power_draw_w": power_draw, + "power_limit_w": power_limit, + "power_utilization_pct": power_pct, + } + + +# ========== Multi-GPU Detection & Safe num_proc ========== + +_physical_gpu_count: Optional[int] = None + + +def get_physical_gpu_count() -> int: + """ + Return the number of physical NVIDIA GPUs on the machine. + + Uses ``nvidia-smi -L`` which is NOT affected by CUDA_VISIBLE_DEVICES, + so it always reflects the true hardware count. + Result is cached after the first call. + """ + global _physical_gpu_count + if _physical_gpu_count is not None: + return _physical_gpu_count + + try: + import subprocess + + result = subprocess.run( + ["nvidia-smi", "-L"], + capture_output = True, + text = True, + timeout = 5, + ) + if result.returncode == 0 and result.stdout.strip(): + _physical_gpu_count = len(result.stdout.strip().splitlines()) + else: + _physical_gpu_count = 1 + except Exception: + _physical_gpu_count = 1 + + return _physical_gpu_count + + +def safe_num_proc(desired: Optional[int] = None) -> int: + """ + Return a safe ``num_proc`` for ``dataset.map()`` calls. + + On Windows, always returns 1 because Python uses ``spawn`` instead of + ``fork`` for multiprocessing — the overhead of re-importing torch, + transformers, unsloth etc. per worker is typically slower than + single-process for normal dataset sizes. + + On multi-GPU machines the NVIDIA driver spawns extra background threads, + making ``os.fork()`` prone to deadlocks when many workers are created. + This helper caps ``num_proc`` to 4 on such machines. + + On single-GPU (or CPU-only) machines the original value is returned + unchanged. + + Args: + desired: The num_proc you *want*. If None, auto-computes from + ``os.cpu_count()``. + + Returns: + A safe integer ≥ 1. + """ + import os + import sys + + # Windows uses 'spawn' for multiprocessing — the overhead of re-importing + # torch/transformers/unsloth per worker is typically slower than single-process. + if sys.platform == "win32": + return 1 + + if desired is None or not isinstance(desired, int): + desired = max(1, os.cpu_count() // 3) + + if get_physical_gpu_count() > 1: + capped = min(4, desired) + logger.info( + f"⚙️ Multi-GPU detected ({get_physical_gpu_count()} GPUs) — " + f"capping num_proc {desired} → {capped} to avoid fork deadlocks" + ) + return capped + + return desired diff --git a/studio/backend/utils/inference/__init__.py b/studio/backend/utils/inference/__init__.py new file mode 100644 index 0000000000..9dc9c08767 --- /dev/null +++ b/studio/backend/utils/inference/__init__.py @@ -0,0 +1,10 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Inference utility functions +""" + +from utils.inference.inference_config import load_inference_config + +__all__ = ["load_inference_config"] diff --git a/studio/backend/utils/inference/inference_config.py b/studio/backend/utils/inference/inference_config.py new file mode 100644 index 0000000000..5b92efce6e --- /dev/null +++ b/studio/backend/utils/inference/inference_config.py @@ -0,0 +1,74 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Inference configuration loading utilities. + +This module provides functions to load inference parameters (temperature, top_p, top_k, min_p) +from model YAML configuration files, with fallback to default.yaml. +""" + +from pathlib import Path +from typing import Dict, Any +import yaml +import structlog +from loggers import get_logger + +from utils.models.model_config import load_model_defaults + +logger = get_logger(__name__) + + +def load_inference_config(model_identifier: str) -> Dict[str, Any]: + """ + Load inference configuration parameters for a model. + + This function loads inference parameters (temperature, top_p, top_k, min_p) from the + model's YAML configuration file using the same mapping logic as the /config endpoint. + If a parameter is missing from the model's config, it falls back to the value in + default.yaml. + + Args: + model_identifier: Model identifier (e.g., "unsloth/llama-3-8b-bnb-4bit") + + Returns: + Dictionary containing inference parameters: + { + "temperature": float, + "top_p": float, + "top_k": int, + "min_p": float + } + """ + # Load model defaults to get inference parameters + model_defaults = load_model_defaults(model_identifier) + + # Load default.yaml for fallback values + script_dir = Path(__file__).parent.parent.parent + defaults_dir = script_dir / "assets" / "configs" / "model_defaults" + default_config_path = defaults_dir / "default.yaml" + + default_inference = {} + if default_config_path.exists(): + try: + with open(default_config_path, "r", encoding = "utf-8") as f: + default_config = yaml.safe_load(f) or {} + default_inference = default_config.get("inference", {}) + except Exception as e: + logger.warning(f"Failed to load default.yaml: {e}") + + # Extract inference parameters from model config, fallback to defaults + model_inference = model_defaults.get("inference", {}) + inference_config = { + "temperature": model_inference.get( + "temperature", default_inference.get("temperature", 0.7) + ), + "top_p": model_inference.get("top_p", default_inference.get("top_p", 0.95)), + "top_k": model_inference.get("top_k", default_inference.get("top_k", -1)), + "min_p": model_inference.get("min_p", default_inference.get("min_p", 0.01)), + "trust_remote_code": model_inference.get( + "trust_remote_code", default_inference.get("trust_remote_code", False) + ), + } + + return inference_config diff --git a/studio/backend/utils/models/__init__.py b/studio/backend/utils/models/__init__.py new file mode 100644 index 0000000000..82236d8013 --- /dev/null +++ b/studio/backend/utils/models/__init__.py @@ -0,0 +1,44 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Model and LoRA configuration handling +""" + +from .model_config import ( + ModelConfig, + GgufVariantInfo, + is_vision_model, + is_embedding_model, + detect_audio_type, + is_audio_input_type, + VALID_AUDIO_TYPES, + scan_trained_loras, + scan_exported_models, + load_model_defaults, + get_base_model_from_lora, + load_model_config, + list_gguf_variants, + MODEL_NAME_MAPPING, + UI_STATUS_INDICATORS, +) +from .checkpoints import scan_checkpoints + +__all__ = [ + "ModelConfig", + "GgufVariantInfo", + "is_vision_model", + "is_embedding_model", + "detect_audio_type", + "is_audio_input_type", + "VALID_AUDIO_TYPES", + "scan_trained_loras", + "scan_exported_models", + "load_model_defaults", + "get_base_model_from_lora", + "load_model_config", + "list_gguf_variants", + "MODEL_NAME_MAPPING", + "UI_STATUS_INDICATORS", + "scan_checkpoints", +] diff --git a/studio/backend/utils/models/checkpoints.py b/studio/backend/utils/models/checkpoints.py new file mode 100644 index 0000000000..a7cb80f338 --- /dev/null +++ b/studio/backend/utils/models/checkpoints.py @@ -0,0 +1,134 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Checkpoint scanning utilities for discovering training runs and their checkpoints. +""" + +import json +import structlog +from loggers import get_logger +from pathlib import Path +from typing import List, Optional, Tuple +from utils.paths import outputs_root, resolve_output_dir + +logger = get_logger(__name__) + + +def _read_checkpoint_loss(checkpoint_path: Path) -> Optional[float]: + """ + Read the training loss from a checkpoint's trainer_state.json. + + Returns the loss from the last log_history entry, or None if unavailable. + """ + trainer_state = checkpoint_path / "trainer_state.json" + if not trainer_state.exists(): + return None + try: + with open(trainer_state) as f: + state = json.load(f) + log_history = state.get("log_history", []) + if log_history: + return log_history[-1].get("loss") + except Exception as e: + logger.debug(f"Could not read loss from {trainer_state}: {e}") + return None + + +def scan_checkpoints( + outputs_dir: str = str(outputs_root()), +) -> List[Tuple[str, List[Tuple[str, str, Optional[float]]], dict]]: + """ + Scan outputs folder for training runs and their checkpoints. + + Returns: + List of tuples: [(model_name, [(display_name, checkpoint_path, loss), ...], metadata), ...] + metadata keys: base_model, peft_type, lora_rank (all optional) + The first entry in each checkpoint list is the main adapter; its loss is + set to the loss of the last (highest-step) intermediate checkpoint. + """ + models = [] + outputs_path = resolve_output_dir(outputs_dir) + + if not outputs_path.exists(): + logger.warning(f"Outputs directory not found: {outputs_dir}") + return models + + try: + for item in outputs_path.iterdir(): + if not item.is_dir(): + continue + + config_file = item / "config.json" + adapter_config = item / "adapter_config.json" + + if not (config_file.exists() or adapter_config.exists()): + continue + + # Extract training metadata from adapter_config.json / config.json + metadata: dict = {} + try: + if adapter_config.exists(): + cfg = json.loads(adapter_config.read_text()) + metadata["base_model"] = cfg.get("base_model_name_or_path") + metadata["peft_type"] = cfg.get("peft_type") + metadata["lora_rank"] = cfg.get("r") + elif config_file.exists(): + cfg = json.loads(config_file.read_text()) + metadata["base_model"] = cfg.get("_name_or_path") + except Exception: + pass + + # Fallback: extract base model name from folder name + # e.g. "unsloth_Llama-3.2-3B-Instruct_1771227800" → "unsloth/Llama-3.2-3B-Instruct" + if not metadata.get("base_model"): + parts = item.name.rsplit("_", 1) + if len(parts) == 2 and parts[1].isdigit(): + name_part = parts[0] + idx = name_part.find("_") + if idx > 0: + metadata["base_model"] = ( + name_part[:idx] + "/" + name_part[idx + 1 :] + ) + else: + metadata["base_model"] = name_part + + # This is a valid training run + checkpoints = [] + + # Placeholder for the main adapter — loss filled from last checkpoint below + checkpoints.append((item.name, str(item), None)) + + # Scan for intermediate checkpoints (checkpoint-N subdirs) + for sub in sorted(item.iterdir()): + if not sub.is_dir() or not sub.name.startswith("checkpoint-"): + continue + sub_config = sub / "config.json" + sub_adapter = sub / "adapter_config.json" + if sub_config.exists() or sub_adapter.exists(): + loss = _read_checkpoint_loss(sub) + checkpoints.append((sub.name, str(sub), loss)) + + # Assign the last checkpoint's loss to the main adapter entry + if len(checkpoints) > 1: + last_checkpoint_loss = checkpoints[-1][2] + checkpoints[0] = ( + checkpoints[0][0], + checkpoints[0][1], + last_checkpoint_loss, + ) + + models.append((item.name, checkpoints, metadata)) + logger.debug( + f"Found model: {item.name} with {len(checkpoints)} checkpoint(s)" + ) + + # Sort by modification time (newest first) + models.sort(key = lambda x: Path(x[1][0][1]).stat().st_mtime, reverse = True) + + logger.info(f"Found {len(models)} training runs in {outputs_dir}") + return models + + except Exception as e: + logger.error(f"Error scanning checkpoints: {e}") + return [] diff --git a/studio/backend/utils/models/model_config.py b/studio/backend/utils/models/model_config.py new file mode 100644 index 0000000000..960e263cb2 --- /dev/null +++ b/studio/backend/utils/models/model_config.py @@ -0,0 +1,1733 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Model and LoRA configuration handling +""" + +from transformers import AutoConfig +from dataclasses import dataclass +from typing import Optional, Dict, Any +from utils.paths import ( + normalize_path, + is_local_path, + is_model_cached, + outputs_root, + exports_root, + resolve_output_dir, + resolve_export_dir, +) +from utils.utils import without_hf_auth +import structlog +from loggers import get_logger +import os +import subprocess +import sys +from pathlib import Path +from typing import List, Tuple +import json +import yaml + + +logger = get_logger(__name__) + +# Model name mapping: maps all equivalent model names to their canonical YAML config file +# Format: "canonical_model_name.yaml": [list of all equivalent model names] +# Based on the model mapper provided - canonical filename is based on the first model name in the mapper +MODEL_NAME_MAPPING = { + # ── Embedding models ── + "unsloth_all-MiniLM-L6-v2.yaml": [ + "unsloth/all-MiniLM-L6-v2", + "sentence-transformers/all-MiniLM-L6-v2", + ], + "unsloth_bge-m3.yaml": [ + "unsloth/bge-m3", + "BAAI/bge-m3", + ], + "unsloth_embeddinggemma-300m.yaml": [ + "unsloth/embeddinggemma-300m", + "google/embeddinggemma-300m", + ], + "unsloth_gte-modernbert-base.yaml": [ + "unsloth/gte-modernbert-base", + "Alibaba-NLP/gte-modernbert-base", + ], + "unsloth_Qwen3-Embedding-0.6B.yaml": [ + "unsloth/Qwen3-Embedding-0.6B", + "Qwen/Qwen3-Embedding-0.6B", + "unsloth/Qwen3-Embedding-4B", + "Qwen/Qwen3-Embedding-4B", + ], + # ── Other models ── + "unsloth_answerdotai_ModernBERT-large.yaml": [ + "answerdotai/ModernBERT-large", + ], + "unsloth_Qwen2.5-Coder-7B-Instruct-bnb-4bit.yaml": [ + "unsloth/Qwen2.5-Coder-7B-Instruct-bnb-4bit", + "unsloth/Qwen2.5-Coder-7B-Instruct", + "Qwen/Qwen2.5-Coder-7B-Instruct", + ], + "unsloth_codegemma-7b-bnb-4bit.yaml": [ + "unsloth/codegemma-7b-bnb-4bit", + "unsloth/codegemma-7b", + "google/codegemma-7b", + ], + "unsloth_ERNIE-4.5-21B-A3B-PT.yaml": [ + "unsloth/ERNIE-4.5-21B-A3B-PT", + ], + "unsloth_ERNIE-4.5-VL-28B-A3B-PT.yaml": [ + "unsloth/ERNIE-4.5-VL-28B-A3B-PT", + ], + "tiiuae_Falcon-H1-0.5B-Instruct.yaml": [ + "tiiuae/Falcon-H1-0.5B-Instruct", + "unsloth/Falcon-H1-0.5B-Instruct", + ], + "unsloth_functiongemma-270m-it.yaml": [ + "unsloth/functiongemma-270m-it-unsloth-bnb-4bit", + "google/functiongemma-270m-it", + "unsloth/functiongemma-270m-it-unsloth-bnb-4bit", + ], + "unsloth_gemma-2-2b.yaml": [ + "unsloth/gemma-2-2b-bnb-4bit", + "google/gemma-2-2b", + ], + "unsloth_gemma-2-27b-bnb-4bit.yaml": [ + "unsloth/gemma-2-9b-bnb-4bit", + "unsloth/gemma-2-9b", + "google/gemma-2-9b", + "unsloth/gemma-2-27b", + "google/gemma-2-27b", + ], + "unsloth_gemma-3-4b-pt.yaml": [ + "unsloth/gemma-3-4b-pt-unsloth-bnb-4bit", + "google/gemma-3-4b-pt", + "unsloth/gemma-3-4b-pt-bnb-4bit", + ], + "unsloth_gemma-3-4b-it.yaml": [ + "unsloth/gemma-3-4b-it-unsloth-bnb-4bit", + "google/gemma-3-4b-it", + "unsloth/gemma-3-4b-it-bnb-4bit", + ], + "unsloth_gemma-3-27b-it.yaml": [ + "unsloth/gemma-3-27b-it-unsloth-bnb-4bit", + "google/gemma-3-27b-it", + "unsloth/gemma-3-27b-it-bnb-4bit", + ], + "unsloth_gemma-3-270m-it.yaml": [ + "unsloth/gemma-3-270m-it-unsloth-bnb-4bit", + "google/gemma-3-270m-it", + "unsloth/gemma-3-270m-it-bnb-4bit", + ], + "unsloth_gemma-3n-E4B-it.yaml": [ + "unsloth/gemma-3n-E4B-it-unsloth-bnb-4bit", + "google/gemma-3n-E4B-it", + "unsloth/gemma-3n-E4B-it-unsloth-bnb-4bit", + ], + "unsloth_gemma-3n-E4B.yaml": [ + "unsloth/gemma-3n-E4B-unsloth-bnb-4bit", + "google/gemma-3n-E4B", + ], + "unsloth_gpt-oss-20b.yaml": [ + "openai/gpt-oss-20b", + "unsloth/gpt-oss-20b-unsloth-bnb-4bit", + "unsloth/gpt-oss-20b-BF16", + ], + "unsloth_gpt-oss-120b.yaml": [ + "openai/gpt-oss-120b", + "unsloth/gpt-oss-120b-unsloth-bnb-4bit", + ], + "unsloth_granite-4.0-350m-unsloth-bnb-4bit.yaml": [ + "unsloth/granite-4.0-350m", + "ibm-granite/granite-4.0-350m", + "unsloth/granite-4.0-350m-bnb-4bit", + ], + "unsloth_granite-4.0-h-micro.yaml": [ + "ibm-granite/granite-4.0-h-micro", + "unsloth/granite-4.0-h-micro-bnb-4bit", + "unsloth/granite-4.0-h-micro-unsloth-bnb-4bit", + ], + "unsloth_LFM2-1.2B.yaml": [ + "unsloth/LFM2-1.2B", + ], + "unsloth_llama-3-8b-bnb-4bit.yaml": [ + "unsloth/llama-3-8b", + "meta-llama/Meta-Llama-3-8B", + ], + "unsloth_llama-3-8b-Instruct-bnb-4bit.yaml": [ + "unsloth/llama-3-8b-Instruct", + "meta-llama/Meta-Llama-3-8B-Instruct", + ], + "unsloth_Meta-Llama-3.1-70B-bnb-4bit.yaml": [ + "unsloth/Meta-Llama-3.1-8B-bnb-4bit", + "unsloth/Meta-Llama-3.1-8B-unsloth-bnb-4bit", + "meta-llama/Meta-Llama-3.1-8B", + "unsloth/Meta-Llama-3.1-70B-bnb-4bit", + "unsloth/Meta-Llama-3.1-8B", + "unsloth/Meta-Llama-3.1-70B", + "meta-llama/Meta-Llama-3.1-70B", + "unsloth/Meta-Llama-3.1-405B-bnb-4bit", + "meta-llama/Meta-Llama-3.1-405B", + ], + "unsloth_Meta-Llama-3.1-8B-Instruct-bnb-4bit.yaml": [ + "unsloth/Meta-Llama-3.1-8B-Instruct-unsloth-bnb-4bit", + "unsloth/Meta-Llama-3.1-8B-Instruct-bnb-4bit", + "meta-llama/Meta-Llama-3.1-8B-Instruct", + "unsloth/Meta-Llama-3.1-8B-Instruct", + "RedHatAI/Llama-3.1-8B-Instruct-FP8", + "unsloth/Llama-3.1-8B-Instruct-FP8-Block", + "unsloth/Llama-3.1-8B-Instruct-FP8-Dynamic", + ], + "unsloth_Llama-3.2-3B-Instruct.yaml": [ + "unsloth/Llama-3.2-3B-Instruct-unsloth-bnb-4bit", + "meta-llama/Llama-3.2-3B-Instruct", + "unsloth/Llama-3.2-3B-Instruct-bnb-4bit", + "RedHatAI/Llama-3.2-3B-Instruct-FP8", + "unsloth/Llama-3.2-3B-Instruct-FP8-Block", + "unsloth/Llama-3.2-3B-Instruct-FP8-Dynamic", + ], + "unsloth_Llama-3.2-1B-Instruct.yaml": [ + "unsloth/Llama-3.2-1B-Instruct-unsloth-bnb-4bit", + "meta-llama/Llama-3.2-1B-Instruct", + "unsloth/Llama-3.2-1B-Instruct-bnb-4bit", + "RedHatAI/Llama-3.2-1B-Instruct-FP8", + "unsloth/Llama-3.2-1B-Instruct-FP8-Block", + "unsloth/Llama-3.2-1B-Instruct-FP8-Dynamic", + ], + "unsloth_Llama-3.2-11B-Vision-Instruct.yaml": [ + "unsloth/Llama-3.2-11B-Vision-Instruct-unsloth-bnb-4bit", + "meta-llama/Llama-3.2-11B-Vision-Instruct", + "unsloth/Llama-3.2-11B-Vision-Instruct-bnb-4bit", + ], + "unsloth_Llama-3.3-70B-Instruct.yaml": [ + "unsloth/Llama-3.3-70B-Instruct-unsloth-bnb-4bit", + "meta-llama/Llama-3.3-70B-Instruct", + "unsloth/Llama-3.3-70B-Instruct-bnb-4bit", + "RedHatAI/Llama-3.3-70B-Instruct-FP8", + "unsloth/Llama-3.3-70B-Instruct-FP8-Block", + "unsloth/Llama-3.3-70B-Instruct-FP8-Dynamic", + ], + "unsloth_Llasa-3B.yaml": [ + "HKUSTAudio/Llasa-1B", + "unsloth/Llasa-3B", + ], + "unsloth_Magistral-Small-2509-unsloth-bnb-4bit.yaml": [ + "unsloth/Magistral-Small-2509", + "mistralai/Magistral-Small-2509", + "unsloth/Magistral-Small-2509-bnb-4bit", + ], + "unsloth_Ministral-3-3B-Instruct-2512.yaml": [ + "unsloth/Ministral-3-3B-Instruct-2512", + ], + "unsloth_mistral-7b-v0.3-bnb-4bit.yaml": [ + "unsloth/mistral-7b-v0.3-bnb-4bit", + "unsloth/mistral-7b-v0.3", + "mistralai/Mistral-7B-v0.3", + ], + "unsloth_Mistral-Nemo-Base-2407-bnb-4bit.yaml": [ + "unsloth/Mistral-Nemo-Base-2407-bnb-4bit", + "unsloth/Mistral-Nemo-Base-2407", + "mistralai/Mistral-Nemo-Base-2407", + "unsloth/Mistral-Nemo-Instruct-2407-bnb-4bit", + "unsloth/Mistral-Nemo-Instruct-2407", + "mistralai/Mistral-Nemo-Instruct-2407", + ], + "unsloth_Mistral-Small-Instruct-2409.yaml": [ + "unsloth/Mistral-Small-Instruct-2409-bnb-4bit", + "mistralai/Mistral-Small-Instruct-2409", + ], + "unsloth_mistral-7b-instruct-v0.3-bnb-4bit.yaml": [ + "unsloth/mistral-7b-instruct-v0.3-bnb-4bit", + "unsloth/mistral-7b-instruct-v0.3", + "mistralai/Mistral-7B-Instruct-v0.3", + ], + "unsloth_Qwen2.5-1.5B-Instruct.yaml": [ + "unsloth/Qwen2.5-1.5B-Instruct-unsloth-bnb-4bit", + "Qwen/Qwen2.5-1.5B-Instruct", + "unsloth/Qwen2.5-1.5B-Instruct-bnb-4bit", + ], + "unsloth_Nemotron-3-Nano-30B-A3B.yaml": [ + "unsloth/Nemotron-3-Nano-30B-A3B", + ], + "unsloth_orpheus-3b-0.1-ft.yaml": [ + "unsloth/orpheus-3b-0.1-ft", + "unsloth/orpheus-3b-0.1-ft-unsloth-bnb-4bit", + "canopylabs/orpheus-3b-0.1-ft", + "unsloth/orpheus-3b-0.1-ft-bnb-4bit", + ], + "OuteAI_Llama-OuteTTS-1.0-1B.yaml": [ + "OuteAI/Llama-OuteTTS-1.0-1B", + "unsloth/Llama-OuteTTS-1.0-1B", + "unsloth/llama-outetts-1.0-1b", + "OuteAI/OuteTTS-1.0-0.6B", + "unsloth/OuteTTS-1.0-0.6B", + "unsloth/outetts-1.0-0.6b", + ], + "unsloth_PaddleOCR-VL.yaml": [ + "unsloth/PaddleOCR-VL", + ], + "unsloth_Phi-3-medium-4k-instruct.yaml": [ + "unsloth/Phi-3-medium-4k-instruct-bnb-4bit", + "microsoft/Phi-3-medium-4k-instruct", + ], + "unsloth_Phi-3.5-mini-instruct.yaml": [ + "unsloth/Phi-3.5-mini-instruct-bnb-4bit", + "microsoft/Phi-3.5-mini-instruct", + ], + "unsloth_Phi-4.yaml": [ + "unsloth/phi-4-unsloth-bnb-4bit", + "microsoft/phi-4", + "unsloth/phi-4-bnb-4bit", + ], + "unsloth_Pixtral-12B-2409.yaml": [ + "unsloth/Pixtral-12B-2409-unsloth-bnb-4bit", + "mistralai/Pixtral-12B-2409", + "unsloth/Pixtral-12B-2409-bnb-4bit", + ], + "unsloth_Qwen2-7B.yaml": [ + "unsloth/Qwen2-7B-bnb-4bit", + "Qwen/Qwen2-7B", + ], + "unsloth_Qwen2-VL-7B-Instruct.yaml": [ + "unsloth/Qwen2-VL-7B-Instruct-unsloth-bnb-4bit", + "Qwen/Qwen2-VL-7B-Instruct", + "unsloth/Qwen2-VL-7B-Instruct-bnb-4bit", + ], + "unsloth_Qwen2.5-7B.yaml": [ + "unsloth/Qwen2.5-7B-unsloth-bnb-4bit", + "Qwen/Qwen2.5-7B", + "unsloth/Qwen2.5-7B-bnb-4bit", + ], + "unsloth_Qwen2.5-Coder-1.5B-Instruct.yaml": [ + "unsloth/Qwen2.5-Coder-1.5B-Instruct-bnb-4bit", + "Qwen/Qwen2.5-Coder-1.5B-Instruct", + ], + "unsloth_Qwen2.5-Coder-14B-Instruct.yaml": [ + "unsloth/Qwen2.5-Coder-14B-Instruct-bnb-4bit", + "Qwen/Qwen2.5-Coder-14B-Instruct", + ], + "unsloth_Qwen2.5-VL-7B-Instruct-bnb-4bit.yaml": [ + "unsloth/Qwen2.5-VL-7B-Instruct", + "Qwen/Qwen2.5-VL-7B-Instruct", + "unsloth/Qwen2.5-VL-7B-Instruct-unsloth-bnb-4bit", + ], + "unsloth_Qwen3-0.6B.yaml": [ + "unsloth/Qwen3-0.6B-unsloth-bnb-4bit", + "Qwen/Qwen3-0.6B", + "unsloth/Qwen3-0.6B-bnb-4bit", + "Qwen/Qwen3-0.6B-FP8", + "unsloth/Qwen3-0.6B-FP8", + ], + "unsloth_Qwen3-4B-Instruct-2507.yaml": [ + "unsloth/Qwen3-4B-Instruct-2507-unsloth-bnb-4bit", + "Qwen/Qwen3-4B-Instruct-2507", + "unsloth/Qwen3-4B-Instruct-2507-bnb-4bit", + "Qwen/Qwen3-4B-Instruct-2507-FP8", + "unsloth/Qwen3-4B-Instruct-2507-FP8", + ], + "unsloth_Qwen3-4B-Thinking-2507.yaml": [ + "unsloth/Qwen3-4B-Thinking-2507-unsloth-bnb-4bit", + "Qwen/Qwen3-4B-Thinking-2507", + "unsloth/Qwen3-4B-Thinking-2507-bnb-4bit", + "Qwen/Qwen3-4B-Thinking-2507-FP8", + "unsloth/Qwen3-4B-Thinking-2507-FP8", + ], + "unsloth_Qwen3-14B-Base-unsloth-bnb-4bit.yaml": [ + "unsloth/Qwen3-14B-Base", + "Qwen/Qwen3-14B-Base", + "unsloth/Qwen3-14B-Base-bnb-4bit", + ], + "unsloth_Qwen3-14B.yaml": [ + "unsloth/Qwen3-14B-unsloth-bnb-4bit", + "Qwen/Qwen3-14B", + "unsloth/Qwen3-14B-bnb-4bit", + "Qwen/Qwen3-14B-FP8", + "unsloth/Qwen3-14B-FP8", + ], + "unsloth_Qwen3-32B.yaml": [ + "unsloth/Qwen3-32B-unsloth-bnb-4bit", + "Qwen/Qwen3-32B", + "unsloth/Qwen3-32B-bnb-4bit", + "Qwen/Qwen3-32B-FP8", + "unsloth/Qwen3-32B-FP8", + ], + "unsloth_Qwen3-VL-8B-Instruct-unsloth-bnb-4bit.yaml": [ + "Qwen/Qwen3-VL-8B-Instruct-FP8", + "unsloth/Qwen3-VL-8B-Instruct-FP8", + "unsloth/Qwen3-VL-8B-Instruct", + "Qwen/Qwen3-VL-8B-Instruct", + "unsloth/Qwen3-VL-8B-Instruct-bnb-4bit", + ], + "sesame_csm-1b.yaml": [ + "sesame/csm-1b", + "unsloth/csm-1b", + ], + "Spark-TTS-0.5B_LLM.yaml": [ + "Spark-TTS-0.5B/LLM", + "unsloth/Spark-TTS-0.5B", + ], + "unsloth_tinyllama-bnb-4bit.yaml": [ + "unsloth/tinyllama", + "TinyLlama/TinyLlama-1.1B-intermediate-step-1431k-3T", + ], + "unsloth_whisper-large-v3.yaml": [ + "unsloth/whisper-large-v3", + "openai/whisper-large-v3", + ], +} + +# Reverse mapping for quick lookup: model_name -> canonical_filename +_REVERSE_MODEL_MAPPING = {} +for canonical_file, model_names in MODEL_NAME_MAPPING.items(): + for model_name in model_names: + _REVERSE_MODEL_MAPPING[model_name.lower()] = canonical_file + + +def load_model_config( + model_name: str, use_auth: bool = False, token: Optional[str] = None +): + """ + Load model config with optional authentication control. + """ + + if token: + # Explicit token provided - use it + return AutoConfig.from_pretrained( + model_name, trust_remote_code = True, token = token + ) + + if not use_auth: + # Load without any authentication (for public model checks) + with without_hf_auth(): + return AutoConfig.from_pretrained( + model_name, trust_remote_code = True, token = None + ) + + # Use default authentication (cached tokens) + return AutoConfig.from_pretrained(model_name, trust_remote_code = True) + + +# VLM architecture suffixes and known VLM model_type values. +_VLM_ARCH_SUFFIXES = ("ForConditionalGeneration", "ForVisionText2Text") +_VLM_MODEL_TYPES = { + "phi3_v", + "llava", + "llava_next", + "llava_onevision", + "internvl_chat", + "cogvlm2", + "minicpmv", +} + +# Pre-computed .venv_t5 path and backend dir for subprocess version switching. +_VENV_T5_DIR = str(Path.home() / ".unsloth" / "studio" / ".venv_t5") +_BACKEND_DIR = str(Path(__file__).resolve().parent.parent.parent) + +# Inline script executed in a subprocess with transformers 5.x activated. +# Receives model_name and token via argv, prints JSON result to stdout. +_VISION_CHECK_SCRIPT = r""" +import sys, os, json +os.environ["TOKENIZERS_PARALLELISM"] = "false" + +# Activate transformers 5.x +venv_t5 = sys.argv[1] +backend_dir = sys.argv[2] +model_name = sys.argv[3] +token = sys.argv[4] if len(sys.argv) > 4 and sys.argv[4] != "" else None + +sys.path.insert(0, venv_t5) +if backend_dir not in sys.path: + sys.path.insert(0, backend_dir) + +try: + from transformers import AutoConfig + kwargs = {"trust_remote_code": True} + if token: + kwargs["token"] = token + config = AutoConfig.from_pretrained(model_name, **kwargs) + + is_vlm = False + if hasattr(config, "architectures"): + is_vlm = any( + x.endswith(("ForConditionalGeneration", "ForVisionText2Text")) + for x in config.architectures + ) + if not is_vlm and hasattr(config, "vision_config"): + is_vlm = True + if not is_vlm and hasattr(config, "img_processor"): + is_vlm = True + if not is_vlm and hasattr(config, "image_token_index"): + is_vlm = True + if not is_vlm and hasattr(config, "model_type"): + vlm_types = {"phi3_v","llava","llava_next","llava_onevision", + "internvl_chat","cogvlm2","minicpmv"} + if config.model_type in vlm_types: + is_vlm = True + + model_type = getattr(config, "model_type", "unknown") + archs = getattr(config, "architectures", []) + logger.info(json.dumps({"is_vision": is_vlm, "model_type": model_type, + "architectures": archs})) +except Exception as exc: + logger.info(json.dumps({"error": str(exc)})) + sys.exit(1) +""" + + +def _is_vision_model_subprocess( + model_name: str, hf_token: Optional[str] = None +) -> bool: + """Run is_vision_model check in a subprocess with transformers 5.x. + + Same pattern as training/inference workers: spawn a clean subprocess + with .venv_t5/ prepended to sys.path so AutoConfig recognizes newer + architectures (glm4_moe_lite, etc.). + """ + token_arg = hf_token or "" + + try: + result = subprocess.run( + [ + sys.executable, + "-c", + _VISION_CHECK_SCRIPT, + _VENV_T5_DIR, + _BACKEND_DIR, + model_name, + token_arg, + ], + capture_output = True, + text = True, + timeout = 60, + ) + + if result.returncode != 0: + stderr = result.stderr.strip() + logger.warning( + "Vision check subprocess failed for '%s': %s", + model_name, + stderr or result.stdout.strip(), + ) + return False + + data = json.loads(result.stdout.strip()) + if "error" in data: + logger.warning( + "Vision check subprocess error for '%s': %s", + model_name, + data["error"], + ) + return False + + is_vlm = data["is_vision"] + logger.info( + "Vision check (subprocess, transformers 5.x) for '%s': " + "model_type=%s, architectures=%s, is_vision=%s", + model_name, + data.get("model_type"), + data.get("architectures"), + is_vlm, + ) + return is_vlm + + except subprocess.TimeoutExpired: + logger.warning("Vision check subprocess timed out for '%s'", model_name) + return False + except Exception as exc: + logger.warning("Vision check subprocess failed for '%s': %s", model_name, exc) + return False + + +def is_vision_model(model_name: str, hf_token: Optional[str] = None) -> bool: + """ + Detect vision-language models (VLMs) by checking architecture in config. + Works for fine-tuned models since they inherit the base architecture. + + For models that require transformers 5.x (e.g. GLM-4.7-Flash), the check + runs in a subprocess with .venv_t5/ activated — same pattern as the + training and inference workers. + + Args: + model_name: Model identifier (HF repo or local path) + hf_token: Optional HF token for accessing gated/private models + """ + # Models that need transformers 5.x must be checked in a subprocess + # because AutoConfig in the main process (transformers 4.57.x) doesn't + # recognize their architectures. + from utils.transformers_version import needs_transformers_5 + + if needs_transformers_5(model_name): + logger.info( + "Model '%s' needs transformers 5.x — checking vision via subprocess", + model_name, + ) + return _is_vision_model_subprocess(model_name, hf_token = hf_token) + + try: + config = load_model_config(model_name, use_auth = True, token = hf_token) + + # Exclude audio-only models that share ForConditionalGeneration suffix + # (e.g. CsmForConditionalGeneration, WhisperForConditionalGeneration) + _audio_only_model_types = {"csm", "whisper"} + model_type = getattr(config, "model_type", None) + if model_type in _audio_only_model_types: + return False + + # Check 1: Architecture class name patterns + if hasattr(config, "architectures"): + is_vlm = any(x.endswith(_VLM_ARCH_SUFFIXES) for x in config.architectures) + if is_vlm: + logger.info( + f"Model {model_name} detected as VLM: architecture {config.architectures}" + ) + return True + + # Check 2: Has vision_config (most VLMs: LLaVA, Gemma-3, Qwen2-VL, etc.) + if hasattr(config, "vision_config"): + logger.info(f"Model {model_name} detected as VLM: has vision_config") + return True + + # Check 3: Has img_processor (Phi-3.5 Vision uses this instead of vision_config) + if hasattr(config, "img_processor"): + logger.info(f"Model {model_name} detected as VLM: has img_processor") + return True + + # Check 4: Has image_token_index (common in VLMs for image placeholder tokens) + if hasattr(config, "image_token_index"): + logger.info(f"Model {model_name} detected as VLM: has image_token_index") + return True + + # Check 5: Known VLM model_type values that may not match above checks + if hasattr(config, "model_type"): + if config.model_type in _VLM_MODEL_TYPES: + logger.info( + f"Model {model_name} detected as VLM: model_type={config.model_type}" + ) + return True + + return False + + except Exception as e: + logger.warning(f"Could not determine if {model_name} is vision model: {e}") + return False + + +VALID_AUDIO_TYPES = ("snac", "csm", "bicodec", "dac", "whisper", "audio_vlm") + +# Cache detection results per session to avoid repeated API calls +_audio_detection_cache: Dict[str, Optional[str]] = {} + +# Tokenizer token patterns → audio_type (all 6 types detected from tokenizer_config.json) +_AUDIO_TOKEN_PATTERNS = { + "csm": lambda tokens: "<|AUDIO|>" in tokens and "<|audio_eos|>" in tokens, + "whisper": lambda tokens: "<|startoftranscript|>" in tokens, + "audio_vlm": lambda tokens: "" in tokens, + "bicodec": lambda tokens: any(t.startswith("<|bicodec_") for t in tokens), + "dac": lambda tokens: "<|audio_start|>" in tokens and "<|audio_end|>" in tokens, + "snac": lambda tokens: sum(1 for t in tokens if t.startswith(" 10000, +} + + +def detect_audio_type(model_name: str, hf_token: Optional[str] = None) -> Optional[str]: + """ + Dynamically detect if a model is an audio model and return its type. + + Fully dynamic — works for any model, not just known ones. + Uses tokenizer_config.json special tokens to detect all 6 audio types. + + Returns: audio_type string ('snac', 'csm', 'bicodec', 'dac', 'whisper', 'audio_vlm') or None. + """ + if model_name in _audio_detection_cache: + return _audio_detection_cache[model_name] + + result = _detect_audio_from_tokenizer(model_name, hf_token) + + _audio_detection_cache[model_name] = result + if result: + logger.info(f"Model {model_name} detected as audio model: audio_type={result}") + return result + + +def _detect_audio_from_tokenizer( + model_name: str, hf_token: Optional[str] = None +) -> Optional[str]: + """Detect audio type from tokenizer special tokens (for LLM-based audio models). + + First checks local HF cache, then fetches tokenizer_config.json from HuggingFace. + Checks added_tokens_decoder for distinctive patterns. + """ + + def _check_token_patterns(tok_config: dict) -> Optional[str]: + added = tok_config.get("added_tokens_decoder", {}) + if not added: + return None + token_contents = [v.get("content", "") for v in added.values()] + for audio_type, check_fn in _AUDIO_TOKEN_PATTERNS.items(): + if check_fn(token_contents): + return audio_type + return None + + # 1) Check local HF cache first (works for gated/offline models) + try: + from huggingface_hub.constants import HF_HUB_CACHE + + cache_dir = Path(HF_HUB_CACHE) + repo_dir_name = f"models--{model_name.replace('/', '--')}" + repo_dir = cache_dir / repo_dir_name + if repo_dir.exists(): + snapshots_dir = repo_dir / "snapshots" + if snapshots_dir.exists(): + for snapshot in snapshots_dir.iterdir(): + for tok_path in [ + "tokenizer_config.json", + "LLM/tokenizer_config.json", + ]: + tok_file = snapshot / tok_path + if tok_file.exists(): + tok_config = json.loads(tok_file.read_text()) + result = _check_token_patterns(tok_config) + if result: + return result + except Exception as e: + logger.debug(f"Could not check local cache for {model_name}: {e}") + + # 2) Fall back to HuggingFace API + try: + import requests + import os + + paths_to_try = ["tokenizer_config.json", "LLM/tokenizer_config.json"] + # Use provided token, or fall back to env + token = hf_token or os.environ.get("HF_TOKEN") + headers = {} + if token: + headers["Authorization"] = f"Bearer {token}" + + for tok_path in paths_to_try: + url = f"https://huggingface.co/{model_name}/resolve/main/{tok_path}" + resp = requests.get(url, headers = headers, timeout = 15) + if not resp.ok: + continue + + tok_config = resp.json() + result = _check_token_patterns(tok_config) + if result: + return result + + return None + except Exception as e: + logger.debug( + f"Could not detect audio type from tokenizer for {model_name}: {e}" + ) + return None + + +def is_audio_input_type(audio_type: Optional[str]) -> bool: + """Check if an audio_type accepts audio input (ASR/speech understanding). + + Whisper (ASR) and audio_vlm (Gemma3n) accept audio input. + """ + return audio_type in ("whisper", "audio_vlm") + + +def _is_mmproj(filename: str) -> bool: + """Check if a GGUF filename is a vision projection (mmproj) file.""" + return "mmproj" in filename.lower() + + +def detect_mmproj_file(path: str) -> Optional[str]: + """ + Find the mmproj (vision projection) GGUF file in a directory. + + Args: + path: Directory to search — or a .gguf file (uses its parent dir). + + Returns: + Full path to the mmproj .gguf file, or None if not found. + """ + p = Path(path) + search_dir = p.parent if p.is_file() else p + if not search_dir.is_dir(): + return None + + for f in search_dir.glob("*.gguf"): + if _is_mmproj(f.name): + return str(f.resolve()) + return None + + +def detect_gguf_model(path: str) -> Optional[str]: + """ + Check if the given local path is or contains a GGUF model file. + + Handles two cases: + 1. path is a direct .gguf file path + 2. path is a directory containing .gguf files + + Skips mmproj (vision projection) files — those must be passed via + ``--mmproj``, not ``-m``. Use :func:`detect_mmproj_file` instead. + + Returns the full path to the .gguf file if found, None otherwise. + For HuggingFace repo detection, use detect_gguf_model_remote() instead. + """ + p = Path(path) + + # Case 1: direct .gguf file + if p.suffix == ".gguf" and p.is_file(): + if _is_mmproj(p.name): + return None + return str(p.resolve()) + + # Case 2: directory containing .gguf files (skip mmproj) + if p.is_dir(): + gguf_files = sorted( + (f for f in p.glob("*.gguf") if not _is_mmproj(f.name)), + key = lambda f: f.stat().st_size, + reverse = True, + ) + if gguf_files: + return str(gguf_files[0].resolve()) + + return None + + +# Preferred GGUF quantization levels, in descending priority. +# Q4_K_M is a good default: small, fast, acceptable quality. +_GGUF_QUANT_PREFERENCE = [ + "Q4_K_M", + "Q4_K_S", + "Q5_K_M", + "Q5_K_S", + "Q6_K", + "Q8_0", + "Q3_K_M", + "Q3_K_L", + "Q2_K", + "F16", + "BF16", + "F32", +] + + +def _pick_best_gguf(filenames: list[str]) -> Optional[str]: + """ + Pick the best GGUF file from a list of filenames. + + Prefers quantization levels in _GGUF_QUANT_PREFERENCE order. + Falls back to the first .gguf file found. + """ + gguf_files = [f for f in filenames if f.endswith(".gguf")] + if not gguf_files: + return None + + # Try preferred quantization levels + for quant in _GGUF_QUANT_PREFERENCE: + for f in gguf_files: + if quant in f: + return f + + # Fallback: first GGUF file + return gguf_files[0] + + +@dataclass +class GgufVariantInfo: + """A single GGUF quantization variant from a HuggingFace repo.""" + + filename: str # e.g., "gemma-3-4b-it-Q4_K_M.gguf" + quant: str # e.g., "Q4_K_M" (extracted from filename) + size_bytes: int # file size + + +def _extract_quant_label(filename: str) -> str: + """ + Extract quantization label like Q4_K_M, IQ4_XS, BF16 from a GGUF filename. + + Examples: + "gemma-3-4b-it-Q4_K_M.gguf" → "Q4_K_M" + "model-IQ4_NL.gguf" → "IQ4_NL" + "model-BF16.gguf" → "BF16" + "model-UD-IQ1_S.gguf" → "UD-IQ1_S" + "model-UD-TQ1_0.gguf" → "UD-TQ1_0" + "MXFP4_MOE/model-MXFP4_MOE-0001.gguf"→ "MXFP4_MOE" + """ + import re + + # Use only the basename (rfilename may include directory) + basename = filename.rsplit("/", 1)[-1] + # Strip .gguf and any shard suffix (-00001-of-00010) + stem = re.sub(r"-\d{3,}-of-\d{3,}", "", basename.rsplit(".", 1)[0]) + # Match known quantization patterns + match = re.search( + r"(UD-)?" # Optional UD- prefix (Ultra Discrete) + r"(MXFP[0-9]+(?:_[A-Z0-9]+)*" # MXFP variants: MXFP4, MXFP4_MOE + r"|IQ[0-9]+_[A-Z]+(?:_[A-Z0-9]+)?" # IQ variants: IQ4_XS, IQ4_NL, IQ1_S + r"|TQ[0-9]+_[0-9]+" # Ternary quant: TQ1_0, TQ2_0 + r"|Q[0-9]+_K_[A-Z]+" # K-quant: Q4_K_M, Q3_K_S + r"|Q[0-9]+_[0-9]+" # Standard: Q8_0, Q5_1 + r"|Q[0-9]+_K" # Short K-quant: Q6_K + r"|BF16|F16|F32)", # Full precision + stem, + re.IGNORECASE, + ) + if match: + prefix = match.group(1) or "" + return f"{prefix}{match.group(2)}" + # Fallback: last segment after hyphen + return stem.split("-")[-1] + + +def list_gguf_variants( + repo_id: str, + hf_token: Optional[str] = None, +) -> tuple[list[GgufVariantInfo], bool]: + """ + List all GGUF quantization variants in a HuggingFace repo. + + Separates main model files from mmproj (vision projection) files. + The presence of mmproj files indicates a vision-capable model. + + Returns: + (variants, has_vision): list of non-mmproj GGUF variants + vision flag. + """ + from huggingface_hub import model_info as hf_model_info + + info = hf_model_info(repo_id, token = hf_token, files_metadata = True) + variants: list[GgufVariantInfo] = [] + has_vision = False + + quant_totals: dict[str, int] = {} # quant -> total bytes + quant_first_file: dict[str, str] = {} # quant -> first filename (for display) + + for sibling in info.siblings: + fname = sibling.rfilename + if not fname.endswith(".gguf"): + continue + size = sibling.size or 0 + + # mmproj files are vision projection models, not main model files + if "mmproj" in fname.lower(): + has_vision = True + continue + + quant = _extract_quant_label(fname) + quant_totals[quant] = quant_totals.get(quant, 0) + size + if quant not in quant_first_file: + quant_first_file[quant] = fname + + for quant, total_size in quant_totals.items(): + variants.append( + GgufVariantInfo( + filename = quant_first_file[quant], + quant = quant, + size_bytes = total_size, + ) + ) + + return variants, has_vision + + +def detect_gguf_model_remote( + repo_id: str, + hf_token: Optional[str] = None, +) -> Optional[str]: + """ + Check if a HuggingFace repo contains GGUF files. + + Returns the filename of the best GGUF file in the repo, or None. + """ + try: + from huggingface_hub import model_info as hf_model_info + + info = hf_model_info(repo_id, token = hf_token) + repo_files = [s.rfilename for s in info.siblings] + return _pick_best_gguf(repo_files) + except Exception as e: + logger.debug(f"Could not check GGUF files for '{repo_id}': {e}") + return None + + +def download_gguf_file( + repo_id: str, + filename: str, + hf_token: Optional[str] = None, +) -> str: + """ + Download a specific GGUF file from a HuggingFace repo. + + Returns the local path to the downloaded file. + """ + from huggingface_hub import hf_hub_download + + local_path = hf_hub_download( + repo_id = repo_id, + filename = filename, + token = hf_token, + ) + return local_path + + +# Cache embedding detection results per session to avoid repeated HF API calls +_embedding_detection_cache: Dict[tuple, bool] = {} + + +def is_embedding_model(model_name: str, hf_token: Optional[str] = None) -> bool: + """ + Detect embedding/sentence-transformer models using HuggingFace model metadata. + + Uses a belt-and-suspenders approach combining three signals: + 1. "sentence-transformers" in model tags + 2. "feature-extraction" in model tags + 3. pipeline_tag is "sentence-similarity" or "feature-extraction" + + This catches all known embedding models including those like gte-modernbert + whose library_name is "transformers" rather than "sentence-transformers". + + Args: + model_name: Model identifier (HF repo or local path) + hf_token: Optional HF token for accessing gated/private models + + Returns: + True if the model is an embedding model, False otherwise. + Defaults to False for local paths or on errors. + """ + cache_key = (model_name, hf_token) + if cache_key in _embedding_detection_cache: + return _embedding_detection_cache[cache_key] + + # Local paths: check for sentence-transformer marker file (modules.json) + if is_local_path(model_name): + local_dir = normalize_path(model_name) + is_emb = os.path.isfile(os.path.join(local_dir, "modules.json")) + _embedding_detection_cache[cache_key] = is_emb + return is_emb + + try: + from huggingface_hub import model_info as hf_model_info + + info = hf_model_info(model_name, token = hf_token) + tags = set(info.tags or []) + pipeline_tag = info.pipeline_tag or "" + + is_emb = ( + "sentence-transformers" in tags + or "feature-extraction" in tags + or pipeline_tag in ("sentence-similarity", "feature-extraction") + ) + + _embedding_detection_cache[cache_key] = is_emb + if is_emb: + logger.info( + f"Model {model_name} detected as embedding model: " + f"pipeline_tag={pipeline_tag}, " + f"sentence-transformers in tags={('sentence-transformers' in tags)}, " + f"feature-extraction in tags={('feature-extraction' in tags)}" + ) + return is_emb + + except Exception as e: + logger.warning(f"Could not determine if {model_name} is embedding model: {e}") + _embedding_detection_cache[cache_key] = False + return False + + +def scan_trained_loras(outputs_dir: str = str(outputs_root())) -> List[Tuple[str, str]]: + """ + Scan outputs folder for trained LoRA adapters. + + Returns: + List of tuples: [(display_name, adapter_path), ...] + + Example: + [ + ("unsloth_Meta-Llama-3.1_...", "./outputs/unsloth_Meta-Llama-3.1_.../"), + ("my_finetuned_model", "./outputs/my_finetuned_model/"), + ] + """ + trained_loras = [] + outputs_path = resolve_output_dir(outputs_dir) + + if not outputs_path.exists(): + logger.warning(f"Outputs directory not found: {outputs_dir}") + return trained_loras + + try: + for item in outputs_path.iterdir(): + if item.is_dir(): + # Check if this directory contains a LoRA adapter + adapter_config = item / "adapter_config.json" + adapter_model = item / "adapter_model.safetensors" + + if adapter_config.exists() or adapter_model.exists(): + display_name = item.name + adapter_path = str(item) + trained_loras.append((display_name, adapter_path)) + logger.debug(f"Found trained LoRA: {display_name}") + + # Sort by modification time (newest first) + trained_loras.sort(key = lambda x: Path(x[1]).stat().st_mtime, reverse = True) + + logger.info( + f"Found {len(trained_loras)} trained LoRA adapters in {outputs_dir}" + ) + return trained_loras + + except Exception as e: + logger.error(f"Error scanning outputs folder: {e}") + return [] + + +def scan_exported_models( + exports_dir: str = str(exports_root()), +) -> List[Tuple[str, str, str, Optional[str]]]: + """ + Scan exports folder for exported models (merged, LoRA, GGUF). + + Supports two directory layouts: + - Two-level: {run}/{checkpoint}/ (merged & LoRA exports) + - Flat: {name}-finetune-gguf/ (GGUF exports) + + Returns: + List of tuples: [(display_name, model_path, export_type, base_model), ...] + export_type: "lora" | "merged" | "gguf" + """ + results = [] + exports_path = resolve_export_dir(exports_dir) + + if not exports_path.exists(): + return results + + try: + for run_dir in exports_path.iterdir(): + if not run_dir.is_dir(): + continue + + # Check for flat GGUF export (e.g. exports/gemma-3-4b-it-finetune-gguf/) + # Filter out mmproj (vision projection) files — they aren't loadable as main models + gguf_files = [f for f in run_dir.glob("*.gguf") if not _is_mmproj(f.name)] + if gguf_files: + base_model = None + export_meta = run_dir / "export_metadata.json" + try: + if export_meta.exists(): + meta = json.loads(export_meta.read_text()) + base_model = meta.get("base_model") + except Exception: + pass + + display_name = run_dir.name + model_path = str(gguf_files[0]) # path to the .gguf file + results.append((display_name, model_path, "gguf", base_model)) + logger.debug(f"Found GGUF export: {display_name}") + continue + + # Two-level: {run}/{checkpoint}/ + for checkpoint_dir in run_dir.iterdir(): + if not checkpoint_dir.is_dir(): + continue + + adapter_config = checkpoint_dir / "adapter_config.json" + config_file = checkpoint_dir / "config.json" + has_weights = any(checkpoint_dir.glob("*.safetensors")) or any( + checkpoint_dir.glob("*.bin") + ) + has_gguf = any(checkpoint_dir.glob("*.gguf")) + + base_model = None + export_type = None + + if adapter_config.exists(): + export_type = "lora" + try: + cfg = json.loads(adapter_config.read_text()) + base_model = cfg.get("base_model_name_or_path") + except Exception: + pass + elif config_file.exists() and has_weights: + export_type = "merged" + export_meta = checkpoint_dir / "export_metadata.json" + try: + if export_meta.exists(): + meta = json.loads(export_meta.read_text()) + base_model = meta.get("base_model") + except Exception: + pass + elif has_gguf: + export_type = "gguf" + gguf_list = list(checkpoint_dir.glob("*.gguf")) + # Check checkpoint_dir first, then fall back to parent run_dir + # (export.py writes metadata to the top-level export directory) + for meta_dir in (checkpoint_dir, run_dir): + export_meta = meta_dir / "export_metadata.json" + try: + if export_meta.exists(): + meta = json.loads(export_meta.read_text()) + base_model = meta.get("base_model") + if base_model: + break + except Exception: + pass + + display_name = f"{run_dir.name} / {checkpoint_dir.name}" + model_path = str(gguf_list[0]) if gguf_list else str(checkpoint_dir) + results.append((display_name, model_path, export_type, base_model)) + logger.debug(f"Found GGUF export: {display_name}") + continue + else: + continue + + # Fallback: read base model from the original training run's + # adapter_config.json in ./outputs/{run_name}/ + if not base_model: + outputs_adapter_cfg = ( + resolve_output_dir(run_dir.name) / "adapter_config.json" + ) + try: + if outputs_adapter_cfg.exists(): + cfg = json.loads(outputs_adapter_cfg.read_text()) + base_model = cfg.get("base_model_name_or_path") + except Exception: + pass + + display_name = f"{run_dir.name} / {checkpoint_dir.name}" + model_path = str(checkpoint_dir) + results.append((display_name, model_path, export_type, base_model)) + logger.debug(f"Found exported model: {display_name} ({export_type})") + + results.sort(key = lambda x: Path(x[1]).stat().st_mtime, reverse = True) + logger.info(f"Found {len(results)} exported models in {exports_dir}") + return results + + except Exception as e: + logger.error(f"Error scanning exports folder: {e}") + return [] + + +def get_base_model_from_lora(lora_path: str) -> Optional[str]: + """ + Read the base model name from a LoRA adapter's config. + + Args: + lora_path: Path to the LoRA adapter directory + + Returns: + Base model identifier (e.g., "unsloth/Meta-Llama-3.1-8B-Instruct-bnb-4bit") + or None if not found + + Example: + >>> get_base_model_from_lora("./outputs/unsloth_Meta-Llama-3.1_.../") + "unsloth/Meta-Llama-3.1-8B-Instruct-bnb-4bit" + """ + try: + lora_path_obj = Path(lora_path) + + # Try adapter_config.json first + adapter_config_path = lora_path_obj / "adapter_config.json" + if adapter_config_path.exists(): + with open(adapter_config_path, "r") as f: + config = json.load(f) + base_model = config.get("base_model_name_or_path") + if base_model: + logger.info( + f"Detected base model from adapter_config.json: {base_model}" + ) + return base_model + + # Fallback: try training_args.bin (requires torch) + training_args_path = lora_path_obj / "training_args.bin" + if training_args_path.exists(): + try: + import torch + + training_args = torch.load(training_args_path) + if hasattr(training_args, "model_name_or_path"): + base_model = training_args.model_name_or_path + logger.info( + f"Detected base model from training_args.bin: {base_model}" + ) + return base_model + except Exception as e: + logger.warning(f"Could not load training_args.bin: {e}") + + # Last resort: parse from directory name + # Format: unsloth_Meta-Llama-3.1-8B-Instruct-bnb-4bit_timestamp + dir_name = lora_path_obj.name + if dir_name.startswith("unsloth_"): + # Remove timestamp suffix (usually _1234567890) + parts = dir_name.split("_") + # Reconstruct model name + if len(parts) >= 2: + model_parts = parts[1:-1] # Skip "unsloth" and timestamp + base_model = "unsloth/" + "_".join(model_parts) + logger.info(f"Detected base model from directory name: {base_model}") + return base_model + + logger.warning(f"Could not detect base model for LoRA: {lora_path}") + return None + + except Exception as e: + logger.error(f"Error reading base model from LoRA config: {e}") + return None + + +# Status indicators that appear in UI dropdowns +UI_STATUS_INDICATORS = [" (Ready)", " (Loading...)", " (Active)", "↓ "] + + +def load_model_defaults(model_name: str) -> Dict[str, Any]: + """ + Load default training parameters for a model from YAML file. + + Args: + model_name: Model identifier (e.g., "unsloth/Meta-Llama-3.1-8B-bnb-4bit") + + Returns: + Dictionary with default parameters from YAML file, or empty dict if not found + + The function looks for a YAML file in configs/model_defaults/ (including subfolders) + based on the model name or its aliases from MODEL_NAME_MAPPING. + If no specific file exists, it falls back to default.yaml. + """ + try: + # Get the script directory to locate configs + script_dir = Path(__file__).parent.parent.parent + defaults_dir = script_dir / "assets" / "configs" / "model_defaults" + + # First, check if model is in the mapping + if model_name.lower() in _REVERSE_MODEL_MAPPING: + canonical_file = _REVERSE_MODEL_MAPPING[model_name.lower()] + # Search in subfolders and root + for config_path in defaults_dir.rglob(canonical_file): + if config_path.is_file(): + with open(config_path, "r", encoding = "utf-8") as f: + config = yaml.safe_load(f) or {} + logger.info( + f"Loaded model defaults from {config_path} (via mapping)" + ) + return config + + # If model_name is a local path (e.g. /home/.../Spark-TTS-0.5B/LLM from + # adapter_config.json), try matching the last 1-2 path components against + # the registry (e.g. "Spark-TTS-0.5B/LLM"). + if model_name not in _REVERSE_MODEL_MAPPING and ( + model_name.startswith("/") or model_name.startswith(".") + ): + parts = Path(model_name).parts + for depth in [2, 1]: + if len(parts) >= depth: + suffix = "/".join(parts[-depth:]) + if suffix in _REVERSE_MODEL_MAPPING: + canonical_file = _REVERSE_MODEL_MAPPING[suffix] + for config_path in defaults_dir.rglob(canonical_file): + if config_path.is_file(): + with open(config_path, "r", encoding = "utf-8") as f: + config = yaml.safe_load(f) or {} + logger.info( + f"Loaded model defaults from {config_path} (via path suffix '{suffix}')" + ) + return config + + # Try exact model name match (for backward compatibility) + model_filename = model_name.replace("/", "_") + ".yaml" + # Search in subfolders and root + for config_path in defaults_dir.rglob(model_filename): + if config_path.is_file(): + with open(config_path, "r", encoding = "utf-8") as f: + config = yaml.safe_load(f) or {} + logger.info(f"Loaded model defaults from {config_path}") + return config + + # Fall back to default.yaml + default_config_path = defaults_dir / "default.yaml" + if default_config_path.exists(): + with open(default_config_path, "r", encoding = "utf-8") as f: + config = yaml.safe_load(f) or {} + logger.info(f"Loaded default model defaults from {default_config_path}") + return config + + logger.warning(f"No default config found for model {model_name}") + return {} + + except Exception as e: + logger.error(f"Error loading model defaults for {model_name}: {e}") + return {} + + +@dataclass +class ModelConfig: + """Configuration for a model to load""" + + identifier: str # Clean model identifier (org/name or path) + display_name: str # Original UI display name + path: str # Normalized filesystem path + is_local: bool # Is this a local file vs HF model? + is_cached: bool # Is this already in HF cache? + is_vision: bool # Is this a vision model? + is_lora: bool # Is this a lora adapter? + is_gguf: bool = False # Is this a GGUF model? + is_audio: bool = False # Is this a TTS audio model? + audio_type: Optional[str] = ( + None # Audio codec type: 'snac', 'csm', 'bicodec', 'dac' + ) + has_audio_input: bool = False # Accepts audio input (ASR/speech understanding) + gguf_file: Optional[str] = None # Full path to the .gguf file (local mode) + gguf_mmproj_file: Optional[str] = ( + None # Full path to the mmproj .gguf file (vision projection) + ) + gguf_hf_repo: Optional[str] = ( + None # HF repo ID for -hf mode (e.g. "unsloth/gemma-3-4b-it-GGUF") + ) + gguf_variant: Optional[str] = None # Quantization variant (e.g. "Q4_K_M") + base_model: Optional[str] = None # Base model (for LoRAs) + + @classmethod + def from_lora_path( + cls, lora_path: str, hf_token: Optional[str] = None + ) -> Optional["ModelConfig"]: + """ + Create ModelConfig from a local LoRA adapter path. + + Automatically detects the base model from adapter config. + + Args: + lora_path: Path to LoRA adapter (e.g., "./outputs/unsloth_Meta-Llama-3.1_.../") + hf_token: HF token for vision detection + + Returns: + ModelConfig for the LoRA adapter + """ + try: + lora_path_obj = Path(lora_path) + + if not lora_path_obj.exists(): + logger.error(f"LoRA path does not exist: {lora_path}") + return None + + # Get base model + base_model = get_base_model_from_lora(lora_path) + if not base_model: + logger.error(f"Could not determine base model for LoRA: {lora_path}") + return None + + # Check if base model is vision + is_vision = is_vision_model(base_model, hf_token = hf_token) + + # Check if base model is audio + audio_type = detect_audio_type(base_model, hf_token = hf_token) + + display_name = lora_path_obj.name + identifier = lora_path # Use path as identifier for local LoRAs + + return cls( + identifier = identifier, + display_name = display_name, + path = lora_path, + is_local = True, + is_cached = True, # Local LoRAs are always "cached" + is_vision = is_vision, + is_lora = True, + is_audio = audio_type is not None and audio_type != "audio_vlm", + audio_type = audio_type, + has_audio_input = is_audio_input_type(audio_type), + base_model = base_model, + ) + + except Exception as e: + logger.error(f"Error creating ModelConfig from LoRA path: {e}") + return None + + @classmethod + def from_identifier( + cls, + model_id: str, + hf_token: Optional[str] = None, + is_lora: bool = False, + gguf_variant: Optional[str] = None, + ) -> Optional["ModelConfig"]: + """ + Create ModelConfig from a clean model identifier. + + For FastAPI routes where the frontend sends sanitized model paths. + No Gradio dropdown parsing - expects clean identifiers like: + - "unsloth/Meta-Llama-3.1-8B-Instruct-bnb-4bit" + - "./outputs/my_lora_adapter" + - "/absolute/path/to/model" + + Args: + model_id: Clean model identifier (HF repo name or local path) + hf_token: Optional HF token for vision detection on gated models + is_lora: Whether this is a LoRA adapter + gguf_variant: Optional GGUF quantization variant (e.g. "Q4_K_M"). + For remote GGUF repos, specifies which quant to load via -hf. + If None, auto-selects using _pick_best_gguf(). + + Returns: + ModelConfig or None if configuration cannot be created + """ + if not model_id or not model_id.strip(): + return None + + identifier = model_id.strip() + is_local = is_local_path(identifier) + path = normalize_path(identifier) if is_local else identifier + + # Add unsloth/ prefix for shorthand HF models + if not is_local and "/" not in identifier: + identifier = f"unsloth/{identifier}" + path = identifier + + # Enforce lowercase for remote Hugging Face identifiers to prevent cache duplication + # Hugging Face Hub APIs are case-insensitive remotely, but case-sensitive locally (repo_folder_name). + if not is_local: + identifier = identifier.lower() + path = path.lower() + + # Auto-detect GGUF models (check before LoRA/vision detection) + if is_local: + gguf_file = detect_gguf_model(path) + if gguf_file: + display_name = Path(gguf_file).stem + logger.info(f"Detected local GGUF model: {gguf_file}") + + # Detect vision: check if base model is vision, then look for mmproj + mmproj_file = None + gguf_is_vision = False + gguf_dir = Path(gguf_file).parent + + # Determine if this is a vision model from export metadata + base_is_vision = False + meta_path = gguf_dir / "export_metadata.json" + if meta_path.exists(): + try: + meta = json.loads(meta_path.read_text()) + base = meta.get("base_model") + if base and is_vision_model(base, hf_token = hf_token): + base_is_vision = True + logger.info(f"GGUF base model '{base}' is a vision model") + except Exception as e: + logger.debug(f"Could not read export metadata: {e}") + + # If vision (or mmproj happens to exist), find the mmproj file + mmproj_file = detect_mmproj_file(gguf_file) + if mmproj_file: + gguf_is_vision = True + logger.info(f"Detected mmproj for vision: {mmproj_file}") + elif base_is_vision: + logger.warning( + f"Base model is vision but no mmproj file found in {gguf_dir}" + ) + + return cls( + identifier = identifier, + display_name = display_name, + path = path, + is_local = True, + is_cached = True, + is_vision = gguf_is_vision, + is_lora = False, + is_gguf = True, + gguf_file = gguf_file, + gguf_mmproj_file = mmproj_file, + ) + else: + # Check if the HF repo contains GGUF files + gguf_filename = detect_gguf_model_remote(identifier, hf_token = hf_token) + if gguf_filename: + # Preflight: verify llama-server binary exists BEFORE user waits + # for a multi-GB download that llama-server handles natively + from core.inference.llama_cpp import LlamaCppBackend + + if not LlamaCppBackend._find_llama_server_binary(): + raise RuntimeError( + "llama-server binary not found — cannot load GGUF models. " + "Run setup.sh to build it, or set LLAMA_SERVER_PATH." + ) + + # Use list_gguf_variants() to detect vision & resolve variant + variants, has_vision = list_gguf_variants(identifier, hf_token = hf_token) + variant = gguf_variant + if not variant: + # Auto-select best quantization + variant_filenames = [v.filename for v in variants] + best = _pick_best_gguf(variant_filenames) + if best: + variant = _extract_quant_label(best) + else: + variant = "Q4_K_M" # Fallback — llama-server's own default + + display_name = f"{identifier.split('/')[-1]} ({variant})" + logger.info( + f"Detected remote GGUF repo '{identifier}', " + f"variant={variant}, vision={has_vision}" + ) + return cls( + identifier = identifier, + display_name = display_name, + path = identifier, + is_local = False, + is_cached = False, + is_vision = has_vision, + is_lora = False, + is_gguf = True, + gguf_file = None, + gguf_hf_repo = identifier, + gguf_variant = variant, + ) + + # Auto-detect LoRA for local paths (check adapter_config.json on disk) + if not is_lora and is_local: + detected_base = get_base_model_from_lora(path) + if detected_base: + is_lora = True + logger.info( + f"Auto-detected local LoRA adapter at '{path}' (base: {detected_base})" + ) + + # Auto-detect LoRA for remote HF models (check repo file listing) + if not is_lora and not is_local: + try: + from huggingface_hub import model_info as hf_model_info + + info = hf_model_info(identifier, token = hf_token) + repo_files = [s.rfilename for s in info.siblings] + if "adapter_config.json" in repo_files: + is_lora = True + logger.info(f"Auto-detected remote LoRA adapter: '{identifier}'") + except Exception as e: + logger.debug( + f"Could not check remote LoRA status for '{identifier}': {e}" + ) + + # Handle LoRA adapters + base_model = None + if is_lora: + if is_local: + # Local LoRA: read adapter_config.json from disk + base_model = get_base_model_from_lora(path) + else: + # Remote LoRA: download adapter_config.json from HF + try: + from huggingface_hub import hf_hub_download + + config_path = hf_hub_download( + identifier, "adapter_config.json", token = hf_token + ) + with open(config_path, "r") as f: + adapter_config = json.load(f) + base_model = adapter_config.get("base_model_name_or_path") + if base_model: + logger.info(f"Resolved remote LoRA base model: '{base_model}'") + except Exception as e: + logger.warning( + f"Could not download adapter_config.json for '{identifier}': {e}" + ) + + if not base_model: + logger.warning(f"Could not determine base model for LoRA '{path}'") + return None + check_model = base_model + else: + check_model = identifier + + vision = is_vision_model(check_model, hf_token = hf_token) + audio_type_val = detect_audio_type(check_model, hf_token = hf_token) + has_audio_in = is_audio_input_type(audio_type_val) + + display_name = Path(path).name if is_local else identifier.split("/")[-1] + + return cls( + identifier = identifier, + display_name = display_name, + path = path, + is_local = is_local, + is_cached = is_model_cached(identifier) if not is_local else True, + is_vision = vision, + is_lora = is_lora, + is_audio = audio_type_val is not None and audio_type_val != "audio_vlm", + audio_type = audio_type_val, + has_audio_input = has_audio_in, + base_model = base_model, + ) + + @classmethod + def from_ui_selection( + cls, + dropdown_value: Optional[str], + search_value: Optional[str], + local_models: list = None, + hf_token: Optional[str] = None, + is_lora: bool = False, + ) -> Optional["ModelConfig"]: + """ + Create a universal ModelConfig from UI dropdown/search selections. + Handles base models and LoRA adapters. + """ + selected = None + if search_value and search_value.strip(): + selected = search_value.strip() + elif dropdown_value: + selected = dropdown_value + + if not selected: + return None + + display_name = selected + + # Use the correct 'local_models' parameter to resolve display names + if " (Active)" in selected or " (Ready)" in selected: + clean_display_name = selected.replace(" (Active)", "").replace( + " (Ready)", "" + ) + if local_models: + for local_display, local_path in local_models: + if local_display == clean_display_name: + selected = local_path + break + + # Clean all UI status indicators to get the final identifier + identifier = selected + for status in UI_STATUS_INDICATORS: + identifier = identifier.replace(status, "") + identifier = identifier.strip() + + is_local = is_local_path(identifier) + path = normalize_path(identifier) if is_local else identifier + + # Add unsloth/ prefix for shorthand HF models + if not is_local and "/" not in identifier: + identifier = f"unsloth/{identifier}" + path = identifier + + # --- Logic for Base Model and Vision Detection --- + base_model = None + is_vision = False + + if is_lora: + # For a LoRA, we MUST find its base model. + base_model = get_base_model_from_lora(path) + if not base_model: + logger.warning( + f"Could not determine base model for LoRA '{path}'. Cannot create config." + ) + return None # Cannot proceed without a base model + + # A LoRA's vision capability is determined by its base model. + is_vision = is_vision_model(base_model, hf_token = hf_token) + else: + # For a base model, just check its own vision status. + is_vision = is_vision_model(identifier, hf_token = hf_token) + + from utils.paths import is_model_cached + + is_cached = is_model_cached(identifier) if not is_local else True + + return cls( + identifier = identifier, + display_name = display_name, + path = path, + is_local = is_local, + is_cached = is_cached, + is_vision = is_vision, + is_lora = is_lora, + base_model = base_model, # This will be None for base models, and populated for LoRAs + ) diff --git a/studio/backend/utils/paths/__init__.py b/studio/backend/utils/paths/__init__.py new file mode 100644 index 0000000000..507fb1106b --- /dev/null +++ b/studio/backend/utils/paths/__init__.py @@ -0,0 +1,59 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Path utilities for model and dataset handling +""" + +from .path_utils import normalize_path, is_local_path, is_model_cached, get_cache_path +from .storage_roots import ( + studio_root, + assets_root, + datasets_root, + dataset_uploads_root, + recipe_datasets_root, + outputs_root, + exports_root, + auth_root, + auth_db_path, + tmp_root, + seed_uploads_root, + unstructured_seed_cache_root, + oxc_validator_tmp_root, + tensorboard_root, + ensure_dir, + ensure_studio_directories, + resolve_under_root, + resolve_output_dir, + resolve_export_dir, + resolve_tensorboard_dir, + resolve_dataset_path, +) + +__all__ = [ + "normalize_path", + "is_local_path", + "is_model_cached", + "get_cache_path", + "studio_root", + "assets_root", + "datasets_root", + "dataset_uploads_root", + "recipe_datasets_root", + "outputs_root", + "exports_root", + "auth_root", + "auth_db_path", + "tmp_root", + "seed_uploads_root", + "unstructured_seed_cache_root", + "oxc_validator_tmp_root", + "tensorboard_root", + "ensure_dir", + "ensure_studio_directories", + "resolve_under_root", + "resolve_output_dir", + "resolve_export_dir", + "resolve_tensorboard_dir", + "resolve_dataset_path", +] diff --git a/studio/backend/utils/paths/path_utils.py b/studio/backend/utils/paths/path_utils.py new file mode 100644 index 0000000000..1d6a952399 --- /dev/null +++ b/studio/backend/utils/paths/path_utils.py @@ -0,0 +1,89 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Path utilities for model and dataset handling +""" + +import os +from pathlib import Path +from typing import Optional +import structlog +from loggers import get_logger + +logger = get_logger(__name__) + + +def normalize_path(path: str) -> str: + """ + Convert Windows paths to WSL format if needed. + + Examples: + C:\\Users\\... -> /mnt/c/Users/... + /home/user/... -> /home/user/... (unchanged) + """ + if not path: + return path + + # Handle Windows drive letters (C:\\ or c:\\) + if len(path) >= 3 and path[1] == ":" and path[2] in ("\\", "/"): + drive = path[0].lower() + rest = path[3:].replace("\\", "/") + return f"/mnt/{drive}/{rest}" + + # Already Unix-style or relative + return path.replace("\\", "/") + + +def is_local_path(path: str) -> bool: + """ + Check if path is a local filesystem path vs HuggingFace model identifier. + + Examples: + True: /home/user/model, C:\\models, ./model, ~/model + False: unsloth/llama-3.1-8b, microsoft/phi-2 + """ + if not path: + return False + + # If it exists on disk, treat as local (covers relative paths like "outputs/foo"). + try: + if Path(normalize_path(path)).expanduser().exists(): + return True + except Exception: + pass + + # Obvious HF patterns + if path.count("/") == 1 and not path.startswith(("/", ".", "~")): + return False # Looks like org/model format + + # Filesystem indicators + return ( + path.startswith(("/", ".", "~")) # Unix absolute/relative + or ":" in path # Windows drive or URL + or "\\" in path # Windows separator + or os.path.isabs(path) # System-absolute + ) + + +def get_cache_path(model_name: str) -> Optional[Path]: + """Get HuggingFace cache path for a model if it exists.""" + cache_dir = Path.home() / ".cache" / "huggingface" / "hub" + model_cache_name = model_name.replace("/", "--") + model_cache_path = cache_dir / f"models--{model_cache_name}" + + return model_cache_path if model_cache_path.exists() else None + + +def is_model_cached(model_name: str) -> bool: + """Check if model is downloaded in HuggingFace cache.""" + cache_path = get_cache_path(model_name) + if not cache_path: + return False + + # Check for actual model files + for suffix in [".safetensors", ".bin", ".json"]: + if list(cache_path.rglob(f"*{suffix}")): + return True + + return False diff --git a/studio/backend/utils/paths/storage_roots.py b/studio/backend/utils/paths/storage_roots.py new file mode 100644 index 0000000000..d9adce2105 --- /dev/null +++ b/studio/backend/utils/paths/storage_roots.py @@ -0,0 +1,164 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +from __future__ import annotations + +from pathlib import Path +import tempfile + + +def studio_root() -> Path: + return Path.home() / ".unsloth" / "studio" + + +def assets_root() -> Path: + return studio_root() / "assets" + + +def datasets_root() -> Path: + return assets_root() / "datasets" + + +def dataset_uploads_root() -> Path: + return datasets_root() / "uploads" + + +def recipe_datasets_root() -> Path: + return datasets_root() / "recipes" + + +def outputs_root() -> Path: + return studio_root() / "outputs" + + +def exports_root() -> Path: + return studio_root() / "exports" + + +def auth_root() -> Path: + return studio_root() / "auth" + + +def auth_db_path() -> Path: + return auth_root() / "auth.db" + + +def tmp_root() -> Path: + return Path(tempfile.gettempdir()) / "unsloth-studio" + + +def seed_uploads_root() -> Path: + return tmp_root() / "seed-uploads" + + +def unstructured_seed_cache_root() -> Path: + return tmp_root() / "unstructured-seed-cache" + + +def oxc_validator_tmp_root() -> Path: + return tmp_root() / "oxc-validator" + + +def tensorboard_root() -> Path: + return studio_root() / "runs" + + +def ensure_dir(path: Path) -> Path: + path.mkdir(parents = True, exist_ok = True) + return path + + +def ensure_studio_directories() -> None: + """Create all standard studio directories on startup.""" + for dir_fn in ( + studio_root, + assets_root, + datasets_root, + dataset_uploads_root, + recipe_datasets_root, + outputs_root, + exports_root, + auth_root, + tensorboard_root, + ): + ensure_dir(dir_fn()) + + +def _clean_relative_path( + path_value: str, *, strip_prefixes: tuple[str, ...] = () +) -> Path: + path = Path(path_value).expanduser() + parts = [part for part in path.parts if part not in ("", ".")] + while parts and parts[0] in strip_prefixes: + parts = parts[1:] + return Path(*parts) if parts else Path() + + +def resolve_under_root( + path_value: str | None, + *, + root: Path, + strip_prefixes: tuple[str, ...] = (), +) -> Path: + if not path_value or not str(path_value).strip(): + return root + + path = Path(str(path_value).strip()).expanduser() + if path.is_absolute(): + return path + + cleaned = _clean_relative_path(str(path), strip_prefixes = strip_prefixes) + return root / cleaned + + +def resolve_output_dir(path_value: str | None = None) -> Path: + return resolve_under_root( + path_value, + root = outputs_root(), + strip_prefixes = ("outputs",), + ) + + +def resolve_export_dir(path_value: str | None = None) -> Path: + return resolve_under_root( + path_value, + root = exports_root(), + strip_prefixes = ("exports",), + ) + + +def resolve_tensorboard_dir(path_value: str | None = None) -> Path: + return resolve_under_root( + path_value, + root = tensorboard_root(), + strip_prefixes = ("runs", "tensorboard"), + ) + + +def resolve_dataset_path(path_value: str) -> Path: + path = Path(path_value).expanduser() + if path.is_absolute(): + return path + + parts = [part for part in Path(path_value).parts if part not in ("", ".")] + if parts[:2] == ["assets", "datasets"]: + parts = parts[2:] + if parts and parts[0] == "uploads": + cleaned = Path(*parts[1:]) if len(parts) > 1 else Path() + return dataset_uploads_root() / cleaned + if parts and parts[0] == "recipes": + cleaned = Path(*parts[1:]) if len(parts) > 1 else Path() + return recipe_datasets_root() / cleaned + + cleaned = Path(*parts) if parts else Path() + candidates = [ + dataset_uploads_root() / cleaned, + recipe_datasets_root() / cleaned, + datasets_root() / cleaned, + dataset_uploads_root() / cleaned.name, + recipe_datasets_root() / cleaned.name, + ] + for candidate in candidates: + if candidate.exists(): + return candidate + return candidates[0] diff --git a/studio/backend/utils/transformers_version.py b/studio/backend/utils/transformers_version.py new file mode 100644 index 0000000000..58fbc35e33 --- /dev/null +++ b/studio/backend/utils/transformers_version.py @@ -0,0 +1,282 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Automatic transformers version switching. + +Some newer model architectures (Ministral-3, GLM-4.7-Flash, Qwen3-30B-A3B MoE, +tiny_qwen3_moe) require transformers>=5.2.0, while everything else needs the +default 4.57.x that ships with Unsloth. + +When loading a LoRA adapter with a custom name, we resolve the base model from +``adapter_config.json`` and check *that* against the model list. + +Strategy: + Training and inference run in subprocesses that activate the correct version + via sys.path (prepending .venv_t5/ for 5.x models). See: + - core/training/worker.py + - core/inference/worker.py + + For export (still in-process), ensure_transformers_version() does a lightweight + sys.path swap using the same .venv_t5/ directory pre-installed by setup.sh. +""" + +import importlib +import json +import structlog +from loggers import get_logger +import os +import subprocess +import sys +from pathlib import Path + +logger = get_logger(__name__) + + +# --------------------------------------------------------------------------- +# Detection +# --------------------------------------------------------------------------- + +# Lowercase substrings — if ANY appears anywhere in the lowered model name, +# we need transformers 5.x. +TRANSFORMERS_5_MODEL_SUBSTRINGS: tuple[str, ...] = ( + "ministral-3-", # Ministral-3-{3,8,14}B-{Instruct,Reasoning,Base}-2512 + "glm-4.7-flash", # GLM-4.7-Flash + "qwen3-30b-a3b", # Qwen3-30B-A3B-Instruct-2507 and variants + "qwen3.5", # Qwen3.5 family (35B-A3B, etc.) + "qwen3-next", # Qwen3-Next and variants + "tiny_qwen3_moe", # imdatta0/tiny_qwen3_moe_2.8B_0.7B +) + +# Versions +TRANSFORMERS_5_VERSION = "5.2.0" +TRANSFORMERS_DEFAULT_VERSION = "4.57.1" + +# Pre-installed directory for transformers 5.x — created by setup.sh / setup.ps1 +_VENV_T5_DIR = str(Path.home() / ".unsloth" / "studio" / ".venv_t5") + + +def _resolve_base_model(model_name: str) -> str: + """If *model_name* points to a LoRA adapter, return its base model. + + Checks for ``adapter_config.json`` locally first. Only calls the heavier + ``get_base_model_from_lora`` for paths that are actual local directories + (avoids noisy warnings for plain HF model IDs). + + Returns the original *model_name* unchanged if it is not a LoRA adapter. + """ + # --- Fast local check --------------------------------------------------- + local_path = Path(model_name) + adapter_cfg_path = local_path / "adapter_config.json" + if adapter_cfg_path.is_file(): + try: + with open(adapter_cfg_path) as f: + cfg = json.load(f) + base = cfg.get("base_model_name_or_path") + if base: + logger.info( + "Resolved LoRA adapter '%s' → base model '%s'", + model_name, + base, + ) + return base + except Exception as exc: + logger.debug("Could not read %s: %s", adapter_cfg_path, exc) + + # --- Only try the heavier fallback for local directories ---------------- + if local_path.is_dir(): + try: + from utils.models import get_base_model_from_lora + + base = get_base_model_from_lora(model_name) + if base: + logger.info( + "Resolved LoRA adapter '%s' → base model '%s' " + "(via get_base_model_from_lora)", + model_name, + base, + ) + return base + except Exception as exc: + logger.debug( + "get_base_model_from_lora failed for '%s': %s", + model_name, + exc, + ) + + return model_name + + +def needs_transformers_5(model_name: str) -> bool: + """Return True if *model_name* belongs to an architecture that requires + ``transformers>=5.2.0``.""" + lowered = model_name.lower() + return any(sub in lowered for sub in TRANSFORMERS_5_MODEL_SUBSTRINGS) + + +# --------------------------------------------------------------------------- +# Version switching (in-process — used only by export) +# --------------------------------------------------------------------------- + + +def _get_in_memory_version() -> str | None: + """Return the transformers version currently loaded in this process.""" + tf = sys.modules.get("transformers") + if tf is not None: + return getattr(tf, "__version__", None) + return None + + +# All top-level prefixes that hold references to transformers internals. +_PURGE_PREFIXES = ( + "transformers", + "huggingface_hub", + "unsloth", + "unsloth_zoo", + "peft", + "trl", + "accelerate", + "auto_gptq", + # NOTE: bitsandbytes is intentionally EXCLUDED — it registers torch custom + # operators at import time via torch.library.define(). Those registrations + # live in torch's global operator registry which survives module purge. + # Re-importing bitsandbytes after purge → duplicate registration → crash. + # Our own modules that import from transformers at module level + # (e.g. model_config.py: `from transformers import AutoConfig`) + "utils.models", + "core.training", + "core.inference", + "core.export", +) + + +def _purge_modules() -> int: + """Remove all cached modules for transformers and its dependents. + + Returns the number of modules purged. + """ + importlib.invalidate_caches() + to_remove = [ + k + for k in list(sys.modules.keys()) + if any(k == p or k.startswith(p + ".") for p in _PURGE_PREFIXES) + ] + for key in to_remove: + del sys.modules[key] + return len(to_remove) + + +def _ensure_venv_t5_exists() -> bool: + """Ensure .venv_t5/ exists. Install at runtime if missing.""" + if os.path.isdir(_VENV_T5_DIR) and os.listdir(_VENV_T5_DIR): + return True + + logger.warning(".venv_t5 not found at %s — installing at runtime", _VENV_T5_DIR) + os.makedirs(_VENV_T5_DIR, exist_ok = True) + for pkg in (f"transformers=={TRANSFORMERS_5_VERSION}", "huggingface_hub==1.3.0"): + cmd = [ + sys.executable, + "-m", + "pip", + "install", + "--target", + _VENV_T5_DIR, + "--no-deps", + pkg, + ] + result = subprocess.run( + cmd, stdout = subprocess.PIPE, stderr = subprocess.STDOUT, text = True + ) + if result.returncode != 0: + logger.error("pip install failed:\n%s", result.stdout) + return False + logger.info("Installed transformers 5.x to %s", _VENV_T5_DIR) + return True + + +def _activate_5x() -> None: + """Prepend .venv_t5/ to sys.path, purge stale modules, reimport.""" + if not _ensure_venv_t5_exists(): + raise RuntimeError( + f"Cannot activate transformers 5.x: .venv_t5 missing at {_VENV_T5_DIR}" + ) + + if _VENV_T5_DIR not in sys.path: + sys.path.insert(0, _VENV_T5_DIR) + logger.info("Prepended %s to sys.path", _VENV_T5_DIR) + + count = _purge_modules() + logger.info("Purged %d cached modules", count) + + import transformers + + logger.info("Loaded transformers %s", transformers.__version__) + + +def _deactivate_5x() -> None: + """Remove .venv_t5/ from sys.path, purge stale modules, reimport.""" + while _VENV_T5_DIR in sys.path: + sys.path.remove(_VENV_T5_DIR) + logger.info("Removed %s from sys.path", _VENV_T5_DIR) + + count = _purge_modules() + logger.info("Purged %d cached modules", count) + + import transformers + + logger.info("Reverted to transformers %s", transformers.__version__) + + +def ensure_transformers_version(model_name: str) -> None: + """Ensure the correct ``transformers`` version is active for *model_name*. + + Uses sys.path with .venv_t5/ (pre-installed by setup.sh): + • Need 5.x → prepend .venv_t5/ to sys.path, purge modules. + • Need 4.x → remove .venv_t5/ from sys.path, purge modules. + + For LoRA adapters with custom names, the base model is resolved from + ``adapter_config.json`` before checking. + + NOTE: Training and inference use subprocess isolation instead of this + function. This is only used by the export path (routes/export.py). + """ + # Resolve LoRA adapters to their base model for accurate detection + resolved = _resolve_base_model(model_name) + want_5 = needs_transformers_5(resolved) + target_version = TRANSFORMERS_5_VERSION if want_5 else TRANSFORMERS_DEFAULT_VERSION + target_major = int(target_version.split(".")[0]) + + # Check what's actually loaded in memory + in_memory = _get_in_memory_version() + + logger.info( + "Version check for '%s' (resolved: '%s'): need=%s, in_memory=%s", + model_name, + resolved, + target_version, + in_memory, + ) + + # --- Already correct? --------------------------------------------------- + if in_memory is not None: + in_memory_major = int(in_memory.split(".")[0]) + if in_memory_major == target_major: + logger.info( + "transformers %s already loaded — correct for '%s'", + in_memory, + model_name, + ) + return + + # --- Switch version ----------------------------------------------------- + if want_5: + logger.info("Activating transformers %s via .venv_t5…", TRANSFORMERS_5_VERSION) + _activate_5x() + else: + logger.info( + "Reverting to default transformers %s…", TRANSFORMERS_DEFAULT_VERSION + ) + _deactivate_5x() + + final = _get_in_memory_version() + logger.info("✓ transformers version is now %s", final) diff --git a/studio/backend/utils/utils.py b/studio/backend/utils/utils.py new file mode 100644 index 0000000000..4e61a5b969 --- /dev/null +++ b/studio/backend/utils/utils.py @@ -0,0 +1,118 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +Shared backend utilities +""" + +import os +import structlog +from loggers import get_logger +from contextlib import contextmanager +from pathlib import Path +import shutil +import tempfile + + +logger = get_logger(__name__) + + +@contextmanager +def without_hf_auth(): + """ + Context manager to temporarily disable HuggingFace authentication. + + Usage: + with without_hf_auth(): + # Code that should run without cached tokens + model_info(model_name, token=None) + """ + # Save environment variables + saved_env = {} + env_vars = ["HF_TOKEN", "HUGGINGFACE_HUB_TOKEN", "HF_HOME"] + for var in env_vars: + if var in os.environ: + saved_env[var] = os.environ[var] + del os.environ[var] + + # Save disable flag + saved_disable = os.environ.get("HF_HUB_DISABLE_IMPLICIT_TOKEN") + os.environ["HF_HUB_DISABLE_IMPLICIT_TOKEN"] = "1" + + # Move token files temporarily + token_files = [] + token_locations = [ + Path.home() / ".cache" / "huggingface" / "token", + Path.home() / ".huggingface" / "token", + ] + + for token_loc in token_locations: + if token_loc.exists(): + temp = tempfile.NamedTemporaryFile(delete = False) + temp.close() + shutil.move(str(token_loc), temp.name) + token_files.append((token_loc, temp.name)) + + try: + yield + finally: + # Restore tokens + for original, temp in token_files: + try: + original.parent.mkdir(parents = True, exist_ok = True) + shutil.move(temp, str(original)) + except Exception as e: + logger.error(f"Failed to restore token {original}: {e}") + + # Restore environment + for var, value in saved_env.items(): + os.environ[var] = value + + if saved_disable is not None: + os.environ["HF_HUB_DISABLE_IMPLICIT_TOKEN"] = saved_disable + else: + os.environ.pop("HF_HUB_DISABLE_IMPLICIT_TOKEN", None) + + +def format_error_message(error: Exception, model_name: str) -> str: + """ + Format user-friendly error messages for common issues. + + Args: + error: The exception that occurred + model_name: Name of the model being loaded + + Returns: + User-friendly error string + """ + error_str = str(error).lower() + model_short = model_name.split("/")[-1] if "/" in model_name else model_name + + if "repository not found" in error_str or "404" in error_str: + return f"Model '{model_short}' not found. Check the model name." + + if "401" in error_str or "unauthorized" in error_str: + return f"Authentication failed for '{model_short}'. Please provide a valid HF token." + + if "gated" in error_str or "access to model" in error_str: + return f"Model '{model_short}' requires authentication. Please provide a valid HF token." + + if "invalid user token" in error_str: + return "Invalid HF token. Please check your token and try again." + + if ( + "memory" in error_str + or "cuda" in error_str + or "mlx" in error_str + or "out of memory" in error_str + ): + from utils.hardware import get_device + + device = get_device() + device_label = {"cuda": "GPU", "mlx": "Apple Silicon GPU", "cpu": "system"}.get( + device.value, "GPU" + ) + return f"Not enough {device_label} memory to load '{model_short}'. Try a smaller model or free memory." + + # Generic fallback + return str(error) diff --git a/studio/frontend/.gitignore b/studio/frontend/.gitignore new file mode 100644 index 0000000000..bf7ac45ef1 --- /dev/null +++ b/studio/frontend/.gitignore @@ -0,0 +1,34 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +test/ +*.local +.env +.env.* +.omx/ + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +._* +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +/src/features/recipe-studio/AGENTS.md +/docs diff --git a/studio/frontend/.gitkeep b/studio/frontend/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/studio/frontend/AGENTS.md b/studio/frontend/AGENTS.md new file mode 100644 index 0000000000..9cd7ba74e2 --- /dev/null +++ b/studio/frontend/AGENTS.md @@ -0,0 +1,40 @@ + + + +# Repository Guidelines + +## Project Structure & Module Organization +- `src/` is app code; entry is `src/main.tsx`, global styles in `src/index.css`. +- `src/app/` holds app shell and routing; `src/features/` is feature slices w/ public `index.ts` exports. +- Shared UI lives in `src/components/` (shadcn in `src/components/ui/`). +- Shared logic in `src/hooks/`, `src/stores/`, `src/utils/`, `src/lib/`, and types in `src/types/`. +- Static assets: `src/assets/` and `public/`. +- `test/` is a Python harness for payload validation and preview; not a JS test suite. + +## Build, Test, and Development Commands +- `bun run dev`: start Vite dev server. +- `bun run build`: typecheck + build to `dist/`. +- `bun run preview`: serve the production build locally. +- `bun run lint`: ESLint checks for TS/React. +- `bun run typecheck`: `tsc` no-emit verification. +- `bun run biome:check` / `bun run biome:fix`: format + lint w/ Biome. +- Optional harness: `python test/scripts/validate_payload.py test/data/ui_payload.json`. + +## Coding Style & Naming Conventions +- TypeScript + React, 2-space indent (Biome). +- Prefer explicit, compact code; avoid heavy abstraction. +- Use path alias `@/` for app imports. +- Feature boundaries enforced: import from `@/features/` only, not deep paths. +- Components in `PascalCase`, hooks in `useCamelCase`, files in `kebab-case` or `camelCase` per local convention. + +## Testing Guidelines +- No frontend test runner configured yet; add one if needed. +- `test/` is for API payload validation and preview flows; add samples as `test/data/ui_payload_*.json`. + +## Commit & Pull Request Guidelines +- Commit history shows short, imperative messages; optional prefix like `refactor:`; keep it terse. +- PRs should include: clear summary, linked issue (if any), and UI screenshots/gifs for visual changes. +- Call out new deps, config, or required env changes in the PR body. + +## Agent Notes +- Keep changes minimal, focused, and easy to review. diff --git a/studio/frontend/README.md b/studio/frontend/README.md new file mode 100644 index 0000000000..7b082a1714 --- /dev/null +++ b/studio/frontend/README.md @@ -0,0 +1,6 @@ + + + +# React + TypeScript + Vite + shadcn/ui + +This is a template for a new Vite project with React, TypeScript, and shadcn/ui. diff --git a/studio/frontend/biome.json b/studio/frontend/biome.json new file mode 100644 index 0000000000..66dcd322a0 --- /dev/null +++ b/studio/frontend/biome.json @@ -0,0 +1,150 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "files": { + "ignore": [ + "dist", + "node_modules", + "test", + "test/**", + "**/._*", + "._*", + "**/.DS_Store", + "tsconfig*.json" + ] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2 + }, + "organizeImports": { + "enabled": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "a11y": { "all": true }, + "complexity": { "all": true }, + "correctness": { "all": true, "useImportExtensions": "off" }, + "performance": { "all": true }, + "security": { "all": true }, + "style": { + "all": true, + "useNamingConvention": { "options": { "strictCase": false } } + }, + "suspicious": { "all": true, "noReactSpecificProps": "off" } + } + }, + "overrides": [ + { + "include": ["vite.config.ts", "eslint.config.js"], + "linter": { + "rules": { + "correctness": { "noNodejsModules": "off" }, + "style": { "noDefaultExport": "off" } + } + } + }, + { + "include": ["src/components/assistant-ui/reasoning.tsx"], + "linter": { + "rules": { + "style": { "useNamingConvention": "off" } + } + } + }, + { + "include": ["src/components/assistant-ui/attachment.tsx"], + "linter": { + "rules": { + "style": { "useNamingConvention": "off" } + } + } + }, + { + "include": ["src/components/assistant-ui/tool-fallback.tsx"], + "linter": { + "rules": { + "style": { "useNamingConvention": "off" } + } + } + }, + { + "include": ["src/components/component-example.tsx"], + "linter": { + "rules": { + "style": { "noNamespaceImport": "off" } + } + } + }, + { + "include": ["src/config/env.ts"], + "linter": { + "rules": { + "style": { "useNamingConvention": "off" } + } + } + }, + { + "include": ["src/components/layout/index.ts"], + "linter": { + "rules": { + "performance": { "noBarrelFile": "off" } + } + } + }, + { + "include": ["src/features/**/index.ts"], + "linter": { + "rules": { + "performance": { "noBarrelFile": "off" } + } + } + }, + { + "include": ["src/features/chat/thread-sidebar.tsx"], + "linter": { + "rules": { + "a11y": { "useSemanticElements": "off" } + } + } + }, + { + "include": ["src/features/chat/runtime-provider.tsx"], + "linter": { + "rules": { + "style": { "useNamingConvention": "off" } + } + } + }, + { + "include": ["src/components/assistant-ui/thread.tsx"], + "linter": { + "rules": { + "style": { "useNamingConvention": "off" } + } + } + }, + { + "include": ["src/features/onboarding/components/steps/summary-step.tsx"], + "linter": { + "rules": { + "style": { "useExplicitLengthCheck": "off" } + } + } + }, + { + "include": ["src/components/ui/**"], + "linter": { + "enabled": false + }, + "formatter": { + "enabled": false + }, + "organizeImports": { + "enabled": false + } + } + ] +} diff --git a/studio/frontend/bun.lock b/studio/frontend/bun.lock new file mode 100644 index 0000000000..e096f34cd7 --- /dev/null +++ b/studio/frontend/bun.lock @@ -0,0 +1,2475 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "unsloth-theme", + "dependencies": { + "@assistant-ui/react": "^0.12.10", + "@assistant-ui/react-markdown": "^0.12.3", + "@assistant-ui/react-streamdown": "^0.1.2", + "@base-ui/react": "^1.2.0", + "@dagrejs/dagre": "^2.0.4", + "@dagrejs/graphlib": "^3.0.4", + "@fontsource-variable/figtree": "^5.2.10", + "@fontsource-variable/inter": "^5.2.8", + "@fontsource-variable/space-grotesk": "^5.2.10", + "@hugeicons/core-free-icons": "^3.1.1", + "@hugeicons/react": "^1.1.5", + "@huggingface/hub": "^2.9.0", + "@langchain/core": "^1.1.27", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@streamdown/cjk": "^1.0.2", + "@streamdown/code": "^1.0.2", + "@streamdown/math": "^1.0.2", + "@streamdown/mermaid": "^1.0.2", + "@tailwindcss/vite": "^4.1.18", + "@tanstack/react-router": "^1.159.10", + "@tanstack/react-table": "^8.21.3", + "@toolwind/corner-shape": "^0.0.8-3", + "@types/canvas-confetti": "^1.9.0", + "@xyflow/react": "^12.10.0", + "assistant-stream": "^0.3.2", + "canvas-confetti": "^1.9.4", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "date-fns": "^4.1.0", + "dexie": "^4.3.0", + "framer-motion": "^11.18.2", + "js-yaml": "^4.1.1", + "katex": "^0.16.28", + "lucide-react": "^0.575.0", + "mammoth": "^1.11.0", + "motion": "^12.34.0", + "next": "^16.1.6", + "next-themes": "^0.4.6", + "radix-ui": "^1.4.3", + "react": "^19.2.4", + "react-day-picker": "^9.13.2", + "react-dom": "^19.2.4", + "react-resizable-panels": "^4.6.4", + "recharts": "3.7.0", + "remark-gfm": "^4.0.1", + "shadcn": "^3.8.4", + "sonner": "^2.0.7", + "streamdown": "^2.2.0", + "tailwind-merge": "^3.4.0", + "tailwindcss": "^4.1.18", + "tw-animate-css": "^1.4.0", + "tw-shimmer": "^0.4.6", + "unpdf": "^1.4.0", + "zustand": "^5.0.11", + }, + "devDependencies": { + "@biomejs/biome": "^1.9.4", + "@eslint/js": "^9.39.1", + "@types/js-yaml": "^4.0.9", + "@types/node": "^24.10.1", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.26", + "globals": "^16.5.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.55.0", + "vite": "^7.3.1", + }, + }, + }, + "packages": { + "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], + + "@antfu/ni": ["@antfu/ni@25.0.0", "", { "dependencies": { "ansis": "^4.0.0", "fzf": "^0.5.2", "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" }, "bin": { "na": "bin/na.mjs", "ni": "bin/ni.mjs", "nr": "bin/nr.mjs", "nci": "bin/nci.mjs", "nlx": "bin/nlx.mjs", "nun": "bin/nun.mjs", "nup": "bin/nup.mjs" } }, "sha512-9q/yCljni37pkMr4sPrI3G4jqdIk074+iukc5aFJl7kmDCCsiJrbZ6zKxnES1Gwg+i9RcDZwvktl23puGslmvA=="], + + "@assistant-ui/core": ["@assistant-ui/core@0.1.0", "", { "dependencies": { "@assistant-ui/tap": "^0.5.0", "assistant-stream": "^0.3.3", "nanoid": "^5.1.6" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-8fIhNjX5Qvdvl5Zu3u0dypEm6/zFSJMKDAyl5icP6zW/2NGy+/CtFlNSdtvJ+tloKevJR7kXmyyTyTuhZRg25g=="], + + "@assistant-ui/react": ["@assistant-ui/react@0.12.11", "", { "dependencies": { "@assistant-ui/core": "^0.1.0", "@assistant-ui/store": "^0.2.0", "@assistant-ui/tap": "^0.5.0", "@radix-ui/primitive": "^1.1.3", "@radix-ui/react-compose-refs": "^1.1.2", "@radix-ui/react-context": "^1.1.3", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-primitive": "^2.1.4", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-use-callback-ref": "^1.1.1", "@radix-ui/react-use-escape-keydown": "^1.1.1", "assistant-cloud": "^0.1.18", "assistant-stream": "^0.3.3", "nanoid": "^5.1.6", "react-textarea-autosize": "^8.5.9", "zod": "^4.3.6", "zustand": "^5.0.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^18 || ^19", "react-dom": "^18 || ^19" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-OATx2u8JqYZCUSuR4JuhDFs64IlF+cvyq6DpIv4ZpkZ8HHMkYhS1hame7oxHeJSf9taWO+RcKrVgmG8txNO0Vg=="], + + "@assistant-ui/react-markdown": ["@assistant-ui/react-markdown@0.12.4", "", { "dependencies": { "@radix-ui/react-primitive": "^2.1.4", "@radix-ui/react-use-callback-ref": "^1.1.1", "classnames": "^2.5.1", "react-markdown": "^10.1.0" }, "peerDependencies": { "@assistant-ui/react": "^0.12.11", "@types/react": "*", "react": "^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-6TD9guiuLJxJoOwSjNHUYAVma2ctDCG9uypUqKHE0OUhDwTDD3NsMvTnQ0n0Lh8nnCEwVglOwKKlSEYpV7SnWA=="], + + "@assistant-ui/react-streamdown": ["@assistant-ui/react-streamdown@0.1.3", "", { "dependencies": { "rehype-harden": "^1.1.7", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "streamdown": "^2.1.0" }, "peerDependencies": { "@assistant-ui/react": "^0.12.11", "@streamdown/cjk": "^1.0.0", "@streamdown/code": "^1.0.0", "@streamdown/math": "^1.0.0", "@streamdown/mermaid": "^1.0.0", "@types/react": "*", "react": "^18 || ^19" }, "optionalPeers": ["@streamdown/cjk", "@streamdown/code", "@streamdown/math", "@streamdown/mermaid", "@types/react"] }, "sha512-n1UCjXQ3svmDtJBMJj/vXqz/BqAQBuy7myrXeymz2tD9l+ENQgqu2JY5ir3J19juJTe5lsi/P3+tOJ2C1jc/nw=="], + + "@assistant-ui/store": ["@assistant-ui/store@0.2.0", "", { "dependencies": { "@assistant-ui/core": "^0.1.0", "@assistant-ui/tap": "^0.5.0", "use-effect-event": "^2.0.3" }, "peerDependencies": { "@types/react": "*", "react": "^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-+8Oq7knxhYh1UAGOolvJRlFB3SkLcxnz971oA/iVAxgN/jpp1MH4h6xQwiLoYrwOtcQDSJOSuivoxrDKZdhFrA=="], + + "@assistant-ui/tap": ["@assistant-ui/tap@0.5.0", "", { "peerDependencies": { "@types/react": "*", "react": "^18 || ^19" }, "optionalPeers": ["@types/react", "react"] }, "sha512-UUWXTLtD5/iIs1hSDDF0Ieew2kna0G6RzIVqxlfy5Ei0qPGxJr90ICkPwjaMzELxT/JlL0u2eo+78wFUUBCMcA=="], + + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + + "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], + + "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], + + "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + + "@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.27.3", "", { "dependencies": { "@babel/types": "^7.27.3" } }, "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], + + "@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@7.28.6", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-member-expression-to-functions": "^7.28.5", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/helper-replace-supers": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/traverse": "^7.28.6", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], + + "@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@7.28.5", "", { "dependencies": { "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5" } }, "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], + + "@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@7.27.1", "", { "dependencies": { "@babel/types": "^7.27.1" } }, "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], + + "@babel/helper-replace-supers": ["@babel/helper-replace-supers@7.28.6", "", { "dependencies": { "@babel/helper-member-expression-to-functions": "^7.28.5", "@babel/helper-optimise-call-expression": "^7.27.1", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg=="], + + "@babel/helper-skip-transparent-expression-wrappers": ["@babel/helper-skip-transparent-expression-wrappers@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], + + "@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="], + + "@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], + + "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="], + + "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A=="], + + "@babel/plugin-transform-modules-commonjs": ["@babel/plugin-transform-modules-commonjs@7.28.6", "", { "dependencies": { "@babel/helper-module-transforms": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA=="], + + "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="], + + "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], + + "@babel/plugin-transform-typescript": ["@babel/plugin-transform-typescript@7.28.6", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-create-class-features-plugin": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw=="], + + "@babel/preset-typescript": ["@babel/preset-typescript@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.28.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g=="], + + "@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="], + + "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + + "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + + "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@base-ui/react": ["@base-ui/react@1.2.0", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@base-ui/utils": "0.2.5", "@floating-ui/react-dom": "^2.1.6", "@floating-ui/utils": "^0.2.10", "tabbable": "^6.4.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-O6aEQHcm+QyGTFY28xuwRD3SEJGZOBDpyjN2WvpfWYFVhg+3zfXPysAILqtM0C1kWC82MccOE/v1j+GHXE4qIw=="], + + "@base-ui/utils": ["@base-ui/utils@0.2.5", "", { "dependencies": { "@babel/runtime": "^7.28.6", "@floating-ui/utils": "^0.2.10", "reselect": "^5.1.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-oYC7w0gp76RI5MxprlGLV0wze0SErZaRl3AAkeP3OnNB/UBMb6RqNf6ZSIlxOc9Qp68Ab3C2VOcJQyRs7Xc7Vw=="], + + "@biomejs/biome": ["@biomejs/biome@1.9.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "1.9.4", "@biomejs/cli-darwin-x64": "1.9.4", "@biomejs/cli-linux-arm64": "1.9.4", "@biomejs/cli-linux-arm64-musl": "1.9.4", "@biomejs/cli-linux-x64": "1.9.4", "@biomejs/cli-linux-x64-musl": "1.9.4", "@biomejs/cli-win32-arm64": "1.9.4", "@biomejs/cli-win32-x64": "1.9.4" }, "bin": { "biome": "bin/biome" } }, "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@1.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@1.9.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@1.9.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@1.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA=="], + + "@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="], + + "@cfworker/json-schema": ["@cfworker/json-schema@4.1.1", "", {}, "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og=="], + + "@chevrotain/cst-dts-gen": ["@chevrotain/cst-dts-gen@11.1.1", "", { "dependencies": { "@chevrotain/gast": "11.1.1", "@chevrotain/types": "11.1.1", "lodash-es": "4.17.23" } }, "sha512-fRHyv6/f542qQqiRGalrfJl/evD39mAvbJLCekPazhiextEatq1Jx1K/i9gSd5NNO0ds03ek0Cbo/4uVKmOBcw=="], + + "@chevrotain/gast": ["@chevrotain/gast@11.1.1", "", { "dependencies": { "@chevrotain/types": "11.1.1", "lodash-es": "4.17.23" } }, "sha512-Ko/5vPEYy1vn5CbCjjvnSO4U7GgxyGm+dfUZZJIWTlQFkXkyym0jFYrWEU10hyCjrA7rQtiHtBr0EaZqvHFZvg=="], + + "@chevrotain/regexp-to-ast": ["@chevrotain/regexp-to-ast@11.1.1", "", {}, "sha512-ctRw1OKSXkOrR8VTvOxrQ5USEc4sNrfwXHa1NuTcR7wre4YbjPcKw+82C2uylg/TEwFRgwLmbhlln4qkmDyteg=="], + + "@chevrotain/types": ["@chevrotain/types@11.1.1", "", {}, "sha512-wb2ToxG8LkgPYnKe9FH8oGn3TMCBdnwiuNC5l5y+CtlaVRbCytU0kbVsk6CGrqTL4ZN4ksJa0TXOYbxpbthtqw=="], + + "@chevrotain/utils": ["@chevrotain/utils@11.1.1", "", {}, "sha512-71eTYMzYXYSFPrbg/ZwftSaSDld7UYlS8OQa3lNnn9jzNtpFbaReRRyghzqS7rI3CDaorqpPJJcXGHK+FE1TVQ=="], + + "@dagrejs/dagre": ["@dagrejs/dagre@2.0.4", "", { "dependencies": { "@dagrejs/graphlib": "3.0.4" } }, "sha512-J6vCWTNpicHF4zFlZG1cS5DkGzMr9941gddYkakjrg3ZNev4bbqEgLHFTWiFrcJm7UCRu7olO3K6IRDd9gSGhA=="], + + "@dagrejs/graphlib": ["@dagrejs/graphlib@3.0.4", "", {}, "sha512-HxZ7fCvAwTLCWCO0WjDkzAFQze8LdC6iOpKbetDKHIuDfIgMlIzYzqZ4nxwLlclQX+3ZVeZ1K2OuaOE2WWcyOg=="], + + "@date-fns/tz": ["@date-fns/tz@1.4.1", "", {}, "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA=="], + + "@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.52.0", "", { "dependencies": { "commander": "^11.1.0", "dotenv": "^17.2.1", "eciesjs": "^0.4.10", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "picomatch": "^4.0.2", "which": "^4.0.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-CaQcc8JvtzQhUSm9877b6V4Tb7HCotkcyud9X2YwdqtQKwgljkMRwU96fVYKnzN3V0Hj74oP7Es+vZ0mS+Aa1w=="], + + "@ecies/ciphers": ["@ecies/ciphers@0.2.5", "", { "peerDependencies": { "@noble/ciphers": "^1.0.0" } }, "sha512-GalEZH4JgOMHYYcYmVqnFirFsjZHeoGMDt9IxEnM9F7GRUUyUksJ7Ou53L83WHJq3RWKD3AcBpo0iQh0oMpf8A=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], + + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + + "@eslint/config-array": ["@eslint/config-array@0.21.1", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA=="], + + "@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="], + + "@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="], + + "@eslint/eslintrc": ["@eslint/eslintrc@3.3.4", "", { "dependencies": { "ajv": "^6.14.0", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.3", "strip-json-comments": "^3.1.1" } }, "sha512-4h4MVF8pmBsncB60r0wSJiIeUKTSD4m7FmTFThG8RHlsg9ajqckLm9OraguFGZE4vVdpiI1Q4+hFnisopmG6gQ=="], + + "@eslint/js": ["@eslint/js@9.39.3", "", {}, "sha512-1B1VkCq6FuUNlQvlBYb+1jDu/gV297TIs/OeiaSR9l1H27SVW55ONE1e1Vp16NqP683+xEGzxYtv4XCiDPaQiw=="], + + "@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="], + + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], + + "@floating-ui/core": ["@floating-ui/core@1.7.4", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.7.5", "", { "dependencies": { "@floating-ui/core": "^1.7.4", "@floating-ui/utils": "^0.2.10" } }, "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg=="], + + "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.7", "", { "dependencies": { "@floating-ui/dom": "^1.7.5" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], + + "@fontsource-variable/figtree": ["@fontsource-variable/figtree@5.2.10", "", {}, "sha512-a5Gumbpy3mdd+Yg31g6Qb7CmjYbrfyutJa3bWfP5q8A4GclIOwX7mI+ZuSHsJnw/mHvW6r9oh1AHJcJTIxK4JA=="], + + "@fontsource-variable/inter": ["@fontsource-variable/inter@5.2.8", "", {}, "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ=="], + + "@fontsource-variable/space-grotesk": ["@fontsource-variable/space-grotesk@5.2.10", "", {}, "sha512-yJQO/o35/hAP3CFnpdFTwQku2yzJOae2HIpBmqkOVoxhhXJaQP3g+b6Jrz7u+eI7A5ZdCIf88uMWpBJdFiGr5w=="], + + "@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="], + + "@hugeicons/core-free-icons": ["@hugeicons/core-free-icons@3.1.1", "", {}, "sha512-UpS2lUQFi5sKyJSWwM6rO+BnPLvVz1gsyCpPHeZyVuZqi89YH8ksliza4cwaODqKOZyeXmG8juo1ty4QtQofkg=="], + + "@hugeicons/react": ["@hugeicons/react@1.1.5", "", { "peerDependencies": { "react": ">=16.0.0" } }, "sha512-JX/iDz3oO7hWdVqbjwFwRrAjHk8h2vI+mBkNzp4JcXG3t4idoupfjon73nLOA7cr27m0M8hrRC1Q2h6nEBGKVA=="], + + "@huggingface/hub": ["@huggingface/hub@2.10.3", "", { "dependencies": { "@huggingface/tasks": "^0.19.85" }, "optionalDependencies": { "cli-progress": "^3.12.0" }, "bin": { "hfjs": "dist/cli.js" } }, "sha512-qSk4FcVFdTGx0lNpFyy7p2KwgAPCsjM2+tupG/MGToEvUGVLsy+dCmela1BcU/VvJNweCtnH5HwdNr7IQa4Zzw=="], + + "@huggingface/tasks": ["@huggingface/tasks@0.19.86", "", {}, "sha512-eab/6J9m+0Z8xw3X2EPPioMLIjFNYjox9nONTmzzgWj0vq6+iMWsMt4tlwrZKLlxxJbFp+acn20VXZi3ejLlng=="], + + "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], + + "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + + "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], + + "@iconify/utils": ["@iconify/utils@3.1.0", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "mlly": "^1.8.0" } }, "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw=="], + + "@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + + "@inquirer/ansi": ["@inquirer/ansi@1.0.2", "", {}, "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ=="], + + "@inquirer/confirm": ["@inquirer/confirm@5.1.21", "", { "dependencies": { "@inquirer/core": "^10.3.2", "@inquirer/type": "^3.0.10" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ=="], + + "@inquirer/core": ["@inquirer/core@10.3.2", "", { "dependencies": { "@inquirer/ansi": "^1.0.2", "@inquirer/figures": "^1.0.15", "@inquirer/type": "^3.0.10", "cli-width": "^4.1.0", "mute-stream": "^2.0.0", "signal-exit": "^4.1.0", "wrap-ansi": "^6.2.0", "yoctocolors-cjs": "^2.1.3" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A=="], + + "@inquirer/figures": ["@inquirer/figures@1.0.15", "", {}, "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g=="], + + "@inquirer/type": ["@inquirer/type@3.0.10", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@langchain/core": ["@langchain/core@1.1.28", "", { "dependencies": { "@cfworker/json-schema": "^4.0.2", "ansi-styles": "^5.0.0", "camelcase": "6", "decamelize": "1.2.0", "js-tiktoken": "^1.0.12", "langsmith": ">=0.5.0 <1.0.0", "mustache": "^4.2.0", "p-queue": "^6.6.2", "uuid": "^10.0.0", "zod": "^3.25.76 || ^4" } }, "sha512-6FAGdezEp8zHY92LtnsAiv54KaG41nBdsuukk+R+1484edV20cVOyIc36ANuGKPx0pmYFCBWhCUdO0jxB/zn2Q=="], + + "@mermaid-js/parser": ["@mermaid-js/parser@1.0.0", "", { "dependencies": { "langium": "^4.0.0" } }, "sha512-vvK0Hi/VWndxoh03Mmz6wa1KDriSPjS2XMZL/1l19HFwygiObEEoEwSDxOqyLzzAI6J2PU3261JjTMTO7x+BPw=="], + + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.27.1", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA=="], + + "@mswjs/interceptors": ["@mswjs/interceptors@0.41.3", "", { "dependencies": { "@open-draft/deferred-promise": "^2.2.0", "@open-draft/logger": "^0.3.0", "@open-draft/until": "^2.0.0", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "strict-event-emitter": "^0.5.1" } }, "sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA=="], + + "@next/env": ["@next/env@16.1.6", "", {}, "sha512-N1ySLuZjnAtN3kFnwhAwPvZah8RJxKasD7x1f8shFqhncnWZn4JMfg37diLNuoHsLAlrDfM3g4mawVdtAG8XLQ=="], + + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@16.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wTzYulosJr/6nFnqGW7FrG3jfUUlEf8UjGA0/pyypJl42ExdVgC6xJgcXQ+V8QFn6niSG2Pb8+MIG1mZr2vczw=="], + + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@16.1.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-BLFPYPDO+MNJsiDWbeVzqvYd4NyuRrEYVB5k2N3JfWncuHAy2IVwMAOlVQDFjj+krkWzhY2apvmekMkfQR0CUQ=="], + + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@16.1.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-OJYkCd5pj/QloBvoEcJ2XiMnlJkRv9idWA/j0ugSuA34gMT6f5b7vOiCQHVRpvStoZUknhl6/UxOXL4OwtdaBw=="], + + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@16.1.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-S4J2v+8tT3NIO9u2q+S0G5KdvNDjXfAv06OhfOzNDaBn5rw84DGXWndOEB7d5/x852A20sW1M56vhC/tRVbccQ=="], + + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@16.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-2eEBDkFlMMNQnkTyPBhQOAyn2qMxyG2eE7GPH2WIDGEpEILcBPI/jdSv4t6xupSP+ot/jkfrCShLAa7+ZUPcJQ=="], + + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@16.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-oicJwRlyOoZXVlxmIMaTq7f8pN9QNbdes0q2FXfRsPhfCi8n8JmOZJm5oo1pwDaFbnnD421rVU409M3evFbIqg=="], + + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@16.1.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-gQmm8izDTPgs+DCWH22kcDmuUp7NyiJgEl18bcr8irXA5N2m2O+JQIr6f3ct42GOs9c0h8QF3L5SzIxcYAAXXw=="], + + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@16.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-NRfO39AIrzBnixKbjuo2YiYhB6o9d8v/ymU9m/Xk8cyVk+k7XylniXkHwjs4s70wedVffc6bQNbufk5v0xEm0A=="], + + "@noble/ciphers": ["@noble/ciphers@1.3.0", "", {}, "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw=="], + + "@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="], + + "@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], + + "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], + + "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + + "@open-draft/deferred-promise": ["@open-draft/deferred-promise@2.2.0", "", {}, "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA=="], + + "@open-draft/logger": ["@open-draft/logger@0.3.0", "", { "dependencies": { "is-node-process": "^1.2.0", "outvariant": "^1.4.0" } }, "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ=="], + + "@open-draft/until": ["@open-draft/until@2.1.0", "", {}, "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg=="], + + "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], + + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], + + "@radix-ui/react-accessible-icon": ["@radix-ui/react-accessible-icon@1.1.7", "", { "dependencies": { "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A=="], + + "@radix-ui/react-accordion": ["@radix-ui/react-accordion@1.2.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA=="], + + "@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="], + + "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], + + "@radix-ui/react-aspect-ratio": ["@radix-ui/react-aspect-ratio@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g=="], + + "@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.10", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog=="], + + "@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw=="], + + "@radix-ui/react-collapsible": ["@radix-ui/react-collapsible@1.1.12", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA=="], + + "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], + + "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], + + "@radix-ui/react-context": ["@radix-ui/react-context@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="], + + "@radix-ui/react-context-menu": ["@radix-ui/react-context-menu@2.2.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww=="], + + "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="], + + "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="], + + "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="], + + "@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="], + + "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="], + + "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="], + + "@radix-ui/react-form": ["@radix-ui/react-form@0.1.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ=="], + + "@radix-ui/react-hover-card": ["@radix-ui/react-hover-card@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg=="], + + "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], + + "@radix-ui/react-label": ["@radix-ui/react-label@2.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A=="], + + "@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="], + + "@radix-ui/react-menubar": ["@radix-ui/react-menubar@1.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA=="], + + "@radix-ui/react-navigation-menu": ["@radix-ui/react-navigation-menu@1.2.14", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w=="], + + "@radix-ui/react-one-time-password-field": ["@radix-ui/react-one-time-password-field@0.1.8", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg=="], + + "@radix-ui/react-password-toggle-field": ["@radix-ui/react-password-toggle-field@0.1.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-is-hydrated": "0.1.0" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw=="], + + "@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA=="], + + "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="], + + "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], + + "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="], + + "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], + + "@radix-ui/react-progress": ["@radix-ui/react-progress@1.1.7", "", { "dependencies": { "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg=="], + + "@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.3.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ=="], + + "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="], + + "@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="], + + "@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="], + + "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="], + + "@radix-ui/react-slider": ["@radix-ui/react-slider@1.3.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw=="], + + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], + + "@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="], + + "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="], + + "@radix-ui/react-toast": ["@radix-ui/react-toast@1.2.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g=="], + + "@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ=="], + + "@radix-ui/react-toggle-group": ["@radix-ui/react-toggle-group@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q=="], + + "@radix-ui/react-toolbar": ["@radix-ui/react-toolbar@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-toggle-group": "1.1.11" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg=="], + + "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="], + + "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], + + "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="], + + "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="], + + "@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="], + + "@radix-ui/react-use-is-hydrated": ["@radix-ui/react-use-is-hydrated@0.1.0", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA=="], + + "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], + + "@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="], + + "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="], + + "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="], + + "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="], + + "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], + + "@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.59.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.59.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.59.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.59.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.59.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.59.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.59.0", "", { "os": "none", "cpu": "arm64" }, "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.59.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.59.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="], + + "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="], + + "@shikijs/core": ["@shikijs/core@3.22.0", "", { "dependencies": { "@shikijs/types": "3.22.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-iAlTtSDDbJiRpvgL5ugKEATDtHdUVkqgHDm/gbD2ZS9c88mx7G1zSYjjOxp5Qa0eaW0MAQosFRmJSk354PRoQA=="], + + "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.22.0", "", { "dependencies": { "@shikijs/types": "3.22.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-jdKhfgW9CRtj3Tor0L7+yPwdG3CgP7W+ZEqSsojrMzCjD1e0IxIbwUMDDpYlVBlC08TACg4puwFGkZfLS+56Tw=="], + + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.22.0", "", { "dependencies": { "@shikijs/types": "3.22.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-DyXsOG0vGtNtl7ygvabHd7Mt5EY8gCNqR9Y7Lpbbd/PbJvgWrqaKzH1JW6H6qFkuUa8aCxoiYVv8/YfFljiQxA=="], + + "@shikijs/langs": ["@shikijs/langs@3.22.0", "", { "dependencies": { "@shikijs/types": "3.22.0" } }, "sha512-x/42TfhWmp6H00T6uwVrdTJGKgNdFbrEdhaDwSR5fd5zhQ1Q46bHq9EO61SCEWJR0HY7z2HNDMaBZp8JRmKiIA=="], + + "@shikijs/themes": ["@shikijs/themes@3.22.0", "", { "dependencies": { "@shikijs/types": "3.22.0" } }, "sha512-o+tlOKqsr6FE4+mYJG08tfCFDS+3CG20HbldXeVoyP+cYSUxDhrFf3GPjE60U55iOkkjbpY2uC3It/eeja35/g=="], + + "@shikijs/types": ["@shikijs/types@3.22.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-491iAekgKDBFE67z70Ok5a8KBMsQ2IJwOWw3us/7ffQkIBCyOQfm/aNwVMBUriP02QshIfgHCBSIYAl3u2eWjg=="], + + "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], + + "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], + + "@streamdown/cjk": ["@streamdown/cjk@1.0.2", "", { "dependencies": { "remark-cjk-friendly": "^1.2.3", "remark-cjk-friendly-gfm-strikethrough": "^1.2.3", "unist-util-visit": "^5.0.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-5OOuZjj2Lnae92Zmg2gA5hloSbcKj25gv+QY4iKbYI+iRsiGWbgmYxmgxNUSO9SR6BKOCy783UHN1HM/QEUpdw=="], + + "@streamdown/code": ["@streamdown/code@1.0.3", "", { "dependencies": { "shiki": "^3.19.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-3Ym5TCLcGhrHY2qBaUVWpqNRtxnZvqh4Y5Qm/pTIKA4AmEWwAAoYjZnxG7mOsvOpWVWiDwETjUtchNL1XzQEAw=="], + + "@streamdown/math": ["@streamdown/math@1.0.2", "", { "dependencies": { "katex": "^0.16.27", "rehype-katex": "^7.0.1", "remark-math": "^6.0.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-r8Ur9/lBuFnzZAFdEWrLUF2s/gRwRRRwruqltdZibyjbCBnuW7SJbFm26nXqvpJPW/gzpBUMrBVBzd88z05D5g=="], + + "@streamdown/mermaid": ["@streamdown/mermaid@1.0.2", "", { "dependencies": { "mermaid": "^11.12.2" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-Fr/4sBWnAeSnxM3PcrV/+DiZe5oPMq9gOkUIAH7ZauJeuwrZ/DVzD4g0zlav6AH0axh2m/sOfrfLtY5aLT7niw=="], + + "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.2.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.1" } }, "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.1", "@tailwindcss/oxide-darwin-arm64": "4.2.1", "@tailwindcss/oxide-darwin-x64": "4.2.1", "@tailwindcss/oxide-freebsd-x64": "4.2.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", "@tailwindcss/oxide-linux-x64-musl": "4.2.1", "@tailwindcss/oxide-wasm32-wasi": "4.2.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" } }, "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.1", "", { "os": "android", "cpu": "arm64" }, "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.1", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ=="], + + "@tailwindcss/vite": ["@tailwindcss/vite@4.2.1", "", { "dependencies": { "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "tailwindcss": "4.2.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w=="], + + "@tanstack/history": ["@tanstack/history@1.161.4", "", {}, "sha512-Kp/WSt411ZWYvgXy6uiv5RmhHrz9cAml05AQPrtdAp7eUqvIDbMGPnML25OKbzR3RJ1q4wgENxDTvlGPa9+Mww=="], + + "@tanstack/react-router": ["@tanstack/react-router@1.162.9", "", { "dependencies": { "@tanstack/history": "1.161.4", "@tanstack/react-store": "^0.9.1", "@tanstack/router-core": "1.162.9", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-APbwKAF+YgSNpHAaA+FdgrmfI/7+qa9hApuVO9+P0IVksJayNIWFQ/6AFG90WQiTYWk64RI1R9cFV2K9Z+j2pQ=="], + + "@tanstack/react-store": ["@tanstack/react-store@0.9.1", "", { "dependencies": { "@tanstack/store": "0.9.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-YzJLnRvy5lIEFTLWBAZmcOjK3+2AepnBv/sr6NZmiqJvq7zTQggyK99Gw8fqYdMdHPQWXjz0epFKJXC+9V2xDA=="], + + "@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="], + + "@tanstack/router-core": ["@tanstack/router-core@1.162.9", "", { "dependencies": { "@tanstack/history": "1.161.4", "@tanstack/store": "^0.9.1", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-eG7C0oVtZbFOkfvsaF8UyGuNjEc1BfIfD5EzQNwG4vqLKOAyY5SMFBCNjabAi2sglRhL0ZOwKon1SExusU5fxA=="], + + "@tanstack/store": ["@tanstack/store@0.9.1", "", {}, "sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg=="], + + "@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="], + + "@toolwind/corner-shape": ["@toolwind/corner-shape@0.0.8-3", "", { "dependencies": { "@types/node": "^20.4.1" } }, "sha512-MPIF81F2bhtXbzEeXF0vnL+PKpnopCHOzBspOkK8osMzWQvPUujZn2XZOMdsu4DF6wsVbbRYQtdsJr486HmIPQ=="], + + "@ts-morph/common": ["@ts-morph/common@0.27.0", "", { "dependencies": { "fast-glob": "^3.3.3", "minimatch": "^10.0.1", "path-browserify": "^1.0.1" } }, "sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ=="], + + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], + + "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], + + "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], + + "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + + "@types/canvas-confetti": ["@types/canvas-confetti@1.9.0", "", {}, "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg=="], + + "@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="], + + "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], + + "@types/d3-axis": ["@types/d3-axis@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw=="], + + "@types/d3-brush": ["@types/d3-brush@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A=="], + + "@types/d3-chord": ["@types/d3-chord@3.0.6", "", {}, "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg=="], + + "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + + "@types/d3-contour": ["@types/d3-contour@3.0.6", "", { "dependencies": { "@types/d3-array": "*", "@types/geojson": "*" } }, "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg=="], + + "@types/d3-delaunay": ["@types/d3-delaunay@6.0.4", "", {}, "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw=="], + + "@types/d3-dispatch": ["@types/d3-dispatch@3.0.7", "", {}, "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA=="], + + "@types/d3-drag": ["@types/d3-drag@3.0.7", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="], + + "@types/d3-dsv": ["@types/d3-dsv@3.0.7", "", {}, "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g=="], + + "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], + + "@types/d3-fetch": ["@types/d3-fetch@3.0.7", "", { "dependencies": { "@types/d3-dsv": "*" } }, "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA=="], + + "@types/d3-force": ["@types/d3-force@3.0.10", "", {}, "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw=="], + + "@types/d3-format": ["@types/d3-format@3.0.4", "", {}, "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g=="], + + "@types/d3-geo": ["@types/d3-geo@3.1.0", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ=="], + + "@types/d3-hierarchy": ["@types/d3-hierarchy@3.1.7", "", {}, "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg=="], + + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], + + "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], + + "@types/d3-polygon": ["@types/d3-polygon@3.0.2", "", {}, "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA=="], + + "@types/d3-quadtree": ["@types/d3-quadtree@3.0.6", "", {}, "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg=="], + + "@types/d3-random": ["@types/d3-random@3.0.3", "", {}, "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ=="], + + "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], + + "@types/d3-scale-chromatic": ["@types/d3-scale-chromatic@3.1.0", "", {}, "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ=="], + + "@types/d3-selection": ["@types/d3-selection@3.0.11", "", {}, "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="], + + "@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="], + + "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], + + "@types/d3-time-format": ["@types/d3-time-format@4.0.3", "", {}, "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg=="], + + "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], + + "@types/d3-transition": ["@types/d3-transition@3.0.9", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg=="], + + "@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="], + + "@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], + + "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], + + "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], + + "@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="], + + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + + "@types/katex": ["@types/katex@0.16.8", "", {}, "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg=="], + + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], + + "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], + + "@types/node": ["@types/node@24.10.13", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg=="], + + "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], + + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + + "@types/statuses": ["@types/statuses@2.0.6", "", {}, "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA=="], + + "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="], + + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + + "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], + + "@types/uuid": ["@types/uuid@10.0.0", "", {}, "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ=="], + + "@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="], + + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.56.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/type-utils": "8.56.1", "@typescript-eslint/utils": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.56.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A=="], + + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.56.1", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg=="], + + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.56.1", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.56.1", "@typescript-eslint/types": "^8.56.1", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ=="], + + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.56.1", "", { "dependencies": { "@typescript-eslint/types": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1" } }, "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w=="], + + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.56.1", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ=="], + + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.56.1", "", { "dependencies": { "@typescript-eslint/types": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/utils": "8.56.1", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.56.1", "", {}, "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw=="], + + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.56.1", "", { "dependencies": { "@typescript-eslint/project-service": "8.56.1", "@typescript-eslint/tsconfig-utils": "8.56.1", "@typescript-eslint/types": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg=="], + + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.56.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/types": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA=="], + + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.56.1", "", { "dependencies": { "@typescript-eslint/types": "8.56.1", "eslint-visitor-keys": "^5.0.0" } }, "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw=="], + + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], + + "@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.4", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA=="], + + "@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="], + + "@xyflow/react": ["@xyflow/react@12.10.1", "", { "dependencies": { "@xyflow/system": "0.0.75", "classcat": "^5.0.3", "zustand": "^4.4.0" }, "peerDependencies": { "react": ">=17", "react-dom": ">=17" } }, "sha512-5eSWtIK/+rkldOuFbOOz44CRgQRjtS9v5nufk77DV+XBnfCGL9HAQ8PG00o2ZYKqkEU/Ak6wrKC95Tu+2zuK3Q=="], + + "@xyflow/system": ["@xyflow/system@0.0.75", "", { "dependencies": { "@types/d3-drag": "^3.0.7", "@types/d3-interpolate": "^3.0.4", "@types/d3-selection": "^3.0.10", "@types/d3-transition": "^3.0.8", "@types/d3-zoom": "^3.0.8", "d3-drag": "^3.0.0", "d3-interpolate": "^3.0.1", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0" } }, "sha512-iXs+AGFLi8w/VlAoc/iSxk+CxfT6o64Uw/k0CKASOPqjqz6E0rb5jFZgJtXGZCpfQI6OQpu5EnumP5fGxQheaQ=="], + + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + + "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + + "ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], + + "assistant-cloud": ["assistant-cloud@0.1.18", "", { "dependencies": { "assistant-stream": "^0.3.3" } }, "sha512-6tq2jPGIBjkjsLQ/Fd4r6PGj4hf05oM2jBl4hBs7YIkaJ3qBVUWiHary2+faNpsPOoY71brsVukl/qz5B1rQkA=="], + + "assistant-stream": ["assistant-stream@0.3.3", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "nanoid": "^5.1.6", "secure-json-parse": "^4.1.0" } }, "sha512-Ne/uTseMIiZx740dTbr/SWxONM8nYj4Z5BRmUfqQN+TNgtOCgWOlC/oTUQ+A7LIUHtmGbcoyZwDf8yd2RASnDA=="], + + "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], + + "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="], + + "bluebird": ["bluebird@3.4.7", "", {}, "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA=="], + + "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], + + "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], + + "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], + + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + + "camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001774", "", {}, "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA=="], + + "canvas-confetti": ["canvas-confetti@1.9.4", "", {}, "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw=="], + + "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], + + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], + + "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], + + "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], + + "character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="], + + "chevrotain": ["chevrotain@11.1.1", "", { "dependencies": { "@chevrotain/cst-dts-gen": "11.1.1", "@chevrotain/gast": "11.1.1", "@chevrotain/regexp-to-ast": "11.1.1", "@chevrotain/types": "11.1.1", "@chevrotain/utils": "11.1.1", "lodash-es": "4.17.23" } }, "sha512-f0yv5CPKaFxfsPTBzX7vGuim4oIC1/gcS7LUGdBSwl2dU6+FON6LVUksdOo1qJjoUvXNn45urgh8C+0a24pACQ=="], + + "chevrotain-allstar": ["chevrotain-allstar@0.3.1", "", { "dependencies": { "lodash-es": "^4.17.21" }, "peerDependencies": { "chevrotain": "^11.0.0" } }, "sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw=="], + + "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], + + "classcat": ["classcat@5.0.5", "", {}, "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w=="], + + "classnames": ["classnames@2.5.1", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="], + + "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], + + "cli-progress": ["cli-progress@3.12.0", "", { "dependencies": { "string-width": "^4.2.3" } }, "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A=="], + + "cli-spinners": ["cli-spinners@2.9.2", "", {}, "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="], + + "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], + + "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], + + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="], + + "code-block-writer": ["code-block-writer@13.0.3", "", {}, "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], + + "commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], + + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + + "confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], + + "console-table-printer": ["console-table-printer@2.15.0", "", { "dependencies": { "simple-wcswidth": "^1.1.2" } }, "sha512-SrhBq4hYVjLCkBVOWaTzceJalvn5K1Zq5aQA6wXC/cYjI3frKWNPEMK3sZsJfNNQApvCQmgBcc13ZKmFj8qExw=="], + + "content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + + "cookie-es": ["cookie-es@2.0.0", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], + + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + + "cose-base": ["cose-base@1.0.3", "", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="], + + "cosmiconfig": ["cosmiconfig@9.0.0", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "cytoscape": ["cytoscape@3.33.1", "", {}, "sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ=="], + + "cytoscape-cose-bilkent": ["cytoscape-cose-bilkent@4.1.0", "", { "dependencies": { "cose-base": "^1.0.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ=="], + + "cytoscape-fcose": ["cytoscape-fcose@2.2.0", "", { "dependencies": { "cose-base": "^2.2.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ=="], + + "d3": ["d3@7.9.0", "", { "dependencies": { "d3-array": "3", "d3-axis": "3", "d3-brush": "3", "d3-chord": "3", "d3-color": "3", "d3-contour": "4", "d3-delaunay": "6", "d3-dispatch": "3", "d3-drag": "3", "d3-dsv": "3", "d3-ease": "3", "d3-fetch": "3", "d3-force": "3", "d3-format": "3", "d3-geo": "3", "d3-hierarchy": "3", "d3-interpolate": "3", "d3-path": "3", "d3-polygon": "3", "d3-quadtree": "3", "d3-random": "3", "d3-scale": "4", "d3-scale-chromatic": "3", "d3-selection": "3", "d3-shape": "3", "d3-time": "3", "d3-time-format": "4", "d3-timer": "3", "d3-transition": "3", "d3-zoom": "3" } }, "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA=="], + + "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], + + "d3-axis": ["d3-axis@3.0.0", "", {}, "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw=="], + + "d3-brush": ["d3-brush@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "3", "d3-transition": "3" } }, "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ=="], + + "d3-chord": ["d3-chord@3.0.1", "", { "dependencies": { "d3-path": "1 - 3" } }, "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g=="], + + "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + + "d3-contour": ["d3-contour@4.0.2", "", { "dependencies": { "d3-array": "^3.2.0" } }, "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA=="], + + "d3-delaunay": ["d3-delaunay@6.0.4", "", { "dependencies": { "delaunator": "5" } }, "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A=="], + + "d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="], + + "d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="], + + "d3-dsv": ["d3-dsv@3.0.1", "", { "dependencies": { "commander": "7", "iconv-lite": "0.6", "rw": "1" }, "bin": { "csv2json": "bin/dsv2json.js", "csv2tsv": "bin/dsv2dsv.js", "dsv2dsv": "bin/dsv2dsv.js", "dsv2json": "bin/dsv2json.js", "json2csv": "bin/json2dsv.js", "json2dsv": "bin/json2dsv.js", "json2tsv": "bin/json2dsv.js", "tsv2csv": "bin/dsv2dsv.js", "tsv2json": "bin/dsv2json.js" } }, "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q=="], + + "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], + + "d3-fetch": ["d3-fetch@3.0.1", "", { "dependencies": { "d3-dsv": "1 - 3" } }, "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw=="], + + "d3-force": ["d3-force@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="], + + "d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="], + + "d3-geo": ["d3-geo@3.1.1", "", { "dependencies": { "d3-array": "2.5.0 - 3" } }, "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q=="], + + "d3-hierarchy": ["d3-hierarchy@3.1.2", "", {}, "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA=="], + + "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], + + "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], + + "d3-polygon": ["d3-polygon@3.0.1", "", {}, "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg=="], + + "d3-quadtree": ["d3-quadtree@3.0.1", "", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="], + + "d3-random": ["d3-random@3.0.1", "", {}, "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ=="], + + "d3-sankey": ["d3-sankey@0.12.3", "", { "dependencies": { "d3-array": "1 - 2", "d3-shape": "^1.2.0" } }, "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ=="], + + "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], + + "d3-scale-chromatic": ["d3-scale-chromatic@3.1.0", "", { "dependencies": { "d3-color": "1 - 3", "d3-interpolate": "1 - 3" } }, "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ=="], + + "d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="], + + "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], + + "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], + + "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], + + "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + + "d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="], + + "d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="], + + "dagre-d3-es": ["dagre-d3-es@7.0.13", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q=="], + + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + + "date-fns": ["date-fns@4.1.0", "", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], + + "date-fns-jalali": ["date-fns-jalali@4.1.0-0", "", {}, "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg=="], + + "dayjs": ["dayjs@1.11.19", "", {}, "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="], + + "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], + + "decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="], + + "dedent": ["dedent@1.7.1", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-9JmrhGZpOlEgOLdQgSm0zxFaYoQon408V1v49aqTWuXENVlnCuY9JBZcXZiCsZQWDjTm5Qf/nIvAy77mXDAjEg=="], + + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + + "deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="], + + "default-browser": ["default-browser@5.5.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw=="], + + "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], + + "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], + + "delaunator": ["delaunator@5.0.1", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], + + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + + "dexie": ["dexie@4.3.0", "", {}, "sha512-5EeoQpJvMKHe6zWt/FSIIuRa3CWlZeIl6zKXt+Lz7BU6RoRRLgX9dZEynRfXrkLcldKYCBiz7xekTEylnie1Ug=="], + + "diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], + + "dingbat-to-unicode": ["dingbat-to-unicode@1.0.1", "", {}, "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w=="], + + "dompurify": ["dompurify@3.3.1", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q=="], + + "dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="], + + "duck": ["duck@0.1.12", "", { "dependencies": { "underscore": "^1.13.1" } }, "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "eciesjs": ["eciesjs@0.4.17", "", { "dependencies": { "@ecies/ciphers": "^0.2.5", "@noble/ciphers": "^1.3.0", "@noble/curves": "^1.9.7", "@noble/hashes": "^1.8.0" } }, "sha512-TOOURki4G7sD1wDCjj7NfLaXZZ49dFOeEb5y39IXpb8p0hRzVvfvzZHOi5JcT+PpyAbi/Y+lxPb8eTag2WYH8w=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.302", "", {}, "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg=="], + + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + + "enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="], + + "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + + "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], + + "error-ex": ["error-ex@1.3.4", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "es-toolkit": ["es-toolkit@1.44.0", "", {}, "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg=="], + + "esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "eslint": ["eslint@9.39.3", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.39.3", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-VmQ+sifHUbI/IcSopBCF/HO3YiHQx/AVd3UVyYL6weuwW+HvON9VYn5l6Zl1WZzPWXPNZrSQpxwkkZ/VuvJZzg=="], + + "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.0.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA=="], + + "eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.26", "", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ=="], + + "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + + "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], + + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + + "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], + + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], + + "execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="], + + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + + "express-rate-limit": ["express-rate-limit@8.2.1", "", { "dependencies": { "ip-address": "10.0.1" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g=="], + + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + + "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], + + "figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="], + + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], + + "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], + + "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "framer-motion": ["framer-motion@11.18.2", "", { "dependencies": { "motion-dom": "^11.18.1", "motion-utils": "^11.18.1", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "fs-extra": ["fs-extra@11.3.3", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-VWSRii4t0AFm6ixFFmLLx1t7wS1gh+ckoa84aOeapGum0h+EZd1EhEumSB+ZdDLnEPuucsVB9oB7cxJHap6Afg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "fuzzysort": ["fuzzysort@3.1.0", "", {}, "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ=="], + + "fzf": ["fzf@0.5.2", "", {}, "sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], + + "get-own-enumerable-keys": ["get-own-enumerable-keys@1.0.0", "", {}, "sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "graphql": ["graphql@16.13.0", "", {}, "sha512-uSisMYERbaB9bkA9M4/4dnqyktaEkf1kMHNKq/7DHyxVeWqHQ2mBmVqm5u6/FVHwF3iCNalKcg82Zfl+tffWoA=="], + + "hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "hast-util-from-dom": ["hast-util-from-dom@5.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "hastscript": "^9.0.0", "web-namespaces": "^2.0.0" } }, "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q=="], + + "hast-util-from-html": ["hast-util-from-html@2.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.1.0", "hast-util-from-parse5": "^8.0.0", "parse5": "^7.0.0", "vfile": "^6.0.0", "vfile-message": "^4.0.0" } }, "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw=="], + + "hast-util-from-html-isomorphic": ["hast-util-from-html-isomorphic@2.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-from-dom": "^5.0.0", "hast-util-from-html": "^2.0.0", "unist-util-remove-position": "^5.0.0" } }, "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw=="], + + "hast-util-from-parse5": ["hast-util-from-parse5@8.0.3", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "devlop": "^1.0.0", "hastscript": "^9.0.0", "property-information": "^7.0.0", "vfile": "^6.0.0", "vfile-location": "^5.0.0", "web-namespaces": "^2.0.0" } }, "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg=="], + + "hast-util-is-element": ["hast-util-is-element@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g=="], + + "hast-util-parse-selector": ["hast-util-parse-selector@4.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A=="], + + "hast-util-raw": ["hast-util-raw@9.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-from-parse5": "^8.0.0", "hast-util-to-parse5": "^8.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "parse5": "^7.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw=="], + + "hast-util-sanitize": ["hast-util-sanitize@5.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "unist-util-position": "^5.0.0" } }, "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg=="], + + "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], + + "hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="], + + "hast-util-to-parse5": ["hast-util-to-parse5@8.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA=="], + + "hast-util-to-text": ["hast-util-to-text@4.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "hast-util-is-element": "^3.0.0", "unist-util-find-after": "^5.0.0" } }, "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A=="], + + "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], + + "hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], + + "headers-polyfill": ["headers-polyfill@4.0.3", "", {}, "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ=="], + + "hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], + + "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], + + "hono": ["hono@4.12.2", "", {}, "sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg=="], + + "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="], + + "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], + + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], + + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="], + + "immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="], + + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="], + + "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], + + "ip-address": ["ip-address@10.0.1", "", {}, "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + + "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], + + "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], + + "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], + + "is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="], + + "is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], + + "is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="], + + "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], + + "is-interactive": ["is-interactive@2.0.0", "", {}, "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ=="], + + "is-node-process": ["is-node-process@1.2.0", "", {}, "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "is-obj": ["is-obj@3.0.0", "", {}, "sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ=="], + + "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + + "is-regexp": ["is-regexp@3.1.0", "", {}, "sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA=="], + + "is-stream": ["is-stream@4.0.1", "", {}, "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A=="], + + "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], + + "is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], + + "isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], + + "isbot": ["isbot@5.1.35", "", {}, "sha512-waFfC72ZNfwLLuJ2iLaoVaqcNo+CAaLR7xCpAn0Y5WfGzkNHv7ZN39Vbi1y+kb+Zs46XHOX3tZNExroFUPX+Kg=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + + "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], + + "js-tiktoken": ["js-tiktoken@1.0.21", "", { "dependencies": { "base64-js": "^1.5.1" } }, "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + + "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "jsonfile": ["jsonfile@6.2.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg=="], + + "jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="], + + "katex": ["katex@0.16.33", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-q3N5u+1sY9Bu7T4nlXoiRBXWfwSefNGoKeOwekV+gw0cAXQlz2Ww6BLcmBxVDeXBMUDQv6fK5bcNaJLxob3ZQA=="], + + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + + "khroma": ["khroma@2.1.0", "", {}, "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="], + + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + + "langium": ["langium@4.2.1", "", { "dependencies": { "chevrotain": "~11.1.1", "chevrotain-allstar": "~0.3.1", "vscode-languageserver": "~9.0.1", "vscode-languageserver-textdocument": "~1.0.11", "vscode-uri": "~3.1.0" } }, "sha512-zu9QWmjpzJcomzdJQAHgDVhLGq5bLosVak1KVa40NzQHXfqr4eAHupvnPOVXEoLkg6Ocefvf/93d//SB7du4YQ=="], + + "langsmith": ["langsmith@0.5.6", "", { "dependencies": { "@types/uuid": "^10.0.0", "chalk": "^5.6.2", "console-table-printer": "^2.12.1", "p-queue": "^6.6.2", "semver": "^7.6.3", "uuid": "^10.0.0" }, "peerDependencies": { "@opentelemetry/api": "*", "@opentelemetry/exporter-trace-otlp-proto": "*", "@opentelemetry/sdk-trace-base": "*", "openai": "*" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/exporter-trace-otlp-proto", "@opentelemetry/sdk-trace-base", "openai"] }, "sha512-T/RA2l2MsTYX0z1aW8rQ2hBQZEOuXV2v/6tkfG6R5EotJTKMpw1dERCbvP8ezOP8otyWfnNlQA88ZnMRsQ7CHA=="], + + "layout-base": ["layout-base@1.0.2", "", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="], + + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + + "lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="], + + "lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.31.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.31.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.31.1", "", { "os": "linux", "cpu": "arm" }, "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.31.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="], + + "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + + "lodash-es": ["lodash-es@4.17.23", "", {}, "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg=="], + + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + + "log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="], + + "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], + + "lop": ["lop@0.4.2", "", { "dependencies": { "duck": "^0.1.12", "option": "~0.2.1", "underscore": "^1.13.1" } }, "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw=="], + + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "lucide-react": ["lucide-react@0.575.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "mammoth": ["mammoth@1.11.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.6", "argparse": "~1.0.3", "base64-js": "^1.5.1", "bluebird": "~3.4.0", "dingbat-to-unicode": "^1.0.1", "jszip": "^3.7.1", "lop": "^0.4.2", "path-is-absolute": "^1.0.0", "underscore": "^1.13.1", "xmlbuilder": "^10.0.0" }, "bin": { "mammoth": "bin/mammoth" } }, "sha512-BcEqqY/BOwIcI1iR5tqyVlqc3KIaMRa4egSoK83YAVrBf6+yqdAAbtUcFDCWX8Zef8/fgNZ6rl4VUv+vVX8ddQ=="], + + "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], + + "marked": ["marked@17.0.3", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-jt1v2ObpyOKR8p4XaUJVk3YWRJ5n+i4+rjQopxvV32rSndTJXvIzuUdWWIy/1pFQMkQmvTXawzDNqOH/CUmx6A=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="], + + "mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="], + + "mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="], + + "mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="], + + "mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="], + + "mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="], + + "mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="], + + "mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="], + + "mdast-util-math": ["mdast-util-math@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "longest-streak": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.1.0", "unist-util-remove-position": "^5.0.0" } }, "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w=="], + + "mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="], + + "mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="], + + "mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="], + + "mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="], + + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], + + "mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="], + + "mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="], + + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], + + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + + "mermaid": ["mermaid@11.12.3", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.1", "@mermaid-js/parser": "^1.0.0", "@types/d3": "^7.4.3", "cytoscape": "^3.29.3", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.13", "dayjs": "^1.11.18", "dompurify": "^3.2.5", "katex": "^0.16.22", "khroma": "^2.1.0", "lodash-es": "^4.17.23", "marked": "^16.2.1", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0" } }, "sha512-wN5ZSgJQIC+CHJut9xaKWsknLxaFBwCPwPkGTSUYrTiHORWvpT8RxGk849HPnpUAQ+/9BPRqYb80jTpearrHzQ=="], + + "micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="], + + "micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="], + + "micromark-extension-cjk-friendly": ["micromark-extension-cjk-friendly@1.2.3", "", { "dependencies": { "devlop": "^1.1.0", "micromark-extension-cjk-friendly-util": "2.1.1", "micromark-util-chunked": "^2.0.1", "micromark-util-resolve-all": "^2.0.1", "micromark-util-symbol": "^2.0.1" }, "peerDependencies": { "micromark": "^4.0.0", "micromark-util-types": "^2.0.0" }, "optionalPeers": ["micromark-util-types"] }, "sha512-gRzVLUdjXBLX6zNPSnHGDoo+ZTp5zy+MZm0g3sv+3chPXY7l9gW+DnrcHcZh/jiPR6MjPKO4AEJNp4Aw6V9z5Q=="], + + "micromark-extension-cjk-friendly-gfm-strikethrough": ["micromark-extension-cjk-friendly-gfm-strikethrough@1.2.3", "", { "dependencies": { "devlop": "^1.1.0", "get-east-asian-width": "^1.3.0", "micromark-extension-cjk-friendly-util": "2.1.1", "micromark-util-character": "^2.1.1", "micromark-util-chunked": "^2.0.1", "micromark-util-resolve-all": "^2.0.1", "micromark-util-symbol": "^2.0.1" }, "peerDependencies": { "micromark": "^4.0.0", "micromark-util-types": "^2.0.0" }, "optionalPeers": ["micromark-util-types"] }, "sha512-gSPnxgHDDqXYOBvQRq6lerrq9mjDhdtKn+7XETuXjxWcL62yZEfUdA28Ml1I2vDIPfAOIKLa0h2XDSGkInGHFQ=="], + + "micromark-extension-cjk-friendly-util": ["micromark-extension-cjk-friendly-util@2.1.1", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "micromark-util-character": "^2.1.1", "micromark-util-symbol": "^2.0.1" } }, "sha512-egs6+12JU2yutskHY55FyR48ZiEcFOJFyk9rsiyIhcJ6IvWB6ABBqVrBw8IobqJTDZ/wdSr9eoXDPb5S2nW1bg=="], + + "micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="], + + "micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="], + + "micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="], + + "micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="], + + "micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="], + + "micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="], + + "micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="], + + "micromark-extension-math": ["micromark-extension-math@3.1.0", "", { "dependencies": { "@types/katex": "^0.16.0", "devlop": "^1.0.0", "katex": "^0.16.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg=="], + + "micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="], + + "micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="], + + "micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="], + + "micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="], + + "micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="], + + "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="], + + "micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="], + + "micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="], + + "micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="], + + "micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="], + + "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], + + "micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="], + + "micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="], + + "micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="], + + "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], + + "micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="], + + "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], + + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + + "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], + + "minimatch": ["minimatch@3.1.3", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA=="], + + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "mlly": ["mlly@1.8.0", "", { "dependencies": { "acorn": "^8.15.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.1" } }, "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g=="], + + "motion": ["motion@12.34.3", "", { "dependencies": { "framer-motion": "^12.34.3", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-xZIkBGO7v/Uvm+EyaqYd+9IpXu0sZqLywVlGdCFrrMiaO9JI4Kx51mO9KlHSWwll+gZUVY5OJsWgYI5FywJ/tw=="], + + "motion-dom": ["motion-dom@11.18.1", "", { "dependencies": { "motion-utils": "^11.18.1" } }, "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw=="], + + "motion-utils": ["motion-utils@11.18.1", "", {}, "sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "msw": ["msw@2.12.10", "", { "dependencies": { "@inquirer/confirm": "^5.0.0", "@mswjs/interceptors": "^0.41.2", "@open-draft/deferred-promise": "^2.2.0", "@types/statuses": "^2.0.6", "cookie": "^1.0.2", "graphql": "^16.12.0", "headers-polyfill": "^4.0.2", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.10.1", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.0", "type-fest": "^5.2.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-G3VUymSE0/iegFnuipujpwyTM2GuZAKXNeerUSrG2+Eg391wW63xFs5ixWsK9MWzr1AGoSkYGmyAzNgbR3+urw=="], + + "mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="], + + "mute-stream": ["mute-stream@2.0.0", "", {}, "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA=="], + + "nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="], + + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "next": ["next@16.1.6", "", { "dependencies": { "@next/env": "16.1.6", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "16.1.6", "@next/swc-darwin-x64": "16.1.6", "@next/swc-linux-arm64-gnu": "16.1.6", "@next/swc-linux-arm64-musl": "16.1.6", "@next/swc-linux-x64-gnu": "16.1.6", "@next/swc-linux-x64-musl": "16.1.6", "@next/swc-win32-arm64-msvc": "16.1.6", "@next/swc-win32-x64-msvc": "16.1.6", "sharp": "^0.34.4" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw=="], + + "next-themes": ["next-themes@0.4.6", "", { "peerDependencies": { "react": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc" } }, "sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA=="], + + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], + + "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + + "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], + + "npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "object-treeify": ["object-treeify@1.1.33", "", {}, "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A=="], + + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + + "oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="], + + "oniguruma-to-es": ["oniguruma-to-es@4.3.4", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-3VhUGN3w2eYxnTzHn+ikMI+fp/96KoRSVK9/kMTcFqj1NRDh2IhQCKvYxDnWePKRXY/AqH+Fuiyb7VHSzBjHfA=="], + + "open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], + + "option": ["option@0.2.4", "", {}, "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A=="], + + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + + "ora": ["ora@8.2.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="], + + "outvariant": ["outvariant@1.4.3", "", {}, "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA=="], + + "p-finally": ["p-finally@1.0.0", "", {}, "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow=="], + + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "p-queue": ["p-queue@6.6.2", "", { "dependencies": { "eventemitter3": "^4.0.4", "p-timeout": "^3.2.0" } }, "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ=="], + + "p-timeout": ["p-timeout@3.2.0", "", { "dependencies": { "p-finally": "^1.0.0" } }, "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg=="], + + "package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], + + "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], + + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + + "parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="], + + "parse-json": ["parse-json@5.2.0", "", { "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", "json-parse-even-better-errors": "^2.3.0", "lines-and-columns": "^1.1.6" } }, "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg=="], + + "parse-ms": ["parse-ms@4.0.0", "", {}, "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw=="], + + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], + + "path-data-parser": ["path-data-parser@0.1.0", "", {}, "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w=="], + + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + + "pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], + + "points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="], + + "points-on-path": ["points-on-path@0.2.1", "", { "dependencies": { "path-data-parser": "0.1.0", "points-on-curve": "0.2.0" } }, "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g=="], + + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + + "postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="], + + "powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], + + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + + "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], + + "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], + + "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], + + "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], + + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="], + + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + + "radix-ui": ["radix-ui@1.4.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-accessible-icon": "1.1.7", "@radix-ui/react-accordion": "1.2.12", "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-aspect-ratio": "1.1.7", "@radix-ui/react-avatar": "1.1.10", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-collapsible": "1.1.12", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-context-menu": "2.2.16", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-dropdown-menu": "2.1.16", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-form": "0.1.8", "@radix-ui/react-hover-card": "1.1.15", "@radix-ui/react-label": "2.1.7", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-menubar": "1.1.16", "@radix-ui/react-navigation-menu": "1.2.14", "@radix-ui/react-one-time-password-field": "0.1.8", "@radix-ui/react-password-toggle-field": "0.1.3", "@radix-ui/react-popover": "1.1.15", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-progress": "1.1.7", "@radix-ui/react-radio-group": "1.3.8", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-scroll-area": "1.2.10", "@radix-ui/react-select": "2.2.6", "@radix-ui/react-separator": "1.1.7", "@radix-ui/react-slider": "1.3.6", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-switch": "1.2.6", "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-toast": "1.2.15", "@radix-ui/react-toggle": "1.1.10", "@radix-ui/react-toggle-group": "1.1.11", "@radix-ui/react-toolbar": "1.1.11", "@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-escape-keydown": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA=="], + + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + + "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + + "react-day-picker": ["react-day-picker@9.13.2", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "date-fns": "^4.1.0", "date-fns-jalali": "^4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-IMPiXfXVIAuR5Yk58DDPBC8QKClrhdXV+Tr/alBrwrHUw0qDDYB1m5zPNuTnnPIr/gmJ4ChMxmtqPdxm8+R4Eg=="], + + "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], + + "react-is": ["react-is@19.2.4", "", {}, "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA=="], + + "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="], + + "react-redux": ["react-redux@9.2.0", "", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="], + + "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], + + "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], + + "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], + + "react-resizable-panels": ["react-resizable-panels@4.6.5", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-pmQP6qv9KmsesNMvWVNvVfVJAwYSOWWbAOAtrPR8Cre20+j1NWIlyft0btjtDQE+OepXmI6g3VPrCXQY0oD7+Q=="], + + "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], + + "react-textarea-autosize": ["react-textarea-autosize@8.5.9", "", { "dependencies": { "@babel/runtime": "^7.20.13", "use-composed-ref": "^1.3.0", "use-latest": "^1.2.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A=="], + + "readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + + "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], + + "recharts": ["recharts@3.7.0", "", { "dependencies": { "@reduxjs/toolkit": "1.x.x || 2.x.x", "clsx": "^2.1.1", "decimal.js-light": "^2.5.1", "es-toolkit": "^1.39.3", "eventemitter3": "^5.0.1", "immer": "^10.1.1", "react-redux": "8.x.x || 9.x.x", "reselect": "5.1.1", "tiny-invariant": "^1.3.3", "use-sync-external-store": "^1.2.2", "victory-vendor": "^37.0.2" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew=="], + + "redux": ["redux@5.0.1", "", {}, "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="], + + "redux-thunk": ["redux-thunk@3.1.0", "", { "peerDependencies": { "redux": "^5.0.0" } }, "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw=="], + + "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], + + "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], + + "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], + + "rehype-harden": ["rehype-harden@1.1.8", "", { "dependencies": { "unist-util-visit": "^5.0.0" } }, "sha512-Qn7vR1xrf6fZCrkm9TDWi/AB4ylrHy+jqsNm1EHOAmbARYA6gsnVJBq/sdBh6kmT4NEZxH5vgIjrscefJAOXcw=="], + + "rehype-katex": ["rehype-katex@7.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/katex": "^0.16.0", "hast-util-from-html-isomorphic": "^2.0.0", "hast-util-to-text": "^4.0.0", "katex": "^0.16.0", "unist-util-visit-parents": "^6.0.0", "vfile": "^6.0.0" } }, "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA=="], + + "rehype-raw": ["rehype-raw@7.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-raw": "^9.0.0", "vfile": "^6.0.0" } }, "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww=="], + + "rehype-sanitize": ["rehype-sanitize@6.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-sanitize": "^5.0.0" } }, "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg=="], + + "remark-cjk-friendly": ["remark-cjk-friendly@1.2.3", "", { "dependencies": { "micromark-extension-cjk-friendly": "1.2.3" }, "peerDependencies": { "@types/mdast": "^4.0.0", "unified": "^11.0.0" }, "optionalPeers": ["@types/mdast"] }, "sha512-UvAgxwlNk+l9Oqgl/9MWK2eWRS7zgBW/nXX9AthV7nd/3lNejF138E7Xbmk9Zs4WjTJGs721r7fAEc7tNFoH7g=="], + + "remark-cjk-friendly-gfm-strikethrough": ["remark-cjk-friendly-gfm-strikethrough@1.2.3", "", { "dependencies": { "micromark-extension-cjk-friendly-gfm-strikethrough": "1.2.3" }, "peerDependencies": { "@types/mdast": "^4.0.0", "unified": "^11.0.0" }, "optionalPeers": ["@types/mdast"] }, "sha512-bXfMZtsaomK6ysNN/UGRIcasQAYkC10NtPmP0oOHOV8YOhA2TXmwRXCku4qOzjIFxAPfish5+XS0eIug2PzNZA=="], + + "remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="], + + "remark-math": ["remark-math@6.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-math": "^3.0.0", "micromark-extension-math": "^3.0.0", "unified": "^11.0.0" } }, "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA=="], + + "remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="], + + "remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="], + + "remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="], + + "remend": ["remend@1.2.1", "", {}, "sha512-4wC12bgXsfKAjF1ewwkNIQz5sqewz/z1xgIgjEMb3r1pEytQ37F0Cm6i+OhbTWEvguJD7lhOUJhK5fSasw9f0w=="], + + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="], + + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + + "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], + + "rettime": ["rettime@0.10.1", "", {}, "sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw=="], + + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + + "robust-predicates": ["robust-predicates@3.0.2", "", {}, "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg=="], + + "rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="], + + "roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="], + + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + + "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], + + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + + "rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="], + + "safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="], + + "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + + "seroval": ["seroval@1.5.0", "", {}, "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw=="], + + "seroval-plugins": ["seroval-plugins@1.5.0", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA=="], + + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + + "setimmediate": ["setimmediate@1.0.5", "", {}, "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "shadcn": ["shadcn@3.8.5", "", { "dependencies": { "@antfu/ni": "^25.0.0", "@babel/core": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-typescript": "^7.27.1", "@dotenvx/dotenvx": "^1.48.4", "@modelcontextprotocol/sdk": "^1.26.0", "@types/validate-npm-package-name": "^4.0.2", "browserslist": "^4.26.2", "commander": "^14.0.0", "cosmiconfig": "^9.0.0", "dedent": "^1.6.0", "deepmerge": "^4.3.1", "diff": "^8.0.2", "execa": "^9.6.0", "fast-glob": "^3.3.3", "fs-extra": "^11.3.1", "fuzzysort": "^3.1.0", "https-proxy-agent": "^7.0.6", "kleur": "^4.1.5", "msw": "^2.10.4", "node-fetch": "^3.3.2", "open": "^11.0.0", "ora": "^8.2.0", "postcss": "^8.5.6", "postcss-selector-parser": "^7.1.0", "prompts": "^2.4.2", "recast": "^0.23.11", "stringify-object": "^5.0.0", "tailwind-merge": "^3.0.1", "ts-morph": "^26.0.0", "tsconfig-paths": "^4.2.0", "validate-npm-package-name": "^7.0.1", "zod": "^3.24.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "shadcn": "dist/index.js" } }, "sha512-jPRx44e+eyeV7xwY3BLJXcfrks00+M0h5BGB9l6DdcBW4BpAj4x3lVmVy0TXPEs2iHEisxejr62sZAAw6B1EVA=="], + + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "shiki": ["shiki@3.22.0", "", { "dependencies": { "@shikijs/core": "3.22.0", "@shikijs/engine-javascript": "3.22.0", "@shikijs/engine-oniguruma": "3.22.0", "@shikijs/langs": "3.22.0", "@shikijs/themes": "3.22.0", "@shikijs/types": "3.22.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-LBnhsoYEe0Eou4e1VgJACes+O6S6QC0w71fCSp5Oya79inkwkm15gQ1UF6VtQ8j/taMDh79hAB49WUk8ALQW3g=="], + + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "simple-wcswidth": ["simple-wcswidth@1.1.2", "", {}, "sha512-j7piyCjAeTDSjzTSQ7DokZtMNwNlEAyxqSZeCS+CXH7fJ4jx3FuJ/mTW3mE+6JLs4VJBbcll0Kjn+KXI5t21Iw=="], + + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + + "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], + + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + + "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], + + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + + "stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="], + + "streamdown": ["streamdown@2.3.0", "", { "dependencies": { "clsx": "^2.1.1", "hast-util-to-jsx-runtime": "^2.3.6", "html-url-attributes": "^3.0.1", "marked": "^17.0.1", "rehype-harden": "^1.1.8", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remend": "1.2.1", "tailwind-merge": "^3.4.0", "unified": "^11.0.5", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-OqS3by/lt91lSicE8RQP2nTsYI6Q/dQgGP2vcyn9YesCmRHhNjswAuBAZA1z0F4+oBU3II/eV51LqjCqwTb1lw=="], + + "strict-event-emitter": ["strict-event-emitter@0.5.1", "", {}, "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ=="], + + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + + "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], + + "stringify-object": ["stringify-object@5.0.0", "", { "dependencies": { "get-own-enumerable-keys": "^1.0.0", "is-obj": "^3.0.0", "is-regexp": "^3.1.0" } }, "sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg=="], + + "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], + + "strip-bom": ["strip-bom@3.0.0", "", {}, "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA=="], + + "strip-final-newline": ["strip-final-newline@4.0.0", "", {}, "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw=="], + + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + + "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], + + "style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="], + + "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], + + "stylis": ["stylis@4.3.6", "", {}, "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ=="], + + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="], + + "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], + + "tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="], + + "tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="], + + "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], + + "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], + + "tiny-warning": ["tiny-warning@1.0.3", "", {}, "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="], + + "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], + + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "tldts": ["tldts@7.0.23", "", { "dependencies": { "tldts-core": "^7.0.23" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw=="], + + "tldts-core": ["tldts-core@7.0.23", "", {}, "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ=="], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "tough-cookie": ["tough-cookie@6.0.0", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w=="], + + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], + + "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], + + "ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="], + + "ts-dedent": ["ts-dedent@2.2.0", "", {}, "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ=="], + + "ts-morph": ["ts-morph@26.0.0", "", { "dependencies": { "@ts-morph/common": "~0.27.0", "code-block-writer": "^13.0.3" } }, "sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug=="], + + "tsconfig-paths": ["tsconfig-paths@4.2.0", "", { "dependencies": { "json5": "^2.2.2", "minimist": "^1.2.6", "strip-bom": "^3.0.0" } }, "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], + + "tw-shimmer": ["tw-shimmer@0.4.6", "", { "peerDependencies": { "tailwindcss": ">=4.0.0-0" } }, "sha512-Wg3Qy9bcIHw6v2hqFzsvBiuIVHey2HyjDPYY/ozkDCWDYNPirxs1GoIs8FCrNtc0YTb+/wuSySAB7DjbTY6uGw=="], + + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + + "type-fest": ["type-fest@5.4.4", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw=="], + + "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "typescript-eslint": ["typescript-eslint@8.56.1", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.56.1", "@typescript-eslint/parser": "8.56.1", "@typescript-eslint/typescript-estree": "8.56.1", "@typescript-eslint/utils": "8.56.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ=="], + + "ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="], + + "underscore": ["underscore@1.13.8", "", {}, "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ=="], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], + + "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], + + "unist-util-find-after": ["unist-util-find-after@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ=="], + + "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], + + "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], + + "unist-util-remove-position": ["unist-util-remove-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-visit": "^5.0.0" } }, "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q=="], + + "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], + + "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], + + "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + + "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], + + "unpdf": ["unpdf@1.4.0", "", { "peerDependencies": { "@napi-rs/canvas": "^0.1.69" }, "optionalPeers": ["@napi-rs/canvas"] }, "sha512-TahIk0xdH/4jh/MxfclzU79g40OyxtP00VnEUZdEkJoYtXAHWLiir6t3FC6z3vDqQTzc2ZHcla6uEiVTNjejuA=="], + + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "until-async": ["until-async@3.0.2", "", {}, "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + + "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], + + "use-composed-ref": ["use-composed-ref@1.4.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w=="], + + "use-effect-event": ["use-effect-event@2.0.3", "", { "peerDependencies": { "react": "^18.3 || ^19.0.0-0" } }, "sha512-fz1en+z3fYXCXx3nMB8hXDMuygBltifNKZq29zDx+xNJ+1vEs6oJlYd9sK31vxJ0YI534VUsHEBY0k2BATsmBQ=="], + + "use-isomorphic-layout-effect": ["use-isomorphic-layout-effect@1.2.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA=="], + + "use-latest": ["use-latest@1.3.0", "", { "dependencies": { "use-isomorphic-layout-effect": "^1.1.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ=="], + + "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], + + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="], + + "validate-npm-package-name": ["validate-npm-package-name@7.0.2", "", {}, "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], + + "vfile-location": ["vfile-location@5.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg=="], + + "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], + + "victory-vendor": ["victory-vendor@37.3.6", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ=="], + + "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], + + "vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], + + "vscode-languageserver": ["vscode-languageserver@9.0.1", "", { "dependencies": { "vscode-languageserver-protocol": "3.17.5" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g=="], + + "vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.17.5", "", { "dependencies": { "vscode-jsonrpc": "8.2.0", "vscode-languageserver-types": "3.17.5" } }, "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg=="], + + "vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="], + + "vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="], + + "vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="], + + "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], + + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + + "wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], + + "xmlbuilder": ["xmlbuilder@10.1.1", "", {}, "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg=="], + + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + + "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + + "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], + + "yoctocolors-cjs": ["yoctocolors-cjs@2.1.3", "", {}, "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], + + "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], + + "zustand": ["zustand@5.0.11", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg=="], + + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + + "@assistant-ui/react/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "@dotenvx/dotenvx/commander": ["commander@11.1.0", "", {}, "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ=="], + + "@dotenvx/dotenvx/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], + + "@dotenvx/dotenvx/which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="], + + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + + "@modelcontextprotocol/sdk/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + + "@radix-ui/react-accordion/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-accordion/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-alert-dialog/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-alert-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-arrow/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-aspect-ratio/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-avatar/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-avatar/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-checkbox/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-checkbox/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-collapsible/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-collapsible/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-collection/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-collection/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-context-menu/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-context-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-dialog/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-dismissable-layer/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-dropdown-menu/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-dropdown-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-focus-scope/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-form/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-form/@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="], + + "@radix-ui/react-form/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-hover-card/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-hover-card/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-menu/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-menubar/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-menubar/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-navigation-menu/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-navigation-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-one-time-password-field/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-one-time-password-field/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-password-toggle-field/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-password-toggle-field/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-popover/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-popover/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-popover/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-popper/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-popper/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-portal/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-progress/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-progress/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-radio-group/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-radio-group/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-roving-focus/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-roving-focus/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-scroll-area/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-scroll-area/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-select/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-select/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-slider/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-slider/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-switch/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-switch/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-tabs/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-tabs/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-toast/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-toast/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-toggle/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-toggle-group/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-toggle-group/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-toolbar/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-toolbar/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-toolbar/@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="], + + "@radix-ui/react-tooltip/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-tooltip/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-visually-hidden/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@reduxjs/toolkit/immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@toolwind/corner-shape/@types/node": ["@types/node@20.19.33", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-Rs1bVAIdBs5gbTIKza/tgpMuG1k3U/UMJLWecIMxNdJFDMzcM5LOiLVRYh3PilWEYDIeUDv7bpiHPLPsbydGcw=="], + + "@ts-morph/common/minimatch": ["minimatch@10.2.2", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw=="], + + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.2", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw=="], + + "@typescript-eslint/typescript-estree/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "@xyflow/react/zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], + + "ajv-formats/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + + "chalk/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "cmdk/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "cytoscape-fcose/cose-base": ["cose-base@2.2.0", "", { "dependencies": { "layout-base": "^2.0.0" } }, "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g=="], + + "d3-dsv/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], + + "d3-dsv/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + + "d3-sankey/d3-array": ["d3-array@2.12.1", "", { "dependencies": { "internmap": "^1.0.0" } }, "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ=="], + + "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], + + "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "langsmith/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "langsmith/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "log-symbols/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], + + "mammoth/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], + + "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], + + "mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="], + + "mermaid/uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], + + "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + + "motion/framer-motion": ["framer-motion@12.34.3", "", { "dependencies": { "motion-dom": "^12.34.3", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-v81ecyZKYO/DfpTwHivqkxSUBzvceOpoI+wLfgCgoUIKxlFKEXdg0oR9imxwXumT4SFy8vRk9xzJ5l3/Du/55Q=="], + + "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], + + "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], + + "ora/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "ora/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + + "parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="], + + "postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], + + "radix-ui/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "radix-ui/@radix-ui/react-label": ["@radix-ui/react-label@2.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ=="], + + "radix-ui/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "radix-ui/@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA=="], + + "radix-ui/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], + + "router/path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], + + "shadcn/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + + "sharp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "@dotenvx/dotenvx/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], + + "@dotenvx/dotenvx/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], + + "@dotenvx/dotenvx/execa/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + + "@dotenvx/dotenvx/execa/npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="], + + "@dotenvx/dotenvx/execa/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "@dotenvx/dotenvx/execa/strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], + + "@dotenvx/dotenvx/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="], + + "@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "@radix-ui/react-accordion/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-arrow/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-aspect-ratio/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-avatar/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-checkbox/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-collapsible/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-context-menu/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-dismissable-layer/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-dropdown-menu/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-focus-scope/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-form/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-hover-card/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-menubar/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-navigation-menu/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-one-time-password-field/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-password-toggle-field/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-popper/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-portal/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-progress/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-radio-group/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-roving-focus/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-scroll-area/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-slider/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-switch/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-tabs/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-toast/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-toggle-group/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-toggle/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-toolbar/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@toolwind/corner-shape/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "@ts-morph/common/minimatch/brace-expansion": ["brace-expansion@5.0.3", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA=="], + + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.3", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA=="], + + "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "cmdk/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="], + + "d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="], + + "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="], + + "motion/framer-motion/motion-dom": ["motion-dom@12.34.3", "", { "dependencies": { "motion-utils": "^12.29.2" } }, "sha512-sYgFe+pR9aIM7o4fhs2aXtOI+oqlUd33N9Yoxcgo1Fv7M20sRkHtCmzE/VRNIcq7uNJ+qio+Xubt1FXH3pQ+eQ=="], + + "motion/framer-motion/motion-utils": ["motion-utils@12.29.2", "", {}, "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A=="], + + "next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "ora/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + + "string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "@ts-morph/common/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + } +} diff --git a/studio/frontend/components.json b/studio/frontend/components.json new file mode 100644 index 0000000000..12670d6a6d --- /dev/null +++ b/studio/frontend/components.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "radix-maia", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "hugeicons", + "menuColor": "default", + "menuAccent": "subtle", + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "registries": { + "@magicui": "https://magicui.design/r/{name}" + } +} diff --git a/studio/frontend/data-designer.openapi (1).yaml b/studio/frontend/data-designer.openapi (1).yaml new file mode 100644 index 0000000000..5d4bb29b17 --- /dev/null +++ b/studio/frontend/data-designer.openapi (1).yaml @@ -0,0 +1,2644 @@ +openapi: 3.1.0 +info: + title: NeMo Data Designer Microservice + description: Service for generating synthetic data. + version: 1.5.0 +paths: + /v1/data-designer/jobs: + post: + tags: + - Data Designer + summary: Create Job + operationId: create_job_v1_data_designer_jobs_post + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DataDesignerJobRequest' + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/DataDesignerJob' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + get: + tags: + - Data Designer + summary: List Jobs + operationId: list_jobs_v1_data_designer_jobs_get + parameters: + - name: page + in: query + required: false + schema: + type: integer + exclusiveMinimum: 0 + description: Page number. + default: 1 + title: Page + description: Page number. + - name: page_size + in: query + required: false + schema: + type: integer + exclusiveMinimum: 0 + description: Page size. + default: 10 + title: Page Size + description: Page size. + - name: sort + in: query + required: false + schema: + allOf: + - $ref: '#/components/schemas/DataDesignerJobsSortField' + description: The field to sort by. To sort in decreasing order, use `-` + in front of the field name. + default: -created_at + description: The field to sort by. To sort in decreasing order, use `-` in + front of the field name. + - in: query + name: filter + style: deepObject + required: false + explode: true + schema: + $ref: '#/components/schemas/DataDesignerJobsListFilter' + description: Filter jobs on various criteria. + - in: query + name: search + style: deepObject + required: false + explode: true + schema: + $ref: '#/components/schemas/DataDesignerJobsSearch' + description: "\nSearch jobs using substring matching.\nYou can combine multiple\ + \ search fields and filters.\n\nFor example:\n- `?search[name]=training`:\ + \ searches all jobs with 'training' in the name.\n- `?search[project]=my-project`:\ + \ searches all jobs with 'my-project'\n in the project field.\n- `?search[name]=training&search[name]=eval`:\ + \ searches all jobs with\n 'training' OR 'eval' in the name.\n- `?search[name]=training&search[project]=my-project`:\ + \ searches all\n jobs with 'training' in the name AND 'my-project' in the\ + \ project.\n" + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/DataDesignerJobsPage' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /v1/data-designer/jobs/{job_id}: + get: + tags: + - Data Designer + summary: Get Job + operationId: get_job_v1_data_designer_jobs__job_id__get + parameters: + - name: job_id + in: path + required: true + schema: + type: string + title: Job Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/DataDesignerJob' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + delete: + tags: + - Data Designer + summary: Delete Job + operationId: delete_job_v1_data_designer_jobs__job_id__delete + parameters: + - name: job_id + in: path + required: true + schema: + type: string + title: Job Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /v1/data-designer/jobs/{job_id}/cancel: + post: + tags: + - Data Designer + summary: Cancel Job + operationId: cancel_job_v1_data_designer_jobs__job_id__cancel_post + parameters: + - name: job_id + in: path + required: true + schema: + type: string + title: Job Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/DataDesignerJob' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /v1/data-designer/jobs/{job_id}/logs: + get: + tags: + - Data Designer + summary: Get Job Logs + operationId: get_job_logs_v1_data_designer_jobs__job_id__logs_get + parameters: + - name: job_id + in: path + required: true + schema: + type: string + title: Job Id + - name: limit + in: query + required: false + schema: + anyOf: + - type: integer + - type: 'null' + title: Limit + - name: page_cursor + in: query + required: false + schema: + anyOf: + - type: string + - type: 'null' + title: Page Cursor + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/PlatformJobLogPage' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /v1/data-designer/jobs/{job_id}/results: + get: + tags: + - Data Designer + summary: List Job Results + operationId: list_job_results_v1_data_designer_jobs__job_id__results_get + parameters: + - name: job_id + in: path + required: true + schema: + type: string + title: Job Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/PlatformJobListResultResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /v1/data-designer/jobs/{job_id}/results/analysis/download: + get: + tags: + - Data Designer + summary: Download Job Result Analysis + operationId: download_job_result_analysis_v1_data_designer_jobs__job_id__results_analysis_download_get + parameters: + - name: job_id + in: path + required: true + schema: + type: string + title: Job Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '404': + description: Not Found + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /v1/data-designer/jobs/{job_id}/results/dataset/download: + get: + tags: + - Data Designer + summary: Download Job Result Dataset + operationId: download_job_result_dataset_v1_data_designer_jobs__job_id__results_dataset_download_get + parameters: + - name: job_id + in: path + required: true + schema: + type: string + title: Job Id + responses: + '200': + description: Successful Response + content: + application/octet-stream: + schema: + type: string + format: binary + '404': + description: Not Found + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /v1/data-designer/jobs/{job_id}/results/{result_name}: + get: + tags: + - Data Designer + summary: Get Job Result + operationId: get_job_result_v1_data_designer_jobs__job_id__results__result_name__get + parameters: + - name: job_id + in: path + required: true + schema: + type: string + title: Job Id + - name: result_name + in: path + required: true + schema: + type: string + title: Result Name + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/PlatformJobResultResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /v1/data-designer/jobs/{job_id}/results/{result_name}/download: + get: + tags: + - Data Designer + summary: Download Job Result + operationId: download_job_result_v1_data_designer_jobs__job_id__results__result_name__download_get + parameters: + - name: job_id + in: path + required: true + schema: + type: string + title: Job Id + - name: result_name + in: path + required: true + schema: + type: string + title: Result Name + responses: + '200': + description: Successful Response + content: + application/octet-stream: + schema: + type: string + format: binary + '404': + description: Not Found + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /v1/data-designer/jobs/{job_id}/status: + get: + tags: + - Data Designer + summary: Get Job Status + operationId: get_job_status_v1_data_designer_jobs__job_id__status_get + parameters: + - name: job_id + in: path + required: true + schema: + type: string + title: Job Id + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/PlatformJobStatusResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /v1/data-designer/preview: + post: + tags: + - Data Designer + summary: Generate preview Data Designer + operationId: preview_v1_data_designer_preview_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PreviewRequest' + required: true + responses: + '200': + description: Successful Response + content: + application/jsonl: + schema: + $ref: '#/components/schemas/PreviewMessage' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + /v1/data-designer/settings: + get: + tags: + - Data Designer + summary: Get Data Designer settings + description: Returns the settings available for Data Designer. + operationId: get_settings_v1_data_designer_settings_get + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/SettingsResponse' +components: + schemas: + BernoulliMixtureSamplerParams: + properties: + p: + type: number + maximum: 1.0 + minimum: 0.0 + title: P + description: Bernoulli distribution probability of success. + dist_name: + type: string + title: Dist Name + description: Mixture distribution name. Samples will be equal to the distribution + sample with probability `p`, otherwise equal to 0. Must be a valid scipy.stats + distribution name. + dist_params: + additionalProperties: true + type: object + title: Dist Params + description: Parameters of the scipy.stats distribution given in `dist_name`. + sampler_type: + type: string + const: bernoulli_mixture + title: Sampler Type + default: bernoulli_mixture + additionalProperties: false + type: object + required: + - p + - dist_name + - dist_params + title: BernoulliMixtureSamplerParams + description: "Parameters for sampling from a Bernoulli mixture distribution.\n\ + \nCombines a Bernoulli distribution with another continuous distribution,\ + \ creating a mixture\nwhere values are either 0 (with probability 1-p) or\ + \ sampled from the specified distribution\n(with probability p). This is useful\ + \ for modeling scenarios with many zero values mixed with\na continuous distribution\ + \ of non-zero values.\n\nCommon use cases include modeling sparse events,\ + \ zero-inflated data, or situations where\nan outcome either doesn't occur\ + \ (0) or follows a specific distribution when it does occur.\n\nAttributes:\n\ + \ p: Probability of sampling from the mixture distribution (non-zero outcome).\n\ + \ Must be between 0.0 and 1.0 (inclusive). With probability 1-p, the\ + \ sample is 0.\n dist_name: Name of the scipy.stats distribution to sample\ + \ from when outcome is non-zero.\n Must be a valid scipy.stats distribution\ + \ name (e.g., \"norm\", \"gamma\", \"expon\").\n dist_params: Parameters\ + \ for the specified scipy.stats distribution." + BernoulliSamplerParams: + properties: + p: + type: number + maximum: 1.0 + minimum: 0.0 + title: P + description: Probability of success. + sampler_type: + type: string + const: bernoulli + title: Sampler Type + default: bernoulli + additionalProperties: false + type: object + required: + - p + title: BernoulliSamplerParams + description: "Parameters for sampling from a Bernoulli distribution.\n\nSamples\ + \ binary values (0 or 1) representing the outcome of a single trial with a\ + \ fixed\nprobability of success. This is the simplest discrete probability\ + \ distribution, useful for\nmodeling binary outcomes like success/failure,\ + \ yes/no, or true/false.\n\nAttributes:\n p: Probability of success (sampling\ + \ 1). Must be between 0.0 and 1.0 (inclusive).\n The probability of\ + \ failure (sampling 0) is automatically 1 - p." + BinomialSamplerParams: + properties: + n: + type: integer + title: N + description: Number of trials. + p: + type: number + maximum: 1.0 + minimum: 0.0 + title: P + description: Probability of success on each trial. + sampler_type: + type: string + const: binomial + title: Sampler Type + default: binomial + additionalProperties: false + type: object + required: + - n + - p + title: BinomialSamplerParams + description: "Parameters for sampling from a Binomial distribution.\n\nSamples\ + \ integer values representing the number of successes in a fixed number of\ + \ independent\nBernoulli trials, each with the same probability of success.\ + \ Commonly used to model the number\nof successful outcomes in repeated experiments.\n\ + \nAttributes:\n n: Number of independent trials. Must be a positive integer.\n\ + \ p: Probability of success on each trial. Must be between 0.0 and 1.0\ + \ (inclusive)." + BuildStage: + type: string + enum: + - pre_batch + - post_batch + - pre_generation + - post_generation + title: BuildStage + CategorySamplerParams: + properties: + values: + items: + anyOf: + - type: string + - type: integer + - type: number + type: array + minItems: 1 + title: Values + description: List of possible categorical values that can be sampled from. + weights: + type: array + items: + type: number + title: Weights + description: List of unnormalized probability weights to assigned to each + value, in order. Larger values will be sampled with higher probability. + sampler_type: + type: string + const: category + title: Sampler Type + default: category + additionalProperties: false + type: object + required: + - values + title: CategorySamplerParams + description: "Parameters for categorical sampling with optional probability\ + \ weighting.\n\nSamples values from a discrete set of categories. When weights\ + \ are provided, values are\nsampled according to their assigned probabilities.\ + \ Without weights, uniform sampling is used.\n\nAttributes:\n values: List\ + \ of possible categorical values to sample from. Can contain strings, integers,\n\ + \ or floats. Must contain at least one value.\n weights: Optional\ + \ unnormalized probability weights for each value. If provided, must be\n\ + \ the same length as `values`. Weights are automatically normalized\ + \ to sum to 1.0.\n Larger weights result in higher sampling probability\ + \ for the corresponding value." + CodeLang: + type: string + enum: + - go + - javascript + - java + - kotlin + - python + - ruby + - rust + - scala + - swift + - typescript + - sql:sqlite + - sql:tsql + - sql:bigquery + - sql:mysql + - sql:postgres + - sql:ansi + title: CodeLang + CodeValidatorParams: + properties: + code_lang: + allOf: + - $ref: '#/components/schemas/CodeLang' + description: The language of the code to validate + additionalProperties: false + type: object + required: + - code_lang + title: CodeValidatorParams + description: "Configuration for code validation. Supports Python and SQL code\ + \ validation.\n\nAttributes:\n code_lang: The language of the code to validate.\ + \ Supported values include: `python`,\n `sql:sqlite`, `sql:postgres`,\ + \ `sql:mysql`, `sql:tsql`, `sql:bigquery`, `sql:ansi`." + ColumnInequalityConstraint: + properties: + target_column: + type: string + title: Target Column + rhs: + type: string + title: Rhs + operator: + $ref: '#/components/schemas/InequalityOperator' + additionalProperties: false + type: object + required: + - target_column + - rhs + - operator + title: ColumnInequalityConstraint + DataDesignerConfig: + properties: + columns: + items: + oneOf: + - $ref: '#/components/schemas/ExpressionColumnConfig' + - $ref: '#/components/schemas/LLMCodeColumnConfig' + - $ref: '#/components/schemas/LLMJudgeColumnConfig' + - $ref: '#/components/schemas/LLMStructuredColumnConfig' + - $ref: '#/components/schemas/LLMTextColumnConfig' + - $ref: '#/components/schemas/SamplerColumnConfig' + - $ref: '#/components/schemas/SeedDatasetColumnConfig' + - $ref: '#/components/schemas/ValidationColumnConfig' + discriminator: + propertyName: column_type + mapping: + expression: '#/components/schemas/ExpressionColumnConfig' + llm-code: '#/components/schemas/LLMCodeColumnConfig-Input' + llm-judge: '#/components/schemas/LLMJudgeColumnConfig-Input' + llm-structured: '#/components/schemas/LLMStructuredColumnConfig-Input' + llm-text: '#/components/schemas/LLMTextColumnConfig-Input' + sampler: '#/components/schemas/SamplerColumnConfig' + seed-dataset: '#/components/schemas/SeedDatasetColumnConfig' + validation: '#/components/schemas/ValidationColumnConfig-Input' + type: array + minItems: 1 + title: Columns + model_configs: + type: array + items: + $ref: '#/components/schemas/ModelConfigInput' + title: Model Configs + seed_config: + $ref: '#/components/schemas/SeedConfig' + constraints: + type: array + items: + anyOf: + - $ref: '#/components/schemas/ScalarInequalityConstraint' + - $ref: '#/components/schemas/ColumnInequalityConstraint' + title: Constraints + profilers: + type: array + items: + $ref: '#/components/schemas/JudgeScoreProfilerConfig' + title: Profilers + processors: + type: array + items: + $ref: '#/components/schemas/ProcessorConfig' + title: Processors + additionalProperties: false + type: object + required: + - columns + title: DataDesignerConfig + description: "Configuration for NeMo Data Designer.\n\nThis class defines the\ + \ main configuration structure for NeMo Data Designer,\nwhich orchestrates\ + \ the generation of synthetic data.\n\nAttributes:\n columns: Required\ + \ list of column configurations defining how each column\n should be\ + \ generated. Must contain at least one column.\n model_configs: Optional\ + \ list of model configurations for LLM-based generation.\n Each model\ + \ config defines the model, provider, and inference parameters.\n seed_config:\ + \ Optional seed dataset settings to use for generation.\n constraints:\ + \ Optional list of column constraints.\n profilers: Optional list of column\ + \ profilers for analyzing generated data characteristics." + DataDesignerJob: + properties: + id: + type: string + title: Id + name: + type: string + title: Name + description: + type: string + title: Description + project: + type: string + title: Project + namespace: + type: string + title: Namespace + created_at: + type: string + title: Created At + updated_at: + type: string + title: Updated At + spec: + $ref: '#/components/schemas/DataDesignerJobConfig' + status: + $ref: '#/components/schemas/PlatformJobStatus' + status_details: + type: object + additionalProperties: true + title: Status Details + error_details: + type: object + additionalProperties: true + title: Error Details + ownership: + type: object + additionalProperties: true + title: Ownership + custom_fields: + type: object + additionalProperties: true + title: Custom Fields + type: object + required: + - name + - spec + title: DataDesignerJob + DataDesignerJobConfig: + properties: + num_records: + type: integer + title: Num Records + config: + $ref: '#/components/schemas/DataDesignerConfig' + type: object + required: + - num_records + - config + title: DataDesignerJobConfig + DataDesignerJobRequest: + properties: + name: + type: string + title: Name + description: + type: string + title: Description + namespace: + type: string + title: Namespace + project: + type: string + title: Project + spec: + $ref: '#/components/schemas/DataDesignerJobConfig' + ownership: + type: object + additionalProperties: true + title: Ownership + custom_fields: + type: object + additionalProperties: true + title: Custom Fields + type: object + required: + - spec + title: DataDesignerJobRequest + DataDesignerJobsListFilter: + properties: + created_at: + allOf: + - $ref: '#/components/schemas/DatetimeFilter' + description: Jobs created at 'gte' datetime or 'lte' datetime. + name: + type: string + title: Name + description: Name of the job. + namespace: + type: string + title: Namespace + description: Namespace of the job. + project: + type: string + title: Project + description: Project containing the job. + status: + allOf: + - $ref: '#/components/schemas/PlatformJobStatus' + description: The current status. + updated_at: + allOf: + - $ref: '#/components/schemas/DatetimeFilter' + description: Jobs updated at 'gte' datetime or 'lte' datetime. + additionalProperties: false + type: object + title: DataDesignerJobsListFilter + DataDesignerJobsPage: + properties: + object: + type: string + title: Object + description: The type of object being returned. + default: list + data: + items: + $ref: '#/components/schemas/DataDesignerJob' + type: array + title: Data + pagination: + allOf: + - $ref: '#/components/schemas/PaginationData' + description: Pagination information. + sort: + type: string + title: Sort + description: The field on which the results are sorted. + filter: + allOf: + - $ref: '#/components/schemas/DataDesignerJobsListFilter' + description: Filtering information. + search: + allOf: + - $ref: '#/components/schemas/DataDesignerJobsSearch' + description: Search information. + type: object + required: + - data + title: DataDesignerJobsPage + DataDesignerJobsSearch: + properties: + name: + type: array + items: + type: string + title: Name + description: Search jobs where name contains any of these strings. + project: + type: array + items: + type: string + title: Project + description: Search jobs where project contains any of these strings. + type: object + title: DataDesignerJobsSearch + DataDesignerJobsSortField: + type: string + enum: + - created_at + - -created_at + - updated_at + - -updated_at + title: DataDesignerJobsSortField + DatetimeFilter: + properties: + gte: + type: string + title: Gte + description: Filter for results greater than or equal to this datetime. + lte: + type: string + title: Lte + description: Filter for results less than or equal to this datetime. + additionalProperties: false + type: object + title: DatetimeFilter + DatetimeSamplerParams: + properties: + start: + type: string + title: Start + description: Earliest possible datetime for sampling range, inclusive. + end: + type: string + title: End + description: Latest possible datetime for sampling range, inclusive. + unit: + type: string + enum: + - Y + - M + - D + - h + - m + - s + title: Unit + description: Sampling units, e.g. the smallest possible time interval between + samples. + default: D + sampler_type: + type: string + const: datetime + title: Sampler Type + default: datetime + additionalProperties: false + type: object + required: + - start + - end + title: DatetimeSamplerParams + description: "Parameters for uniform datetime sampling within a specified range.\n\ + \nSamples datetime values uniformly between a start and end date with a specified\ + \ granularity.\nThe sampling unit determines the smallest possible time interval\ + \ between consecutive samples.\n\nAttributes:\n start: Earliest possible\ + \ datetime for the sampling range (inclusive). Must be a valid\n datetime\ + \ string parseable by pandas.to_datetime().\n end: Latest possible datetime\ + \ for the sampling range (inclusive). Must be a valid\n datetime string\ + \ parseable by pandas.to_datetime().\n unit: Time unit for sampling granularity.\ + \ Options:\n - \"Y\": Years\n - \"M\": Months\n - \"\ + D\": Days (default)\n - \"h\": Hours\n - \"m\": Minutes\n \ + \ - \"s\": Seconds" + DisplayModelProvider: + properties: + name: + type: string + title: Name + provider_type: + type: string + title: Provider Type + default: openai + extra_body: + type: object + additionalProperties: true + title: Extra Body + allowed_models: + type: array + items: + type: string + title: Allowed Models + additionalProperties: false + type: object + required: + - name + title: DisplayModelProvider + DistributionType: + type: string + enum: + - uniform + - manual + title: DistributionType + ExpressionColumnConfig: + properties: + name: + type: string + title: Name + drop: + type: boolean + title: Drop + default: false + column_type: + type: string + const: expression + title: Column Type + default: expression + expr: + type: string + title: Expr + dtype: + type: string + enum: + - int + - float + - str + - bool + title: Dtype + default: str + additionalProperties: false + type: object + required: + - name + - expr + title: ExpressionColumnConfig + description: "Configuration for derived columns using Jinja2 expressions.\n\n\ + Expression columns compute values by evaluating Jinja2 templates that reference\ + \ other\ncolumns. Useful for transformations, concatenations, conditional\ + \ logic, and derived\nfeatures without requiring LLM generation. The expression\ + \ is evaluated row-by-row.\n\nAttributes:\n expr: Jinja2 expression to\ + \ evaluate. Can reference other column values using\n {{ column_name\ + \ }} syntax. Supports filters, conditionals, and arithmetic.\n Must\ + \ be a valid, non-empty Jinja2 template.\n dtype: Data type to cast the\ + \ result to. Must be one of \"int\", \"float\", \"str\", or \"bool\".\n \ + \ Defaults to \"str\". Type conversion is applied after expression evaluation.\n\ + \ column_type: Discriminator field, always \"expression\" for this configuration\ + \ type." + FileStorageType: + type: string + enum: + - nds + title: FileStorageType + GaussianSamplerParams: + properties: + mean: + type: number + title: Mean + description: Mean of the Gaussian distribution + stddev: + type: number + title: Stddev + description: Standard deviation of the Gaussian distribution + decimal_places: + type: integer + title: Decimal Places + description: Number of decimal places to round the sampled values to. + sampler_type: + type: string + const: gaussian + title: Sampler Type + default: gaussian + additionalProperties: false + type: object + required: + - mean + - stddev + title: GaussianSamplerParams + description: "Parameters for sampling from a Gaussian (Normal) distribution.\n\ + \nSamples continuous values from a normal distribution characterized by its\ + \ mean and standard\ndeviation. The Gaussian distribution is one of the most\ + \ commonly used probability distributions,\nappearing naturally in many real-world\ + \ phenomena due to the Central Limit Theorem.\n\nAttributes:\n mean: Mean\ + \ (center) of the Gaussian distribution. This is the expected value and the\n\ + \ location of the distribution's peak.\n stddev: Standard deviation\ + \ of the Gaussian distribution. Controls the spread or width\n of the\ + \ distribution. Must be positive.\n decimal_places: Optional number of\ + \ decimal places to round sampled values to. If None,\n values are\ + \ not rounded." + HTTPValidationError: + properties: + detail: + items: + $ref: '#/components/schemas/ValidationError' + type: array + title: Detail + type: object + title: HTTPValidationError + ImageContext: + properties: + modality: + allOf: + - $ref: '#/components/schemas/Modality' + default: image + column_name: + type: string + title: Column Name + data_type: + $ref: '#/components/schemas/ModalityDataType' + image_format: + $ref: '#/components/schemas/ImageFormat' + type: object + required: + - column_name + - data_type + title: ImageContext + ImageFormat: + type: string + enum: + - png + - jpg + - jpeg + - gif + - webp + title: ImageFormat + IndexRange: + properties: + start: + type: integer + minimum: 0.0 + title: Start + description: The start index of the index range (inclusive) + end: + type: integer + minimum: 0.0 + title: End + description: The end index of the index range (inclusive) + additionalProperties: false + type: object + required: + - start + - end + title: IndexRange + InequalityOperator: + type: string + enum: + - lt + - le + - gt + - ge + title: InequalityOperator + InferenceParametersInput: + properties: + temperature: + anyOf: + - type: number + - $ref: '#/components/schemas/UniformDistribution' + - $ref: '#/components/schemas/ManualDistribution' + - type: 'null' + title: Temperature + top_p: + anyOf: + - type: number + - $ref: '#/components/schemas/UniformDistribution' + - $ref: '#/components/schemas/ManualDistribution' + - type: 'null' + title: Top P + max_tokens: + type: integer + title: Max Tokens + max_parallel_requests: + type: integer + minimum: 1.0 + title: Max Parallel Requests + default: 4 + timeout: + type: integer + title: Timeout + extra_body: + type: object + additionalProperties: true + title: Extra Body + additionalProperties: false + type: object + title: InferenceParametersInput + InferenceParametersOutput: + properties: + temperature: + anyOf: + - type: number + - $ref: '#/components/schemas/UniformDistribution' + - $ref: '#/components/schemas/ManualDistribution' + - type: 'null' + title: Temperature + top_p: + anyOf: + - type: number + - $ref: '#/components/schemas/UniformDistribution' + - $ref: '#/components/schemas/ManualDistribution' + - type: 'null' + title: Top P + max_tokens: + type: integer + title: Max Tokens + max_parallel_requests: + type: integer + minimum: 1.0 + title: Max Parallel Requests + default: 4 + timeout: + type: integer + title: Timeout + extra_body: + type: object + additionalProperties: true + title: Extra Body + additionalProperties: false + type: object + title: InferenceParametersOutput + JudgeScoreProfilerConfig: + properties: + model_alias: + type: string + title: Model Alias + summary_score_sample_size: + type: integer + title: Summary Score Sample Size + default: 20 + additionalProperties: false + type: object + required: + - model_alias + title: JudgeScoreProfilerConfig + LLMCodeColumnConfig: + properties: + name: + type: string + title: Name + drop: + type: boolean + title: Drop + default: false + column_type: + type: string + const: llm-code + title: Column Type + default: llm-code + prompt: + type: string + title: Prompt + model_alias: + type: string + title: Model Alias + system_prompt: + type: string + title: System Prompt + multi_modal_context: + type: array + items: + $ref: '#/components/schemas/ImageContext' + title: Multi Modal Context + code_lang: + $ref: '#/components/schemas/CodeLang' + additionalProperties: false + type: object + required: + - name + - prompt + - model_alias + - code_lang + title: LLMCodeColumnConfig + description: "Configuration for code generation columns using Large Language\ + \ Models.\n\nExtends LLMTextColumnConfig to generate code snippets in specific\ + \ programming languages\nor SQL dialects. The generated code is automatically\ + \ extracted from markdown code blocks\nfor the specified language. Inherits\ + \ all prompt templating capabilities.\n\nAttributes:\n code_lang: Programming\ + \ language or SQL dialect for code generation. Supported\n values include:\ + \ \"python\", \"javascript\", \"typescript\", \"java\", \"kotlin\", \"go\"\ + ,\n \"rust\", \"ruby\", \"scala\", \"swift\", \"sql:sqlite\", \"sql:postgres\"\ + , \"sql:mysql\",\n \"sql:tsql\", \"sql:bigquery\", \"sql:ansi\". See\ + \ CodeLang enum for complete list.\n column_type: Discriminator field,\ + \ always \"llm-code\" for this configuration type." + LLMJudgeColumnConfig: + properties: + name: + type: string + title: Name + drop: + type: boolean + title: Drop + default: false + column_type: + type: string + const: llm-judge + title: Column Type + default: llm-judge + prompt: + type: string + title: Prompt + model_alias: + type: string + title: Model Alias + system_prompt: + type: string + title: System Prompt + multi_modal_context: + type: array + items: + $ref: '#/components/schemas/ImageContext' + title: Multi Modal Context + scores: + items: + $ref: '#/components/schemas/Score' + type: array + minItems: 1 + title: Scores + additionalProperties: false + type: object + required: + - name + - prompt + - model_alias + - scores + title: LLMJudgeColumnConfig + description: "Configuration for LLM-as-a-judge quality assessment and scoring\ + \ columns.\n\nExtends LLMTextColumnConfig to create judge columns that evaluate\ + \ and score other\ngenerated content based on the defined criteria. Useful\ + \ for quality assessment, preference\nranking, and multi-dimensional evaluation\ + \ of generated data.\n\nAttributes:\n scores: List of Score objects defining\ + \ the evaluation dimensions. Each score\n represents a different aspect\ + \ to evaluate (e.g., accuracy, relevance, fluency).\n Must contain\ + \ at least one score.\n column_type: Discriminator field, always \"llm-judge\"\ + \ for this configuration type." + LLMStructuredColumnConfig: + properties: + name: + type: string + title: Name + drop: + type: boolean + title: Drop + default: false + column_type: + type: string + const: llm-structured + title: Column Type + default: llm-structured + prompt: + type: string + title: Prompt + model_alias: + type: string + title: Model Alias + system_prompt: + type: string + title: System Prompt + multi_modal_context: + type: array + items: + $ref: '#/components/schemas/ImageContext' + title: Multi Modal Context + output_format: + anyOf: + - additionalProperties: true + type: object + - {} + title: Output Format + additionalProperties: false + type: object + required: + - name + - prompt + - model_alias + - output_format + title: LLMStructuredColumnConfig + description: "Configuration for structured JSON generation columns using Large\ + \ Language Models.\n\nExtends LLMTextColumnConfig to generate structured data\ + \ conforming to a specified schema.\nUses JSON schema or Pydantic models to\ + \ define the expected output structure, enabling\ntype-safe and validated\ + \ structured output generation. Inherits prompt templating capabilities.\n\ + \nAttributes:\n output_format: The schema defining the expected output\ + \ structure. Can be either:\n - A Pydantic BaseModel class (recommended)\n\ + \ - A JSON schema dictionary\n column_type: Discriminator field,\ + \ always \"llm-structured\" for this configuration type." + LLMTextColumnConfig: + properties: + name: + type: string + title: Name + drop: + type: boolean + title: Drop + default: false + column_type: + type: string + const: llm-text + title: Column Type + default: llm-text + prompt: + type: string + title: Prompt + model_alias: + type: string + title: Model Alias + system_prompt: + type: string + title: System Prompt + multi_modal_context: + type: array + items: + $ref: '#/components/schemas/ImageContext' + title: Multi Modal Context + additionalProperties: false + type: object + required: + - name + - prompt + - model_alias + title: LLMTextColumnConfig + description: "Configuration for text generation columns using Large Language\ + \ Models.\n\nLLM text columns generate free-form text content using language\ + \ models via LiteLLM.\nPrompts support Jinja2 templating to reference values\ + \ from other columns, enabling\ncontext-aware generation. The generated text\ + \ can optionally include reasoning traces\nwhen models support extended thinking.\n\ + \nAttributes:\n prompt: Prompt template for text generation. Supports Jinja2\ + \ syntax to\n reference other columns (e.g., \"Write a story about\ + \ {{ character_name }}\").\n Must be a valid Jinja2 template.\n \ + \ model_alias: Alias of the model configuration to use for generation.\n \ + \ Must match a model alias defined when initializing the DataDesignerConfigBuilder.\n\ + \ system_prompt: Optional system prompt to set model behavior and constraints.\n\ + \ Also supports Jinja2 templating. If provided, must be a valid Jinja2\ + \ template.\n Do not put any output parsing instructions in the system\ + \ prompt. Instead,\n use the appropriate column type for the output\ + \ you want to generate - e.g.,\n `LLMStructuredColumnConfig` for structured\ + \ output, `LLMCodeColumnConfig` for code.\n multi_modal_context: Optional\ + \ list of image contexts for multi-modal generation.\n Enables vision-capable\ + \ models to generate text based on image inputs.\n column_type: Discriminator\ + \ field, always \"llm-text\" for this configuration type." + LocalCallableValidatorParams: + properties: + validation_function: + title: Validation Function + description: Function (Callable[[pd.DataFrame], pd.DataFrame]) to validate + the data + output_schema: + type: object + additionalProperties: true + title: Output Schema + description: Expected schema for local callable validator's output + additionalProperties: false + type: object + required: + - validation_function + title: LocalCallableValidatorParams + description: "Configuration for local callable validation. Expects a function\ + \ to be passed that validates the data.\n\nAttributes:\n validation_function:\ + \ Function (`Callable[[pd.DataFrame], pd.DataFrame]`) to validate the\n \ + \ data. Output must contain a column `is_valid` of type `bool`.\n \ + \ output_schema: The JSON schema for the local callable validator's output.\ + \ If not provided,\n the output will not be validated." + ManualDistribution: + properties: + distribution_type: + allOf: + - $ref: '#/components/schemas/DistributionType' + default: manual + params: + $ref: '#/components/schemas/ManualDistributionParams' + additionalProperties: false + type: object + required: + - params + title: ManualDistribution + ManualDistributionParams: + properties: + values: + items: + type: number + type: array + minItems: 1 + title: Values + weights: + type: array + items: + type: number + title: Weights + additionalProperties: false + type: object + required: + - values + title: ManualDistributionParams + MessageType: + type: string + enum: + - analysis + - dataset + - heartbeat + - log + title: MessageType + Modality: + type: string + enum: + - image + title: Modality + ModalityDataType: + type: string + enum: + - url + - base64 + title: ModalityDataType + ModelConfigInput: + properties: + alias: + type: string + title: Alias + model: + type: string + title: Model + inference_parameters: + $ref: '#/components/schemas/InferenceParametersInput' + provider: + type: string + title: Provider + additionalProperties: false + type: object + required: + - alias + - model + title: ModelConfigInput + ModelConfigOutput: + properties: + alias: + type: string + title: Alias + model: + type: string + title: Model + inference_parameters: + $ref: '#/components/schemas/InferenceParametersOutput' + provider: + type: string + title: Provider + additionalProperties: false + type: object + required: + - alias + - model + title: ModelConfigOutput + PaginationData: + properties: + page: + type: integer + title: Page + description: The current page number. + page_size: + type: integer + title: Page Size + description: The page size used for the query. + current_page_size: + type: integer + title: Current Page Size + description: The size for the current page. + total_pages: + type: integer + title: Total Pages + description: The total number of pages. + total_results: + type: integer + title: Total Results + description: The total number of results. + type: object + required: + - page + - page_size + - current_page_size + - total_pages + - total_results + title: PaginationData + PartitionBlock: + properties: + index: + type: integer + minimum: 0.0 + title: Index + description: The index of the partition to sample from + default: 0 + num_partitions: + type: integer + minimum: 1.0 + title: Num Partitions + description: The total number of partitions in the dataset + default: 1 + additionalProperties: false + type: object + title: PartitionBlock + PersonFromFakerSamplerParams: + properties: + locale: + type: string + title: Locale + description: Locale string, determines the language and geographic locale + that a synthetic person will be sampled from. E.g, en_US, en_GB, fr_FR, + ... + default: en_US + sex: + type: string + title: Sex + description: If specified, then only synthetic people of the specified sex + will be sampled. + city: + anyOf: + - type: string + - items: + type: string + type: array + title: City + description: If specified, then only synthetic people from these cities + will be sampled. + age_range: + items: + type: integer + type: array + maxItems: 2 + minItems: 2 + title: Age Range + description: If specified, then only synthetic people within this age range + will be sampled. + default: + - 18 + - 114 + sampler_type: + type: string + const: person_from_faker + title: Sampler Type + default: person_from_faker + additionalProperties: false + type: object + title: PersonFromFakerSamplerParams + PersonSamplerParams: + properties: + locale: + type: string + title: Locale + description: 'Locale that determines the language and geographic location + that a synthetic person will be sampled from. Must be a locale supported + by a managed Nemotron Personas dataset. Managed datasets exist for the + following locales: en_US, ja_JP, en_IN, hi_IN.' + default: en_US + sex: + type: string + title: Sex + description: If specified, then only synthetic people of the specified sex + will be sampled. + city: + anyOf: + - type: string + - items: + type: string + type: array + title: City + description: If specified, then only synthetic people from these cities + will be sampled. + age_range: + items: + type: integer + type: array + maxItems: 2 + minItems: 2 + title: Age Range + description: If specified, then only synthetic people within this age range + will be sampled. + default: + - 18 + - 114 + select_field_values: + type: object + additionalProperties: + items: + type: string + type: array + title: Select Field Values + description: Sample synthetic people with the specified field values. This + is meant to be a flexible argument for selecting a subset of the population + from the managed dataset. Note that this sampler does not support rare + combinations of field values and will likely fail if your desired subset + is not well-represented in the managed Nemotron Personas dataset. We generally + recommend using the `sex`, `city`, and `age_range` arguments to filter + the population when possible. + examples: + - education_level: + - high_school + - some_college + - bachelors + state: + - NY + - CA + - OH + - TX + - NV + with_synthetic_personas: + type: boolean + title: With Synthetic Personas + description: If True, then append synthetic persona columns to each generated + person. + default: false + sampler_type: + type: string + const: person + title: Sampler Type + default: person + additionalProperties: false + type: object + title: PersonSamplerParams + description: "Parameters for sampling synthetic person data with demographic\ + \ attributes.\n\nGenerates realistic synthetic person data including names,\ + \ addresses, phone numbers, and other\ndemographic information. Data can be\ + \ sampled from managed datasets (when available) or generated\nusing Faker.\ + \ The sampler supports filtering by locale, sex, age, geographic location,\ + \ and can\noptionally include synthetic persona descriptions.\n\nAttributes:\n\ + \ locale: Locale string determining the language and geographic region\ + \ for synthetic people.\n Format: language_COUNTRY (e.g., \"en_US\"\ + , \"en_GB\", \"fr_FR\", \"de_DE\", \"es_ES\", \"ja_JP\").\n Defaults\ + \ to \"en_US\".\n sex: If specified, filters to only sample people of the\ + \ specified sex. Options: \"Male\" or\n \"Female\". If None, samples\ + \ both sexes.\n city: If specified, filters to only sample people from\ + \ the specified city or cities. Can be\n a single city name (string)\ + \ or a list of city names.\n age_range: Two-element list [min_age, max_age]\ + \ specifying the age range to sample from\n (inclusive). Defaults to\ + \ a standard age range. Both values must be between minimum and\n maximum\ + \ allowed ages.\n with_synthetic_personas: If True, appends additional\ + \ synthetic persona columns including\n personality traits, interests,\ + \ and background descriptions. Only supported for certain\n locales\ + \ with managed datasets.\n sample_dataset_when_available: If True, samples\ + \ from curated managed datasets when available\n for the specified\ + \ locale. If False or unavailable, falls back to Faker-generated data.\n \ + \ Managed datasets typically provide more realistic and diverse synthetic\ + \ people." + PlatformJobListResultResponse: + properties: + object: + type: string + title: Object + description: The type of object being returned. + default: list + data: + items: + $ref: '#/components/schemas/PlatformJobResultResponse' + type: array + title: Data + type: object + required: + - data + title: PlatformJobListResultResponse + PlatformJobLog: + properties: + timestamp: + type: string + format: date-time + title: Timestamp + job_id: + type: string + title: Job Id + job_step: + type: string + title: Job Step + job_task: + type: string + title: Job Task + message: + type: string + title: Message + type: object + required: + - timestamp + - job_id + - job_step + - job_task + - message + title: PlatformJobLog + PlatformJobLogPage: + properties: + object: + type: string + title: Object + description: The type of object being returned. + default: list + data: + items: + $ref: '#/components/schemas/PlatformJobLog' + type: array + title: Data + total: + type: integer + title: Total + next_page: + type: string + title: Next Page + prev_page: + type: string + title: Prev Page + type: object + required: + - data + - total + - next_page + - prev_page + title: PlatformJobLogPage + PlatformJobResultResponse: + properties: + result_name: + type: string + title: Result Name + job_id: + type: string + title: Job Id + namespace: + type: string + title: Namespace + project: + type: string + title: Project + created_at: + type: string + format: date-time + title: Created At + updated_at: + type: string + format: date-time + title: Updated At + artifact_url: + type: string + title: Artifact Url + artifact_storage_type: + $ref: '#/components/schemas/FileStorageType' + type: object + required: + - result_name + - job_id + - namespace + - artifact_url + - artifact_storage_type + title: PlatformJobResultResponse + PlatformJobStatus: + type: string + enum: + - created + - pending + - active + - cancelled + - cancelling + - error + - completed + - paused + - pausing + - resuming + title: PlatformJobStatus + description: 'Enumeration of possible job statuses. + + + This enum represents the various states a job can be in during its lifecycle, + + from creation to a terminal state.' + PlatformJobStatusResponse: + properties: + job_id: + type: string + title: Job Id + status: + $ref: '#/components/schemas/PlatformJobStatus' + status_details: + additionalProperties: true + type: object + title: Status Details + error_details: + type: object + additionalProperties: true + title: Error Details + steps: + items: + $ref: '#/components/schemas/PlatformJobStepStatusResponse' + type: array + title: Steps + type: object + required: + - job_id + - status + - status_details + - error_details + - steps + title: PlatformJobStatusResponse + PlatformJobStepStatusResponse: + properties: + name: + type: string + title: Name + status: + $ref: '#/components/schemas/PlatformJobStatus' + status_details: + additionalProperties: true + type: object + title: Status Details + error_details: + type: object + additionalProperties: true + title: Error Details + tasks: + items: + $ref: '#/components/schemas/PlatformJobTaskStatusResponse' + type: array + title: Tasks + type: object + required: + - name + - status + - status_details + - error_details + - tasks + title: PlatformJobStepStatusResponse + PlatformJobTaskStatusResponse: + properties: + id: + type: string + title: Id + status: + $ref: '#/components/schemas/PlatformJobStatus' + status_details: + additionalProperties: true + type: object + title: Status Details + error_details: + type: object + additionalProperties: true + title: Error Details + error_stack: + type: string + title: Error Stack + type: object + required: + - id + - status + - status_details + - error_details + - error_stack + title: PlatformJobTaskStatusResponse + PoissonSamplerParams: + properties: + mean: + type: number + title: Mean + description: Mean number of events in a fixed interval. + sampler_type: + type: string + const: poisson + title: Sampler Type + default: poisson + additionalProperties: false + type: object + required: + - mean + title: PoissonSamplerParams + description: "Parameters for sampling from a Poisson distribution.\n\nSamples\ + \ non-negative integer values representing the number of events occurring\ + \ in a fixed\ninterval of time or space. The Poisson distribution is commonly\ + \ used to model count data\nlike the number of arrivals, occurrences, or events\ + \ per time period.\n\nThe distribution is characterized by a single parameter\ + \ (mean/rate), and both the mean and\nvariance equal this parameter value.\n\ + \nAttributes:\n mean: Mean number of events in the fixed interval (also\ + \ called rate parameter \u03BB).\n Must be positive. This represents\ + \ both the expected value and the variance of the\n distribution." + PreviewMessage: + properties: + message: + type: string + title: Message + message_type: + $ref: '#/components/schemas/MessageType' + extra: + type: object + additionalProperties: + type: string + title: Extra + additionalProperties: false + type: object + required: + - message + - message_type + title: PreviewMessage + PreviewRequest: + properties: + config: + $ref: '#/components/schemas/DataDesignerConfig' + num_records: + type: integer + title: Num Records + type: object + required: + - config + title: PreviewRequest + ProcessorConfig: + properties: + build_stage: + allOf: + - $ref: '#/components/schemas/BuildStage' + description: 'The stage at which the processor will run. Supported stages: + post_batch' + additionalProperties: false + type: object + required: + - build_stage + title: ProcessorConfig + RemoteValidatorParams: + properties: + endpoint_url: + type: string + title: Endpoint Url + description: URL of the remote endpoint + output_schema: + type: object + additionalProperties: true + title: Output Schema + description: Expected schema for remote validator's output + timeout: + type: number + exclusiveMinimum: 0.0 + title: Timeout + description: The timeout for the HTTP request + default: 30.0 + max_retries: + type: integer + minimum: 0.0 + title: Max Retries + description: The maximum number of retry attempts + default: 3 + retry_backoff: + type: number + exclusiveMinimum: 1.0 + title: Retry Backoff + description: The backoff factor for the retry delay + default: 2.0 + max_parallel_requests: + type: integer + minimum: 1.0 + title: Max Parallel Requests + description: The maximum number of parallel requests to make + default: 4 + additionalProperties: false + type: object + required: + - endpoint_url + title: RemoteValidatorParams + description: "Configuration for remote validation. Sends data to a remote endpoint\ + \ for validation.\n\nAttributes:\n endpoint_url: The URL of the remote\ + \ endpoint.\n output_schema: The JSON schema for the remote validator's\ + \ output. If not provided,\n the output will not be validated.\n \ + \ timeout: The timeout for the HTTP request in seconds. Defaults to 30.0.\n\ + \ max_retries: The maximum number of retry attempts. Defaults to 3.\n \ + \ retry_backoff: The backoff factor for the retry delay in seconds. Defaults\ + \ to 2.0.\n max_parallel_requests: The maximum number of parallel requests\ + \ to make. Defaults to 4." + SamplerColumnConfig: + properties: + name: + type: string + title: Name + drop: + type: boolean + title: Drop + default: false + column_type: + type: string + const: sampler + title: Column Type + default: sampler + sampler_type: + $ref: '#/components/schemas/SamplerType' + params: + oneOf: + - $ref: '#/components/schemas/SubcategorySamplerParams' + - $ref: '#/components/schemas/CategorySamplerParams' + - $ref: '#/components/schemas/DatetimeSamplerParams' + - $ref: '#/components/schemas/PersonSamplerParams' + - $ref: '#/components/schemas/PersonFromFakerSamplerParams' + - $ref: '#/components/schemas/TimeDeltaSamplerParams' + - $ref: '#/components/schemas/UUIDSamplerParams' + - $ref: '#/components/schemas/BernoulliSamplerParams' + - $ref: '#/components/schemas/BernoulliMixtureSamplerParams' + - $ref: '#/components/schemas/BinomialSamplerParams' + - $ref: '#/components/schemas/GaussianSamplerParams' + - $ref: '#/components/schemas/PoissonSamplerParams' + - $ref: '#/components/schemas/UniformSamplerParams' + - $ref: '#/components/schemas/ScipySamplerParams' + title: Params + discriminator: + propertyName: sampler_type + mapping: + bernoulli: '#/components/schemas/BernoulliSamplerParams' + bernoulli_mixture: '#/components/schemas/BernoulliMixtureSamplerParams' + binomial: '#/components/schemas/BinomialSamplerParams' + category: '#/components/schemas/CategorySamplerParams' + datetime: '#/components/schemas/DatetimeSamplerParams' + gaussian: '#/components/schemas/GaussianSamplerParams' + person: '#/components/schemas/PersonSamplerParams' + person_from_faker: '#/components/schemas/PersonFromFakerSamplerParams' + poisson: '#/components/schemas/PoissonSamplerParams' + scipy: '#/components/schemas/ScipySamplerParams' + subcategory: '#/components/schemas/SubcategorySamplerParams' + timedelta: '#/components/schemas/TimeDeltaSamplerParams' + uniform: '#/components/schemas/UniformSamplerParams' + uuid: '#/components/schemas/UUIDSamplerParams' + conditional_params: + additionalProperties: + oneOf: + - $ref: '#/components/schemas/SubcategorySamplerParams' + - $ref: '#/components/schemas/CategorySamplerParams' + - $ref: '#/components/schemas/DatetimeSamplerParams' + - $ref: '#/components/schemas/PersonSamplerParams' + - $ref: '#/components/schemas/PersonFromFakerSamplerParams' + - $ref: '#/components/schemas/TimeDeltaSamplerParams' + - $ref: '#/components/schemas/UUIDSamplerParams' + - $ref: '#/components/schemas/BernoulliSamplerParams' + - $ref: '#/components/schemas/BernoulliMixtureSamplerParams' + - $ref: '#/components/schemas/BinomialSamplerParams' + - $ref: '#/components/schemas/GaussianSamplerParams' + - $ref: '#/components/schemas/PoissonSamplerParams' + - $ref: '#/components/schemas/UniformSamplerParams' + - $ref: '#/components/schemas/ScipySamplerParams' + discriminator: + propertyName: sampler_type + mapping: + bernoulli: '#/components/schemas/BernoulliSamplerParams' + bernoulli_mixture: '#/components/schemas/BernoulliMixtureSamplerParams' + binomial: '#/components/schemas/BinomialSamplerParams' + category: '#/components/schemas/CategorySamplerParams' + datetime: '#/components/schemas/DatetimeSamplerParams' + gaussian: '#/components/schemas/GaussianSamplerParams' + person: '#/components/schemas/PersonSamplerParams' + person_from_faker: '#/components/schemas/PersonFromFakerSamplerParams' + poisson: '#/components/schemas/PoissonSamplerParams' + scipy: '#/components/schemas/ScipySamplerParams' + subcategory: '#/components/schemas/SubcategorySamplerParams' + timedelta: '#/components/schemas/TimeDeltaSamplerParams' + uniform: '#/components/schemas/UniformSamplerParams' + uuid: '#/components/schemas/UUIDSamplerParams' + type: object + title: Conditional Params + default: {} + convert_to: + type: string + title: Convert To + additionalProperties: false + type: object + required: + - name + - sampler_type + - params + title: SamplerColumnConfig + description: "Configuration for columns generated using numerical samplers.\n\ + \nSampler columns provide efficient data generation using numerical samplers\ + \ for\ncommon data types and distributions. Supported samplers include UUID\ + \ generation,\ndatetime/timedelta sampling, person generation, category /\ + \ subcategory sampling,\nand various statistical distributions (uniform, gaussian,\ + \ binomial, poisson, scipy).\n\nAttributes:\n sampler_type: Type of sampler\ + \ to use. Available types include:\n \"uuid\", \"category\", \"subcategory\"\ + , \"uniform\", \"gaussian\", \"bernoulli\",\n \"bernoulli_mixture\"\ + , \"binomial\", \"poisson\", \"scipy\", \"person\", \"datetime\", \"timedelta\"\ + .\n params: Parameters specific to the chosen sampler type. Type varies\ + \ based on the `sampler_type`\n (e.g., `CategorySamplerParams`, `UniformSamplerParams`,\ + \ `PersonSamplerParams`).\n conditional_params: Optional dictionary for\ + \ conditional parameters. The dict keys\n are the conditions that must\ + \ be met (e.g., \"age > 21\") for the conditional parameters\n to be\ + \ used. The values of dict are the parameters to use when the condition is\ + \ met.\n convert_to: Optional type conversion to apply after sampling.\ + \ Must be one of \"float\", \"int\", or \"str\".\n Useful for converting\ + \ numerical samples to strings or other types.\n column_type: Discriminator\ + \ field, always \"sampler\" for this configuration type.\n\n!!! tip \"Displaying\ + \ available samplers and their parameters\"\n The config builder has an\ + \ `info` attribute that can be used to display the\n available samplers\ + \ and their parameters:\n ```python\n config_builder.info.display(\"\ + samplers\")\n ```" + SamplerType: + type: string + enum: + - bernoulli + - bernoulli_mixture + - binomial + - category + - datetime + - gaussian + - person + - person_from_faker + - poisson + - scipy + - subcategory + - timedelta + - uniform + - uuid + title: SamplerType + SamplingStrategy: + type: string + enum: + - ordered + - shuffle + title: SamplingStrategy + ScalarInequalityConstraint: + properties: + target_column: + type: string + title: Target Column + rhs: + type: number + title: Rhs + operator: + $ref: '#/components/schemas/InequalityOperator' + additionalProperties: false + type: object + required: + - target_column + - rhs + - operator + title: ScalarInequalityConstraint + ScipySamplerParams: + properties: + dist_name: + type: string + title: Dist Name + description: Name of a scipy.stats distribution. + dist_params: + additionalProperties: true + type: object + title: Dist Params + description: Parameters of the scipy.stats distribution given in `dist_name`. + decimal_places: + type: integer + title: Decimal Places + description: Number of decimal places to round the sampled values to. + sampler_type: + type: string + const: scipy + title: Sampler Type + default: scipy + additionalProperties: false + type: object + required: + - dist_name + - dist_params + title: ScipySamplerParams + description: "Parameters for sampling from any scipy.stats continuous or discrete\ + \ distribution.\n\nProvides a flexible interface to sample from the wide range\ + \ of probability distributions\navailable in scipy.stats. This enables advanced\ + \ statistical sampling beyond the built-in\ndistribution types (Gaussian,\ + \ Uniform, etc.).\n\nSee: [scipy.stats documentation](https://docs.scipy.org/doc/scipy/reference/stats.html)\n\ + \nAttributes:\n dist_name: Name of the scipy.stats distribution to sample\ + \ from (e.g., \"beta\", \"gamma\",\n \"lognorm\", \"expon\"). Must\ + \ be a valid distribution name from scipy.stats.\n dist_params: Dictionary\ + \ of parameters for the specified distribution. Parameter names\n and\ + \ values must match the scipy.stats distribution specification (e.g., {\"\ + a\": 2, \"b\": 5}\n for beta distribution, {\"scale\": 1.5} for exponential).\n\ + \ decimal_places: Optional number of decimal places to round sampled values\ + \ to. If None,\n values are not rounded." + Score: + properties: + name: + type: string + title: Name + description: A clear name for this score. + description: + type: string + title: Description + description: An informative and detailed assessment guide for using this + score. + options: + additionalProperties: + type: string + type: object + title: Options + description: 'Score options in the format of {score: description}.' + additionalProperties: false + type: object + required: + - name + - description + - options + title: Score + description: "Configuration for a \"score\" in an LLM judge evaluation.\n\n\ + Defines a single scoring criterion with its possible values and descriptions.\ + \ Multiple\nScore objects can be combined in an LLMJudgeColumnConfig to create\ + \ multi-dimensional\nquality assessments.\n\nAttributes:\n name: A clear,\ + \ concise name for this scoring dimension (e.g., \"Relevance\", \"Fluency\"\ + ).\n description: An informative and detailed assessment guide explaining\ + \ how to evaluate\n this dimension. Should provide clear criteria for\ + \ scoring.\n options: Dictionary mapping score values to their descriptions.\ + \ Keys can be integers\n (e.g., 1-5 scale) or strings (e.g., \"Poor\"\ + , \"Good\", \"Excellent\"). Values are\n descriptions explaining what\ + \ each score level means." + SeedConfig: + properties: + dataset: + type: string + title: Dataset + sampling_strategy: + allOf: + - $ref: '#/components/schemas/SamplingStrategy' + default: ordered + selection_strategy: + anyOf: + - $ref: '#/components/schemas/IndexRange' + - $ref: '#/components/schemas/PartitionBlock' + title: Selection Strategy + additionalProperties: false + type: object + required: + - dataset + title: SeedConfig + description: "Configuration for sampling data from a seed dataset.\n\nArgs:\n\ + \ dataset: Path or identifier for the seed dataset.\n sampling_strategy:\ + \ Strategy for how to sample rows from the dataset.\n - ORDERED: Read\ + \ rows sequentially in their original order.\n - SHUFFLE: Randomly\ + \ shuffle rows before sampling. When used with\n selection_strategy,\ + \ shuffling occurs within the selected range/partition.\n selection_strategy:\ + \ Optional strategy to select a subset of the dataset.\n - IndexRange:\ + \ Select a specific range of indices (e.g., rows 100-200).\n - PartitionBlock:\ + \ Select a partition by splitting the dataset into N equal parts.\n \ + \ Partition indices are zero-based (index=0 is the first partition, index=1\ + \ is\n the second, etc.).\n\nExamples:\n Read rows sequentially\ + \ from start to end:\n SeedConfig(dataset=\"my_data.parquet\", sampling_strategy=SamplingStrategy.ORDERED)\n\ + \n Read rows in random order:\n SeedConfig(dataset=\"my_data.parquet\"\ + , sampling_strategy=SamplingStrategy.SHUFFLE)\n\n Read specific index range\ + \ (rows 100-199):\n SeedConfig(\n dataset=\"my_data.parquet\"\ + ,\n sampling_strategy=SamplingStrategy.ORDERED,\n selection_strategy=IndexRange(start=100,\ + \ end=199)\n )\n\n Read random rows from a specific index range\ + \ (shuffles within rows 100-199):\n SeedConfig(\n dataset=\"\ + my_data.parquet\",\n sampling_strategy=SamplingStrategy.SHUFFLE,\n\ + \ selection_strategy=IndexRange(start=100, end=199)\n )\n\ + \n Read from partition 2 (3rd partition, zero-based) of 5 partitions (20%\ + \ of dataset):\n SeedConfig(\n dataset=\"my_data.parquet\"\ + ,\n sampling_strategy=SamplingStrategy.ORDERED,\n selection_strategy=PartitionBlock(index=2,\ + \ num_partitions=5)\n )\n\n Read shuffled rows from partition 0\ + \ of 10 partitions (shuffles within the partition):\n SeedConfig(\n\ + \ dataset=\"my_data.parquet\",\n sampling_strategy=SamplingStrategy.SHUFFLE,\n\ + \ selection_strategy=PartitionBlock(index=0, num_partitions=10)\n\ + \ )" + SeedDatasetColumnConfig: + properties: + name: + type: string + title: Name + drop: + type: boolean + title: Drop + default: false + column_type: + type: string + const: seed-dataset + title: Column Type + default: seed-dataset + additionalProperties: false + type: object + required: + - name + title: SeedDatasetColumnConfig + description: "Configuration for columns sourced from seed datasets.\n\nThis\ + \ config marks columns that come from seed data. It is typically created\n\ + automatically when calling `with_seed_dataset()` on the builder, rather than\n\ + being instantiated directly by users.\n\nAttributes:\n column_type: Discriminator\ + \ field, always \"seed-dataset\" for this configuration type." + SettingsDefaults: + properties: + model_configs: + items: + $ref: '#/components/schemas/ModelConfigOutput' + type: array + title: Model Configs + model_provider: + type: string + title: Model Provider + type: object + required: + - model_configs + - model_provider + title: SettingsDefaults + SettingsResponse: + properties: + defaults: + $ref: '#/components/schemas/SettingsDefaults' + model_providers: + items: + $ref: '#/components/schemas/DisplayModelProvider' + type: array + title: Model Providers + type: object + required: + - defaults + - model_providers + title: SettingsResponse + SubcategorySamplerParams: + properties: + category: + type: string + title: Category + description: Name of parent category to this subcategory. + values: + additionalProperties: + items: + anyOf: + - type: string + - type: integer + - type: number + type: array + type: object + title: Values + description: Mapping from each value of parent category to a list of subcategory + values. + sampler_type: + type: string + const: subcategory + title: Sampler Type + default: subcategory + additionalProperties: false + type: object + required: + - category + - values + title: SubcategorySamplerParams + description: "Parameters for subcategory sampling conditioned on a parent category\ + \ column.\n\nSamples subcategory values based on the value of a parent category\ + \ column. Each parent\ncategory value maps to its own list of possible subcategory\ + \ values, enabling hierarchical\nor conditional sampling patterns.\n\nAttributes:\n\ + \ category: Name of the parent category column that this subcategory depends\ + \ on.\n The parent column must be generated before this subcategory\ + \ column.\n values: Mapping from each parent category value to a list of\ + \ possible subcategory values.\n Each key must correspond to a value\ + \ that appears in the parent category column." + TimeDeltaSamplerParams: + properties: + dt_min: + type: integer + minimum: 0.0 + title: Dt Min + description: Minimum possible time-delta for sampling range, inclusive. + Must be less than `dt_max`. + dt_max: + type: integer + exclusiveMinimum: 0.0 + title: Dt Max + description: Maximum possible time-delta for sampling range, exclusive. + Must be greater than `dt_min`. + reference_column_name: + type: string + title: Reference Column Name + description: Name of an existing datetime column to condition time-delta + sampling on. + unit: + type: string + enum: + - D + - h + - m + - s + title: Unit + description: Sampling units, e.g. the smallest possible time interval between + samples. + default: D + sampler_type: + type: string + const: timedelta + title: Sampler Type + default: timedelta + additionalProperties: false + type: object + required: + - dt_min + - dt_max + - reference_column_name + title: TimeDeltaSamplerParams + description: "Parameters for sampling time deltas relative to a reference datetime\ + \ column.\n\nSamples time offsets within a specified range and adds them to\ + \ values from a reference\ndatetime column. This is useful for generating\ + \ related datetime columns like order dates\nand delivery dates, or event\ + \ start times and end times.\n\nNote:\n Years and months are not supported\ + \ as timedelta units because they have variable lengths.\n See: [pandas\ + \ timedelta documentation](https://pandas.pydata.org/docs/user_guide/timedeltas.html)\n\ + \nAttributes:\n dt_min: Minimum time-delta value (inclusive). Must be non-negative\ + \ and less than `dt_max`.\n Specified in units defined by the `unit`\ + \ parameter.\n dt_max: Maximum time-delta value (exclusive). Must be positive\ + \ and greater than `dt_min`.\n Specified in units defined by the `unit`\ + \ parameter.\n reference_column_name: Name of an existing datetime column\ + \ to add the time-delta to.\n This column must be generated before\ + \ the timedelta column.\n unit: Time unit for the delta values. Options:\n\ + \ - \"D\": Days (default)\n - \"h\": Hours\n - \"m\"\ + : Minutes\n - \"s\": Seconds" + UUIDSamplerParams: + properties: + prefix: + type: string + title: Prefix + description: String prepended to the front of the UUID. + short_form: + type: boolean + title: Short Form + description: If true, all UUIDs sampled will be truncated at 8 characters. + default: false + uppercase: + type: boolean + title: Uppercase + description: If true, all letters in the UUID will be capitalized. + default: false + sampler_type: + type: string + const: uuid + title: Sampler Type + default: uuid + additionalProperties: false + type: object + title: UUIDSamplerParams + description: "Parameters for generating UUID (Universally Unique Identifier)\ + \ values.\n\nGenerates UUID4 (random) identifiers with optional formatting\ + \ options. UUIDs are useful\nfor creating unique identifiers for records,\ + \ entities, or transactions.\n\nAttributes:\n prefix: Optional string to\ + \ prepend to each UUID. Useful for creating namespaced or\n typed identifiers\ + \ (e.g., \"user-\", \"order-\", \"txn-\").\n short_form: If True, truncates\ + \ UUIDs to 8 characters (first segment only). Default is False\n for\ + \ full 32-character UUIDs (excluding hyphens).\n uppercase: If True, converts\ + \ all hexadecimal letters to uppercase. Default is False for\n lowercase\ + \ UUIDs." + UniformDistribution: + properties: + distribution_type: + allOf: + - $ref: '#/components/schemas/DistributionType' + default: uniform + params: + $ref: '#/components/schemas/UniformDistributionParams' + additionalProperties: false + type: object + required: + - params + title: UniformDistribution + UniformDistributionParams: + properties: + low: + type: number + title: Low + high: + type: number + title: High + additionalProperties: false + type: object + required: + - low + - high + title: UniformDistributionParams + UniformSamplerParams: + properties: + low: + type: number + title: Low + description: Lower bound of the uniform distribution, inclusive. + high: + type: number + title: High + description: Upper bound of the uniform distribution, inclusive. + decimal_places: + type: integer + title: Decimal Places + description: Number of decimal places to round the sampled values to. + sampler_type: + type: string + const: uniform + title: Sampler Type + default: uniform + additionalProperties: false + type: object + required: + - low + - high + title: UniformSamplerParams + description: "Parameters for sampling from a continuous Uniform distribution.\n\ + \nSamples continuous values uniformly from a specified range, where every\ + \ value in the range\nhas equal probability of being sampled. This is useful\ + \ when all values within a range are\nequally likely, such as random percentages,\ + \ proportions, or unbiased measurements.\n\nAttributes:\n low: Lower bound\ + \ of the uniform distribution (inclusive). Can be any real number.\n high:\ + \ Upper bound of the uniform distribution (inclusive). Must be greater than\ + \ `low`.\n decimal_places: Optional number of decimal places to round sampled\ + \ values to. If None,\n values are not rounded and may have many decimal\ + \ places." + ValidationColumnConfig: + properties: + name: + type: string + title: Name + drop: + type: boolean + title: Drop + default: false + column_type: + type: string + const: validation + title: Column Type + default: validation + target_columns: + items: + type: string + type: array + title: Target Columns + validator_type: + $ref: '#/components/schemas/ValidatorType' + validator_params: + anyOf: + - $ref: '#/components/schemas/CodeValidatorParams' + - $ref: '#/components/schemas/LocalCallableValidatorParams' + - $ref: '#/components/schemas/RemoteValidatorParams' + title: Validator Params + batch_size: + type: integer + minimum: 1.0 + title: Batch Size + description: Number of records to process in each batch + default: 10 + additionalProperties: false + type: object + required: + - name + - target_columns + - validator_type + - validator_params + title: ValidationColumnConfig + description: "Configuration for validation columns that validate existing columns.\n\ + \nValidation columns execute validation logic against specified target columns\ + \ and return\nstructured results indicating pass/fail status with validation\ + \ details. Supports multiple\nvalidation strategies: code execution (Python/SQL),\ + \ local callable functions (library only),\nand remote HTTP endpoints.\n\n\ + Attributes:\n target_columns: List of column names to validate. These columns\ + \ are passed to the\n validator for validation. All target columns\ + \ must exist in the dataset\n before validation runs.\n validator_type:\ + \ The type of validator to use. Options:\n - \"code\": Execute code\ + \ (Python or SQL) for validation. The code receives a\n DataFrame\ + \ with target columns and must return a DataFrame with validation results.\n\ + \ - \"local_callable\": Call a local Python function with the data.\ + \ Only supported\n when running DataDesigner locally.\n -\ + \ \"remote\": Send data to a remote HTTP endpoint for validation. Useful for\n\ + \ validator_params: Parameters specific to the validator type. Type varies\ + \ by validator:\n - CodeValidatorParams: Specifies code language (python\ + \ or SQL dialect like\n \"sql:postgres\", \"sql:mysql\").\n \ + \ - LocalCallableValidatorParams: Provides validation function (Callable[[pd.DataFrame],\n\ + \ pd.DataFrame]) and optional output schema for validation results.\n\ + \ - RemoteValidatorParams: Configures endpoint URL, HTTP timeout, retry\ + \ behavior\n (max_retries, retry_backoff), and parallel request limits\ + \ (max_parallel_requests).\n batch_size: Number of records to process in\ + \ each validation batch. Defaults to 10.\n Larger batches are more\ + \ efficient but use more memory. Adjust based on validator\n complexity\ + \ and available resources.\n column_type: Discriminator field, always \"\ + validation\" for this configuration type." + ValidationError: + properties: + loc: + items: + anyOf: + - type: string + - type: integer + type: array + title: Location + msg: + type: string + title: Message + type: + type: string + title: Error Type + type: object + required: + - loc + - msg + - type + title: ValidationError + ValidatorType: + type: string + enum: + - code + - local_callable + - remote + title: ValidatorType +tags: +- name: Data Designer + description: Operations related to synthetic data generation. +- name: Health Checks + description: Operations related to NeMo Microservices platform health. diff --git a/studio/frontend/eslint.config.js b/studio/frontend/eslint.config.js new file mode 100644 index 0000000000..e76e018697 --- /dev/null +++ b/studio/frontend/eslint.config.js @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import js from "@eslint/js"; +import reactHooks from "eslint-plugin-react-hooks"; +import reactRefresh from "eslint-plugin-react-refresh"; +import { defineConfig, globalIgnores } from "eslint/config"; +import globals from "globals"; +import tseslint from "typescript-eslint"; + +export default defineConfig([ + globalIgnores(["dist", "**/._*"]), + { + files: ["**/*.{ts,tsx}"], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + rules: { + // Allow shadcn ui components to export variants + "react-refresh/only-export-components": [ + "warn", + { allowConstantExport: true }, + ], + // Import restrictions for architecture enforcement + "no-restricted-imports": [ + "error", + { + patterns: [ + // Prevent cross-feature imports + { + group: ["@/features/*/*"], + message: "Import from feature index only: @/features/[name]", + }, + // Prevent app layer from importing features internals + { + group: ["../features/*/**"], + message: "Use absolute imports: @/features/[name]", + }, + ], + }, + ], + }, + }, +]); diff --git a/studio/frontend/index.html b/studio/frontend/index.html new file mode 100644 index 0000000000..4f81ffd4ff --- /dev/null +++ b/studio/frontend/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + Unsloth Studio + + +
+ + + diff --git a/studio/frontend/package.json b/studio/frontend/package.json new file mode 100644 index 0000000000..9a607f1c1f --- /dev/null +++ b/studio/frontend/package.json @@ -0,0 +1,92 @@ +{ + "name": "unsloth-theme", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview", + "typecheck": "tsc -b --pretty false", + "biome:check": "biome check .", + "biome:fix": "biome check . --write" + }, + "dependencies": { + "@assistant-ui/react": "^0.12.10", + "@assistant-ui/react-markdown": "^0.12.3", + "@assistant-ui/react-streamdown": "^0.1.2", + "@base-ui/react": "^1.2.0", + "@dagrejs/dagre": "^2.0.4", + "@dagrejs/graphlib": "^3.0.4", + "@fontsource-variable/figtree": "^5.2.10", + "@fontsource-variable/inter": "^5.2.8", + "@fontsource-variable/space-grotesk": "^5.2.10", + "@hugeicons/core-free-icons": "^3.1.1", + "@hugeicons/react": "^1.1.5", + "@huggingface/hub": "^2.9.0", + "@langchain/core": "^1.1.27", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@streamdown/cjk": "1.0.2", + "@streamdown/code": "1.0.2", + "@streamdown/math": "1.0.2", + "@streamdown/mermaid": "1.0.2", + "@tailwindcss/vite": "^4.1.18", + "@tanstack/react-router": "^1.159.10", + "@tanstack/react-table": "^8.21.3", + "@toolwind/corner-shape": "^0.0.8-3", + "@types/canvas-confetti": "^1.9.0", + "@xyflow/react": "^12.10.0", + "assistant-stream": "^0.3.2", + "canvas-confetti": "^1.9.4", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "date-fns": "^4.1.0", + "dexie": "^4.3.0", + "framer-motion": "^11.18.2", + "js-yaml": "^4.1.1", + "katex": "^0.16.28", + "lucide-react": "^0.575.0", + "mammoth": "^1.11.0", + "motion": "^12.34.0", + "next": "^16.1.6", + "next-themes": "^0.4.6", + "radix-ui": "^1.4.3", + "react": "^19.2.4", + "react-day-picker": "^9.13.2", + "react-dom": "^19.2.4", + "react-resizable-panels": "^4.6.4", + "recharts": "3.7.0", + "remark-gfm": "^4.0.1", + "shadcn": "^3.8.4", + "sonner": "^2.0.7", + "streamdown": "2.3.0", + "tailwind-merge": "^3.4.0", + "tailwindcss": "^4.1.18", + "tw-animate-css": "^1.4.0", + "tw-shimmer": "^0.4.6", + "unpdf": "^1.4.0", + "zustand": "^5.0.11" + }, + "devDependencies": { + "@biomejs/biome": "^1.9.4", + "@eslint/js": "^9.39.1", + "@types/js-yaml": "^4.0.9", + "@types/node": "^24.10.1", + "@types/react": "^19.2.5", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.26", + "globals": "^16.5.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.55.0", + "vite": "^7.3.1" + } +} diff --git a/studio/frontend/public/Hellix font official/OTF/Hellix-SemiBold.otf b/studio/frontend/public/Hellix font official/OTF/Hellix-SemiBold.otf new file mode 100644 index 0000000000..595ee5d23e Binary files /dev/null and b/studio/frontend/public/Hellix font official/OTF/Hellix-SemiBold.otf differ diff --git a/studio/frontend/public/Hellix font official/TTF/Hellix-SemiBold.ttf b/studio/frontend/public/Hellix font official/TTF/Hellix-SemiBold.ttf new file mode 100644 index 0000000000..a1a8ece69c Binary files /dev/null and b/studio/frontend/public/Hellix font official/TTF/Hellix-SemiBold.ttf differ diff --git a/studio/frontend/public/Hellix font official/WEB/Hellix-SemiBold.woff b/studio/frontend/public/Hellix font official/WEB/Hellix-SemiBold.woff new file mode 100644 index 0000000000..c094199d85 Binary files /dev/null and b/studio/frontend/public/Hellix font official/WEB/Hellix-SemiBold.woff differ diff --git a/studio/frontend/public/Hellix font official/WEB/Hellix-SemiBold.woff2 b/studio/frontend/public/Hellix font official/WEB/Hellix-SemiBold.woff2 new file mode 100644 index 0000000000..1bef1a2756 Binary files /dev/null and b/studio/frontend/public/Hellix font official/WEB/Hellix-SemiBold.woff2 differ diff --git a/studio/frontend/public/Sloth emojis/241024 Sloth Drink Jus.png b/studio/frontend/public/Sloth emojis/241024 Sloth Drink Jus.png new file mode 100644 index 0000000000..dd3ff673a8 Binary files /dev/null and b/studio/frontend/public/Sloth emojis/241024 Sloth Drink Jus.png differ diff --git a/studio/frontend/public/Sloth emojis/251008 Sloth Pin.png b/studio/frontend/public/Sloth emojis/251008 Sloth Pin.png new file mode 100644 index 0000000000..3245c62c64 Binary files /dev/null and b/studio/frontend/public/Sloth emojis/251008 Sloth Pin.png differ diff --git a/studio/frontend/public/Sloth emojis/FO2C6766BA42 Sloth Gift.png b/studio/frontend/public/Sloth emojis/FO2C6766BA42 Sloth Gift.png new file mode 100644 index 0000000000..72edda6e40 Binary files /dev/null and b/studio/frontend/public/Sloth emojis/FO2C6766BA42 Sloth Gift.png differ diff --git a/studio/frontend/public/Sloth emojis/FO71A40FA5581 Sloth and Llama.png b/studio/frontend/public/Sloth emojis/FO71A40FA5581 Sloth and Llama.png new file mode 100644 index 0000000000..b420edf499 Binary files /dev/null and b/studio/frontend/public/Sloth emojis/FO71A40FA5581 Sloth and Llama.png differ diff --git a/studio/frontend/public/Sloth emojis/Large sloth Question mark.png b/studio/frontend/public/Sloth emojis/Large sloth Question mark.png new file mode 100644 index 0000000000..559e7890f6 Binary files /dev/null and b/studio/frontend/public/Sloth emojis/Large sloth Question mark.png differ diff --git a/studio/frontend/public/Sloth emojis/Sloth loca pc.png b/studio/frontend/public/Sloth emojis/Sloth loca pc.png new file mode 100644 index 0000000000..65824b7faa Binary files /dev/null and b/studio/frontend/public/Sloth emojis/Sloth loca pc.png differ diff --git a/studio/frontend/public/Sloth emojis/Sloth w Gameboy Confetti no Logo.png b/studio/frontend/public/Sloth emojis/Sloth w Gameboy Confetti no Logo.png new file mode 100644 index 0000000000..574371526f Binary files /dev/null and b/studio/frontend/public/Sloth emojis/Sloth w Gameboy Confetti no Logo.png differ diff --git a/studio/frontend/public/Sloth emojis/Sloth w PC Confetti no Logo.png b/studio/frontend/public/Sloth emojis/Sloth w PC Confetti no Logo.png new file mode 100644 index 0000000000..cb339ee666 Binary files /dev/null and b/studio/frontend/public/Sloth emojis/Sloth w PC Confetti no Logo.png differ diff --git a/studio/frontend/public/Sloth emojis/Sloth w PC no Logo.png b/studio/frontend/public/Sloth emojis/Sloth w PC no Logo.png new file mode 100644 index 0000000000..caad48432c Binary files /dev/null and b/studio/frontend/public/Sloth emojis/Sloth w PC no Logo.png differ diff --git a/studio/frontend/public/Sloth emojis/UnSloth Eat GPU Mouth.png b/studio/frontend/public/Sloth emojis/UnSloth Eat GPU Mouth.png new file mode 100644 index 0000000000..4cbc362eef Binary files /dev/null and b/studio/frontend/public/Sloth emojis/UnSloth Eat GPU Mouth.png differ diff --git a/studio/frontend/public/Sloth emojis/UnSloth Eat GPU.png b/studio/frontend/public/Sloth emojis/UnSloth Eat GPU.png new file mode 100644 index 0000000000..cec14ef5f5 Binary files /dev/null and b/studio/frontend/public/Sloth emojis/UnSloth Eat GPU.png differ diff --git a/studio/frontend/public/Sloth emojis/UnSloth GPU Front square.png b/studio/frontend/public/Sloth emojis/UnSloth GPU Front square.png new file mode 100644 index 0000000000..b2173e9609 Binary files /dev/null and b/studio/frontend/public/Sloth emojis/UnSloth GPU Front square.png differ diff --git a/studio/frontend/public/Sloth emojis/UnSloth Laptop.png b/studio/frontend/public/Sloth emojis/UnSloth Laptop.png new file mode 100644 index 0000000000..43aece398a Binary files /dev/null and b/studio/frontend/public/Sloth emojis/UnSloth Laptop.png differ diff --git a/studio/frontend/public/Sloth emojis/UnSloth Sparkling large.png b/studio/frontend/public/Sloth emojis/UnSloth Sparkling large.png new file mode 100644 index 0000000000..80c2b98101 Binary files /dev/null and b/studio/frontend/public/Sloth emojis/UnSloth Sparkling large.png differ diff --git a/studio/frontend/public/Sloth emojis/large sloth cheeky.png b/studio/frontend/public/Sloth emojis/large sloth cheeky.png new file mode 100644 index 0000000000..3f5ccfe819 Binary files /dev/null and b/studio/frontend/public/Sloth emojis/large sloth cheeky.png differ diff --git a/studio/frontend/public/Sloth emojis/large sloth drink.png b/studio/frontend/public/Sloth emojis/large sloth drink.png new file mode 100644 index 0000000000..9426353524 Binary files /dev/null and b/studio/frontend/public/Sloth emojis/large sloth drink.png differ diff --git a/studio/frontend/public/Sloth emojis/large sloth fire.png b/studio/frontend/public/Sloth emojis/large sloth fire.png new file mode 100644 index 0000000000..24aff8cf4d Binary files /dev/null and b/studio/frontend/public/Sloth emojis/large sloth fire.png differ diff --git a/studio/frontend/public/Sloth emojis/large sloth glasses.png b/studio/frontend/public/Sloth emojis/large sloth glasses.png new file mode 100644 index 0000000000..1743e14eb7 Binary files /dev/null and b/studio/frontend/public/Sloth emojis/large sloth glasses.png differ diff --git a/studio/frontend/public/Sloth emojis/large sloth heart.png b/studio/frontend/public/Sloth emojis/large sloth heart.png new file mode 100644 index 0000000000..9635964077 Binary files /dev/null and b/studio/frontend/public/Sloth emojis/large sloth heart.png differ diff --git a/studio/frontend/public/Sloth emojis/large sloth laugh.png b/studio/frontend/public/Sloth emojis/large sloth laugh.png new file mode 100644 index 0000000000..d2619f9426 Binary files /dev/null and b/studio/frontend/public/Sloth emojis/large sloth laugh.png differ diff --git a/studio/frontend/public/Sloth emojis/large sloth sad.png b/studio/frontend/public/Sloth emojis/large sloth sad.png new file mode 100644 index 0000000000..45f2d736b9 Binary files /dev/null and b/studio/frontend/public/Sloth emojis/large sloth sad.png differ diff --git a/studio/frontend/public/Sloth emojis/large sloth thumbs.png b/studio/frontend/public/Sloth emojis/large sloth thumbs.png new file mode 100644 index 0000000000..29eeb499d8 Binary files /dev/null and b/studio/frontend/public/Sloth emojis/large sloth thumbs.png differ diff --git a/studio/frontend/public/Sloth emojis/large sloth wave.png b/studio/frontend/public/Sloth emojis/large sloth wave.png new file mode 100644 index 0000000000..134302f250 Binary files /dev/null and b/studio/frontend/public/Sloth emojis/large sloth wave.png differ diff --git a/studio/frontend/public/Sloth emojis/large sloth yay.png b/studio/frontend/public/Sloth emojis/large sloth yay.png new file mode 100644 index 0000000000..3bb7f8eb6a Binary files /dev/null and b/studio/frontend/public/Sloth emojis/large sloth yay.png differ diff --git a/studio/frontend/public/Sloth emojis/sloth headphones.png b/studio/frontend/public/Sloth emojis/sloth headphones.png new file mode 100644 index 0000000000..bad67f6bfa Binary files /dev/null and b/studio/frontend/public/Sloth emojis/sloth headphones.png differ diff --git a/studio/frontend/public/Sloth emojis/sloth huglove large.png b/studio/frontend/public/Sloth emojis/sloth huglove large.png new file mode 100644 index 0000000000..b668cf997d Binary files /dev/null and b/studio/frontend/public/Sloth emojis/sloth huglove large.png differ diff --git a/studio/frontend/public/Sloth emojis/sloth huglove large33.png b/studio/frontend/public/Sloth emojis/sloth huglove large33.png new file mode 100644 index 0000000000..c0da3181cc Binary files /dev/null and b/studio/frontend/public/Sloth emojis/sloth huglove large33.png differ diff --git a/studio/frontend/public/Sloth emojis/sloth magnify final(1).png b/studio/frontend/public/Sloth emojis/sloth magnify final(1).png new file mode 100644 index 0000000000..5106e4d3d0 Binary files /dev/null and b/studio/frontend/public/Sloth emojis/sloth magnify final(1).png differ diff --git a/studio/frontend/public/Sloth emojis/sloth magnify final.png b/studio/frontend/public/Sloth emojis/sloth magnify final.png new file mode 100644 index 0000000000..5106e4d3d0 Binary files /dev/null and b/studio/frontend/public/Sloth emojis/sloth magnify final.png differ diff --git a/studio/frontend/public/Sloth emojis/sloth on phone.png b/studio/frontend/public/Sloth emojis/sloth on phone.png new file mode 100644 index 0000000000..5aac20e033 Binary files /dev/null and b/studio/frontend/public/Sloth emojis/sloth on phone.png differ diff --git a/studio/frontend/public/Sloth emojis/sloth pc emoji.png b/studio/frontend/public/Sloth emojis/sloth pc emoji.png new file mode 100644 index 0000000000..0f6a046147 Binary files /dev/null and b/studio/frontend/public/Sloth emojis/sloth pc emoji.png differ diff --git a/studio/frontend/public/Sloth emojis/sloth pc square.png b/studio/frontend/public/Sloth emojis/sloth pc square.png new file mode 100644 index 0000000000..25b5eed328 Binary files /dev/null and b/studio/frontend/public/Sloth emojis/sloth pc square.png differ diff --git a/studio/frontend/public/Sloth emojis/sloth rounded.png b/studio/frontend/public/Sloth emojis/sloth rounded.png new file mode 100644 index 0000000000..1ed4205ba5 Binary files /dev/null and b/studio/frontend/public/Sloth emojis/sloth rounded.png differ diff --git a/studio/frontend/public/Sloth emojis/sloth shock large.png b/studio/frontend/public/Sloth emojis/sloth shock large.png new file mode 100644 index 0000000000..614caf8656 Binary files /dev/null and b/studio/frontend/public/Sloth emojis/sloth shock large.png differ diff --git a/studio/frontend/public/Sloth emojis/sloth shy large.png b/studio/frontend/public/Sloth emojis/sloth shy large.png new file mode 100644 index 0000000000..d8f03d4155 Binary files /dev/null and b/studio/frontend/public/Sloth emojis/sloth shy large.png differ diff --git a/studio/frontend/public/Sloth emojis/sloth sir large.png b/studio/frontend/public/Sloth emojis/sloth sir large.png new file mode 100644 index 0000000000..8e65fae2d8 Binary files /dev/null and b/studio/frontend/public/Sloth emojis/sloth sir large.png differ diff --git a/studio/frontend/public/Sloth emojis/sloth w pc transparent.png b/studio/frontend/public/Sloth emojis/sloth w pc transparent.png new file mode 100644 index 0000000000..17aedd7e92 Binary files /dev/null and b/studio/frontend/public/Sloth emojis/sloth w pc transparent.png differ diff --git a/studio/frontend/public/Sloth emojis/sloth with gameboy.png b/studio/frontend/public/Sloth emojis/sloth with gameboy.png new file mode 100644 index 0000000000..7cdcc7ee17 Binary files /dev/null and b/studio/frontend/public/Sloth emojis/sloth with gameboy.png differ diff --git a/studio/frontend/public/blacklogo.png b/studio/frontend/public/blacklogo.png new file mode 100644 index 0000000000..e74c19040a Binary files /dev/null and b/studio/frontend/public/blacklogo.png differ diff --git a/studio/frontend/public/favicon.png b/studio/frontend/public/favicon.png new file mode 100644 index 0000000000..86fbdf1fbc Binary files /dev/null and b/studio/frontend/public/favicon.png differ diff --git a/studio/frontend/public/fonts/Hellix-SemiBold.woff b/studio/frontend/public/fonts/Hellix-SemiBold.woff new file mode 100644 index 0000000000..c094199d85 Binary files /dev/null and b/studio/frontend/public/fonts/Hellix-SemiBold.woff differ diff --git a/studio/frontend/public/fonts/Hellix-SemiBold.woff2 b/studio/frontend/public/fonts/Hellix-SemiBold.woff2 new file mode 100644 index 0000000000..1bef1a2756 Binary files /dev/null and b/studio/frontend/public/fonts/Hellix-SemiBold.woff2 differ diff --git a/studio/frontend/public/huggingface.svg b/studio/frontend/public/huggingface.svg new file mode 100644 index 0000000000..c48757ff87 --- /dev/null +++ b/studio/frontend/public/huggingface.svg @@ -0,0 +1,4 @@ + + + +HuggingFace \ No newline at end of file diff --git a/studio/frontend/public/logotext.png b/studio/frontend/public/logotext.png new file mode 100644 index 0000000000..bec916be8b Binary files /dev/null and b/studio/frontend/public/logotext.png differ diff --git a/studio/frontend/public/unsloth-gem.png b/studio/frontend/public/unsloth-gem.png new file mode 100644 index 0000000000..662f5615dd Binary files /dev/null and b/studio/frontend/public/unsloth-gem.png differ diff --git a/studio/frontend/public/vite.svg b/studio/frontend/public/vite.svg new file mode 100644 index 0000000000..8f4c4a2a10 --- /dev/null +++ b/studio/frontend/public/vite.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/studio/frontend/public/whitelogo.png b/studio/frontend/public/whitelogo.png new file mode 100644 index 0000000000..9db7c0e943 Binary files /dev/null and b/studio/frontend/public/whitelogo.png differ diff --git a/studio/frontend/src/app/app.tsx b/studio/frontend/src/app/app.tsx new file mode 100644 index 0000000000..6ef72f7879 --- /dev/null +++ b/studio/frontend/src/app/app.tsx @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { RouterProvider } from "@tanstack/react-router"; +import { router } from "./router"; + +export function App() { + return ; +} diff --git a/studio/frontend/src/app/auth-guards.ts b/studio/frontend/src/app/auth-guards.ts new file mode 100644 index 0000000000..8ec1c92666 --- /dev/null +++ b/studio/frontend/src/app/auth-guards.ts @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { redirect } from "@tanstack/react-router"; +import { + getPostAuthRoute, + hasAuthToken, + hasRefreshToken, + refreshSession, +} from "@/features/auth"; + +async function hasActiveSession(): Promise { + if (hasAuthToken()) return true; + if (!hasRefreshToken()) return false; + return refreshSession(); +} + +async function checkAuthInitialized(): Promise { + try { + const res = await fetch("/api/auth/status"); + if (!res.ok) return true; // fallback to login on error + const data = (await res.json()) as { initialized: boolean }; + return data.initialized; + } catch { + return true; // fallback to login on error + } +} + +export async function requireAuth(): Promise { + if (await hasActiveSession()) return; + const initialized = await checkAuthInitialized(); + throw redirect({ to: initialized ? "/login" : "/signup" }); +} + +export async function requireGuest(): Promise { + if (!(await hasActiveSession())) return; + throw redirect({ to: getPostAuthRoute() }); +} diff --git a/studio/frontend/src/app/provider.tsx b/studio/frontend/src/app/provider.tsx new file mode 100644 index 0000000000..68ce3061bd --- /dev/null +++ b/studio/frontend/src/app/provider.tsx @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { Toaster } from "@/components/ui/sonner"; +import { ThemeProvider } from "next-themes"; +import type { ReactNode } from "react"; + +interface AppProviderProps { + children: ReactNode; +} + +export function AppProvider({ children }: AppProviderProps) { + return ( + + {children} + + + ); +} diff --git a/studio/frontend/src/app/router.tsx b/studio/frontend/src/app/router.tsx new file mode 100644 index 0000000000..d4046559a3 --- /dev/null +++ b/studio/frontend/src/app/router.tsx @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { createRouter } from "@tanstack/react-router"; +import { Route as rootRoute } from "./routes/__root"; +import { Route as dataRecipesRoute } from "./routes/data-recipes"; +import { Route as dataRecipeRoute } from "./routes/data-recipes.$recipeId"; +import { Route as chatRoute } from "./routes/chat"; +import { Route as exportRoute } from "./routes/export"; +import { Route as gridTestRoute } from "./routes/grid-test"; +import { Route as indexRoute } from "./routes/index"; +import { Route as loginRoute } from "./routes/login"; +import { Route as onboardingRoute } from "./routes/onboarding"; +import { Route as signupRoute } from "./routes/signup"; +import { Route as studioRoute } from "./routes/studio"; + +const routeTree = rootRoute.addChildren([ + indexRoute, + onboardingRoute, + loginRoute, + signupRoute, + gridTestRoute, + studioRoute, + chatRoute, + exportRoute, + dataRecipesRoute, + dataRecipeRoute, +]); + +export const router = createRouter({ routeTree }); + +declare module "@tanstack/react-router" { + interface Register { + router: typeof router; + } +} diff --git a/studio/frontend/src/app/routes/__root.tsx b/studio/frontend/src/app/routes/__root.tsx new file mode 100644 index 0000000000..903e618d3e --- /dev/null +++ b/studio/frontend/src/app/routes/__root.tsx @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { Navbar } from "@/components/navbar"; +import { + Outlet, + createRootRoute, + useRouterState, +} from "@tanstack/react-router"; +import { AnimatePresence, motion } from "motion/react"; +import { Suspense } from "react"; +import { AppProvider } from "../provider"; + +export const Route = createRootRoute({ + component: RootLayout, +}); + +const HIDDEN_NAVBAR_ROUTES = ["/onboarding", "/login", "/signup"]; + +function RootLayout() { + const pathname = useRouterState({ select: (s) => s.location.pathname }); + const hideNavbar = HIDDEN_NAVBAR_ROUTES.includes(pathname); + + return ( + + {!hideNavbar && } + + + + + + + + + ); +} diff --git a/studio/frontend/src/app/routes/chat.tsx b/studio/frontend/src/app/routes/chat.tsx new file mode 100644 index 0000000000..e435f090bd --- /dev/null +++ b/studio/frontend/src/app/routes/chat.tsx @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { createRoute } from "@tanstack/react-router"; +import { lazy } from "react"; +import { requireAuth } from "../auth-guards"; +import { Route as rootRoute } from "./__root"; + +const ChatPage = lazy(() => + import("@/features/chat/chat-page").then((m) => ({ default: m.ChatPage })), +); + +export const Route = createRoute({ + getParentRoute: () => rootRoute, + path: "/chat", + beforeLoad: () => requireAuth(), + component: ChatPage, +}); diff --git a/studio/frontend/src/app/routes/data-recipes.$recipeId.tsx b/studio/frontend/src/app/routes/data-recipes.$recipeId.tsx new file mode 100644 index 0000000000..998633b5e7 --- /dev/null +++ b/studio/frontend/src/app/routes/data-recipes.$recipeId.tsx @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { createRoute } from "@tanstack/react-router"; +import type { ReactElement } from "react"; +import { lazy } from "react"; +import { requireAuth } from "../auth-guards"; +import { Route as rootRoute } from "./__root"; + +const EditRecipePage = lazy(() => + import("@/features/data-recipes").then((m) => ({ + default: m.EditRecipePage, + })), +); + +export const Route = createRoute({ + getParentRoute: () => rootRoute, + path: "/data-recipes/$recipeId", + beforeLoad: () => requireAuth(), + component: DataRecipeEditorRoute, +}); + +function DataRecipeEditorRoute(): ReactElement { + const { recipeId } = Route.useParams(); + return ; +} diff --git a/studio/frontend/src/app/routes/data-recipes.tsx b/studio/frontend/src/app/routes/data-recipes.tsx new file mode 100644 index 0000000000..bafdc43921 --- /dev/null +++ b/studio/frontend/src/app/routes/data-recipes.tsx @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { createRoute } from "@tanstack/react-router"; +import { lazy } from "react"; +import { requireAuth } from "../auth-guards"; +import { Route as rootRoute } from "./__root"; + +const DataRecipesPage = lazy(() => + import("@/features/data-recipes").then((m) => ({ + default: m.DataRecipesPage, + })), +); + +export const Route = createRoute({ + getParentRoute: () => rootRoute, + path: "/data-recipes", + beforeLoad: () => requireAuth(), + component: DataRecipesPage, +}); diff --git a/studio/frontend/src/app/routes/export.tsx b/studio/frontend/src/app/routes/export.tsx new file mode 100644 index 0000000000..4bb311e56d --- /dev/null +++ b/studio/frontend/src/app/routes/export.tsx @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { createRoute } from "@tanstack/react-router"; +import { lazy } from "react"; +import { requireAuth } from "../auth-guards"; +import { Route as rootRoute } from "./__root"; + +const ExportPage = lazy(() => + import("@/features/export/export-page").then((m) => ({ + default: m.ExportPage, + })), +); + +export const Route = createRoute({ + getParentRoute: () => rootRoute, + path: "/export", + beforeLoad: () => requireAuth(), + component: ExportPage, +}); diff --git a/studio/frontend/src/app/routes/grid-test.tsx b/studio/frontend/src/app/routes/grid-test.tsx new file mode 100644 index 0000000000..c4b6b505a1 --- /dev/null +++ b/studio/frontend/src/app/routes/grid-test.tsx @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { DashboardGrid, DashboardLayout } from "@/components/layout"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { createRoute } from "@tanstack/react-router"; +import { requireAuth } from "../auth-guards"; +import { Route as rootRoute } from "./__root"; + +export const Route = createRoute({ + getParentRoute: () => rootRoute, + path: "/grid-test", + beforeLoad: () => requireAuth(), + component: GridTestPage, +}); + +function GridTestPage() { + return ( + +
+
+

Grid Test - 3 Columns

+

+ max-w-7xl, gap-6, responsive 1→2→3 +

+
+ + + {[1, 2, 3].map((i) => ( + + + Card {i} + ~400px at 1280px viewport + + +
+ + + ))} + + +
+

4 Columns

+

~296px per card at 1280px

+
+ + + {[1, 2, 3, 4].map((i) => ( + + + Card {i} + Smaller cards + + +
+ + + ))} + +
+ + ); +} diff --git a/studio/frontend/src/app/routes/index.tsx b/studio/frontend/src/app/routes/index.tsx new file mode 100644 index 0000000000..96be1a974b --- /dev/null +++ b/studio/frontend/src/app/routes/index.tsx @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { createRoute, redirect } from "@tanstack/react-router"; +import { getPostAuthRoute } from "@/features/auth"; +import { requireAuth } from "../auth-guards"; +import { Route as rootRoute } from "./__root"; + +export const Route = createRoute({ + getParentRoute: () => rootRoute, + path: "/", + beforeLoad: async () => { + await requireAuth(); + throw redirect({ to: getPostAuthRoute() }); + }, + component: () => null, +}); diff --git a/studio/frontend/src/app/routes/login.tsx b/studio/frontend/src/app/routes/login.tsx new file mode 100644 index 0000000000..409ba53375 --- /dev/null +++ b/studio/frontend/src/app/routes/login.tsx @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { createRoute } from "@tanstack/react-router"; +import { lazy } from "react"; +import { requireGuest } from "../auth-guards"; +import { Route as rootRoute } from "./__root"; + +const LoginPage = lazy(() => + import("@/features/auth").then((m) => ({ default: m.LoginPage })), +); + +export const Route = createRoute({ + getParentRoute: () => rootRoute, + path: "/login", + beforeLoad: () => requireGuest(), + component: LoginPage, +}); diff --git a/studio/frontend/src/app/routes/onboarding.tsx b/studio/frontend/src/app/routes/onboarding.tsx new file mode 100644 index 0000000000..dcc3593b1a --- /dev/null +++ b/studio/frontend/src/app/routes/onboarding.tsx @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { createRoute } from "@tanstack/react-router"; +import { lazy } from "react"; +import { requireAuth } from "../auth-guards"; +import { Route as rootRoute } from "./__root"; + +const WizardLayout = lazy(() => + import("@/features/onboarding/components/wizard-layout").then((m) => ({ + default: m.WizardLayout, + })), +); + +export const Route = createRoute({ + getParentRoute: () => rootRoute, + path: "/onboarding", + beforeLoad: () => requireAuth(), + component: WizardLayout, +}); diff --git a/studio/frontend/src/app/routes/signup.tsx b/studio/frontend/src/app/routes/signup.tsx new file mode 100644 index 0000000000..8c93f1f09b --- /dev/null +++ b/studio/frontend/src/app/routes/signup.tsx @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { createRoute } from "@tanstack/react-router"; +import { lazy } from "react"; +import { requireGuest } from "../auth-guards"; +import { Route as rootRoute } from "./__root"; + +const SignupPage = lazy(() => + import("@/features/auth").then((m) => ({ + default: m.SignupPage, + })), +); + +export const Route = createRoute({ + getParentRoute: () => rootRoute, + path: "/signup", + beforeLoad: () => requireGuest(), + component: SignupPage, +}); diff --git a/studio/frontend/src/app/routes/studio.tsx b/studio/frontend/src/app/routes/studio.tsx new file mode 100644 index 0000000000..bfdabe882f --- /dev/null +++ b/studio/frontend/src/app/routes/studio.tsx @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { createRoute } from "@tanstack/react-router"; +import { lazy } from "react"; +import { requireAuth } from "../auth-guards"; +import { Route as rootRoute } from "./__root"; + +const StudioPage = lazy(() => + import("@/features/studio/studio-page").then((m) => ({ + default: m.StudioPage, + })), +); + +export const Route = createRoute({ + getParentRoute: () => rootRoute, + path: "/studio", + beforeLoad: () => requireAuth(), + component: StudioPage, +}); diff --git a/studio/frontend/src/assets/react.svg b/studio/frontend/src/assets/react.svg new file mode 100644 index 0000000000..2b67796fea --- /dev/null +++ b/studio/frontend/src/assets/react.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/studio/frontend/src/components/assistant-ui/attachment.tsx b/studio/frontend/src/components/assistant-ui/attachment.tsx new file mode 100644 index 0000000000..074dba5320 --- /dev/null +++ b/studio/frontend/src/components/assistant-ui/attachment.tsx @@ -0,0 +1,241 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"use client"; + +// Avatar removed — caused circular crop on image thumbnails +import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; +import { + Dialog, + DialogContent, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { + AttachmentPrimitive, + ComposerPrimitive, + MessagePrimitive, + useAui, + useAuiState, +} from "@assistant-ui/react"; +import { FileText, PlusIcon, XIcon } from "lucide-react"; +import { + type FC, + type PropsWithChildren, + useEffect, + useState, +} from "react"; +import { useShallow } from "zustand/shallow"; + +const useFileSrc = (file: File | undefined): string | undefined => { + const [objectUrl, setObjectUrl] = useState(undefined); + + useEffect(() => { + if (!file) { + setObjectUrl(undefined); + return; + } + const url = URL.createObjectURL(file); + setObjectUrl(url); + return () => URL.revokeObjectURL(url); + }, [file]); + + return objectUrl; +}; + +const useAttachmentSrc = (): string | undefined => { + const { file, src } = useAuiState( + useShallow(({ attachment }): { file?: File; src?: string } => { + if (attachment.type !== "image") { + return {}; + } + if (attachment.file) { + return { file: attachment.file }; + } + const src = attachment.content?.filter((c) => c.type === "image")[0] + ?.image; + if (!src) { + return {}; + } + return { src }; + }), + ); + + return useFileSrc(file) ?? src; +}; + +type AttachmentPreviewProps = { + src: string; +}; + +const AttachmentPreview: FC = ({ src }) => { + const [isLoaded, setIsLoaded] = useState(false); + return ( + Preview setIsLoaded(true)} + /> + ); +}; + +const AttachmentPreviewDialog: FC = ({ children }) => { + const src = useAttachmentSrc(); + + if (!src) { + return children; + } + + return ( + + + {children} + + + + Image Attachment Preview + +
+ +
+
+
+ ); +}; + +const AttachmentThumb: FC = () => { + const src = useAttachmentSrc(); + + if (src) { + return ( + Attachment preview + ); + } + + return ( +
+ +
+ ); +}; + +const AttachmentUI: FC = () => { + const aui = useAui(); + const isComposer = aui.attachment.source === "composer"; + + const isImage = useAuiState(({ attachment }) => attachment.type === "image"); + const typeLabel = useAuiState(({ attachment }) => { + const type = attachment.type; + switch (type) { + case "image": + return "Image"; + case "document": + return "Document"; + case "file": + return "File"; + default: + throw new Error(`Unknown attachment type: ${type as string}`); + } + }); + + return ( + + #attachment-tile]:size-16", + )} + > + + + + + + {isComposer && } + + + + + + ); +}; + +const AttachmentRemove: FC = () => { + return ( + + + + + + ); +}; + +export const UserMessageAttachments: FC = () => { + return ( +
+ +
+ ); +}; + +export const ComposerAttachments: FC = () => { + return ( +
+ +
+ ); +}; + +export const ComposerAddAttachment: FC = () => { + return ( + + + + + + ); +}; diff --git a/studio/frontend/src/components/assistant-ui/audio-player.tsx b/studio/frontend/src/components/assistant-ui/audio-player.tsx new file mode 100644 index 0000000000..6b99de8374 --- /dev/null +++ b/studio/frontend/src/components/assistant-ui/audio-player.tsx @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"use client"; + +import { Button } from "@/components/ui/button"; +import { DownloadIcon, PauseIcon, PlayIcon } from "lucide-react"; +import { type FC, useRef, useState } from "react"; + +interface AudioPlayerProps { + src: string; +} + +export const AudioPlayer: FC = ({ src }) => { + const audioRef = useRef(null); + const [isPlaying, setIsPlaying] = useState(false); + const [progress, setProgress] = useState(0); + const [duration, setDuration] = useState(0); + + const togglePlay = () => { + const audio = audioRef.current; + if (!audio) return; + if (isPlaying) { + audio.pause(); + } else { + audio.play(); + } + setIsPlaying(!isPlaying); + }; + + const handleTimeUpdate = () => { + const audio = audioRef.current; + if (!audio) return; + setProgress(audio.currentTime); + }; + + const handleLoadedMetadata = () => { + const audio = audioRef.current; + if (!audio) return; + setDuration(audio.duration); + }; + + const handleEnded = () => { + setIsPlaying(false); + setProgress(0); + }; + + const handleSeek = (e: React.ChangeEvent) => { + const audio = audioRef.current; + if (!audio) return; + const time = parseFloat(e.target.value); + audio.currentTime = time; + setProgress(time); + }; + + const handleDownload = () => { + const link = document.createElement("a"); + link.href = src; + link.download = "generated-audio.wav"; + link.click(); + }; + + const formatTime = (t: number) => { + const mins = Math.floor(t / 60); + const secs = Math.floor(t % 60); + return `${mins}:${secs.toString().padStart(2, "0")}`; + }; + + return ( +
+
+ ); +}; diff --git a/studio/frontend/src/components/assistant-ui/markdown-text.tsx b/studio/frontend/src/components/assistant-ui/markdown-text.tsx new file mode 100644 index 0000000000..ce8829a37f --- /dev/null +++ b/studio/frontend/src/components/assistant-ui/markdown-text.tsx @@ -0,0 +1,258 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"use client"; + +import { copyToClipboard } from "@/lib/copy-to-clipboard"; +import { INTERNAL, useMessagePartText } from "@assistant-ui/react"; +import { Copy02Icon, Tick02Icon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { code } from "@streamdown/code"; +import { math } from "@streamdown/math"; +import { mermaid } from "@streamdown/mermaid"; +import { DownloadIcon } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { Block, type BlockProps, Streamdown } from "streamdown"; +import "katex/dist/katex.min.css"; +import { AudioPlayer } from "./audio-player"; + +const { withSmoothContextProvider } = INTERNAL; +const COPY_RESET_MS = 2000; +const MERMAID_SOURCE_RE = /```mermaid\s*([\s\S]*?)```/i; +const CODE_FENCE_RE = /^```([^\r\n`]*)\r?\n([\s\S]*?)\r?\n?```$/; +const ACTION_PANEL_CLASS = + "pointer-events-auto flex shrink-0 items-center gap-2 rounded-md border border-sidebar bg-sidebar/80 px-1.5 py-1 supports-[backdrop-filter]:bg-sidebar/70 supports-[backdrop-filter]:backdrop-blur"; +const ACTION_BUTTON_CLASS = + "cursor-pointer p-1 text-muted-foreground transition-all hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"; + +type CodeFence = { + language: string | null; + source: string; +}; + +function getMermaidSource(blockContent: string): string | null { + const source = blockContent.match(MERMAID_SOURCE_RE)?.[1]?.trim(); + return source && source.length > 0 ? source : null; +} + +function getCodeFence(blockContent: string): CodeFence | null { + const match = blockContent.trimEnd().match(CODE_FENCE_RE); + if (!match) { + return null; + } + + return { + language: match[1]?.trim() || null, + source: match[2], + }; +} + +function getCodeFilename(language: string | null) { + const extByLanguage: Record = { + bash: "sh", + javascript: "js", + js: "js", + json: "json", + jsx: "jsx", + markdown: "md", + md: "md", + python: "py", + py: "py", + shell: "sh", + sh: "sh", + sql: "sql", + ts: "ts", + tsx: "tsx", + typescript: "ts", + yaml: "yml", + yml: "yml", + }; + + const normalized = language?.toLowerCase(); + const fallbackExt = normalized?.replace(/[^a-z0-9]+/g, "-"); + const ext = normalized + ? extByLanguage[normalized] || fallbackExt || "txt" + : "txt"; + return `snippet.${ext}`; +} + +function downloadTextFile(filename: string, text: string): void { + const blob = new Blob([text], { type: "text/plain;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + window.setTimeout(() => URL.revokeObjectURL(url), 0); +} + +function useCopiedState() { + const [copied, setCopied] = useState(false); + const resetTimeoutRef = useRef | null>(null); + + useEffect(() => { + return () => { + if (resetTimeoutRef.current) { + clearTimeout(resetTimeoutRef.current); + } + }; + }, []); + + const showCopied = () => { + setCopied(true); + if (resetTimeoutRef.current) { + clearTimeout(resetTimeoutRef.current); + } + resetTimeoutRef.current = setTimeout(() => { + setCopied(false); + resetTimeoutRef.current = null; + }, COPY_RESET_MS); + }; + + return { copied, showCopied }; +} + +function MermaidCopyButton({ source }: { source: string }) { + const { copied, showCopied } = useCopiedState(); + + return ( + + ); +} + +function CodeBlockActions({ + disabled, + language, + source, +}: { + disabled: boolean; + language: string | null; + source: string; +}) { + const { copied, showCopied } = useCopiedState(); + + return ( +
+
+ + +
+
+ ); +} + +function StreamdownBlock(props: BlockProps) { + const hasMermaidFence = props.content.includes("```mermaid"); + const mermaidSource = getMermaidSource(props.content); + const codeFence = getCodeFence(props.content); + + if (props.isIncomplete && hasMermaidFence) { + return ( +
+ Loading diagram... +
+ ); + } + + if (mermaidSource) { + return ( +
+ + +
+ ); + } + + if (codeFence) { + return ( +
+ + +
+ ); + } + + return ; +} +const AUDIO_PLAYER_RE = //; + +const MarkdownTextImpl = () => { + const { text, status } = useMessagePartText(); + + const audioMatch = text.match(AUDIO_PLAYER_RE); + if (audioMatch) { + return ; + } + + return ( +
+ + {text} + +
+ ); +}; + +export const MarkdownText = withSmoothContextProvider(MarkdownTextImpl); diff --git a/studio/frontend/src/components/assistant-ui/model-selector.tsx b/studio/frontend/src/components/assistant-ui/model-selector.tsx new file mode 100644 index 0000000000..28f94dbd19 --- /dev/null +++ b/studio/frontend/src/components/assistant-ui/model-selector.tsx @@ -0,0 +1,258 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"use client"; + +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { cn } from "@/lib/utils"; +import { + ArrowDown01Icon, + Logout01Icon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { useMemo, useState } from "react"; +import type { + LoraModelOption, + ModelOption, + ModelSelectorChangeMeta, +} from "./model-selector/types"; +import { HubModelPicker, LoraModelPicker } from "./model-selector/pickers"; + +export type { LoraModelOption, ModelOption, ModelSelectorChangeMeta } from "./model-selector/types"; + +interface ModelSelectorProps { + models: ModelOption[]; + loraModels?: LoraModelOption[]; + value?: string; + defaultValue?: string; + activeGgufVariant?: string | null; + onValueChange?: (value: string, meta: ModelSelectorChangeMeta) => void; + onEject?: () => void; + variant?: "outline" | "ghost" | "muted"; + size?: "sm" | "default" | "lg"; + className?: string; + contentClassName?: string; + open?: boolean; + onOpenChange?: (open: boolean) => void; + triggerDataTour?: string; + contentDataTour?: string; +} + +function ModelSelectorTrigger({ + currentModel, + isLoaded, + variant = "outline", + size = "default", + className, + dataTour, +}: { + currentModel?: ModelOption; + isLoaded: boolean; + variant?: "outline" | "ghost" | "muted"; + size?: "sm" | "default" | "lg"; + className?: string; + dataTour?: string; +}) { + return ( + + + + ); +} + +function ModelSelectorContent({ + models, + loraModels, + value, + onSelect, + onEject, + className, + dataTour, +}: { + models: ModelOption[]; + loraModels: LoraModelOption[]; + value?: string; + onSelect: (id: string, meta: ModelSelectorChangeMeta) => void; + onEject?: () => void; + className?: string; + dataTour?: string; +}) { + const hasSelection = Boolean(value); + + return ( + + + + Hub models + Fine-tuned + + + + + + + + + + + + {hasSelection && onEject ? ( +
+ +
+ ) : null} +
+ ); +} + +export function ModelSelector({ + models, + loraModels = [], + value, + defaultValue, + activeGgufVariant, + onValueChange, + onEject, + variant = "outline", + size = "default", + className, + contentClassName, + open: controlledOpen, + onOpenChange, + triggerDataTour, + contentDataTour, +}: ModelSelectorProps) { + const [uncontrolledOpen, setUncontrolledOpen] = useState(false); + const open = controlledOpen ?? uncontrolledOpen; + const setOpen = onOpenChange ?? setUncontrolledOpen; + const [uncontrolled, setUncontrolled] = useState(defaultValue ?? ""); + + const selected = value ?? uncontrolled; + const isLoaded = selected !== ""; + + const optionById = useMemo(() => { + const all = new Map(); + for (const model of models) { + all.set(model.id, model); + } + for (const lora of loraModels) { + // Strip "/ suffix" from display name (e.g. "foo_123/foo" → "foo_123") + const displayName = lora.name.includes("/") + ? lora.name.split("/")[0].trim() + : lora.name; + // Show type tag instead of base model name + const isExported = lora.source === "exported"; + const isMerged = lora.exportType === "merged"; + const tag = isExported + ? isMerged ? "Merged · Exported" : "LoRA" + : "LoRA"; + all.set(lora.id, { + ...lora, + name: displayName, + description: tag, + }); + } + return all; + }, [loraModels, models]); + + const currentModel = useMemo(() => { + if (!selected) return undefined; + const found = optionById.get(selected); + if (activeGgufVariant) { + const desc = `GGUF · ${activeGgufVariant}`; + return found ? { ...found, description: desc } : { id: selected, name: selected, description: desc }; + } + return found ?? { id: selected, name: selected }; + }, [selected, optionById, activeGgufVariant]); + + function handleSelect(id: string, meta: ModelSelectorChangeMeta) { + if (onValueChange) { + onValueChange(id, meta); + } else { + setUncontrolled(id); + } + setOpen(false); + } + + function handleEject() { + onEject?.(); + setOpen(false); + } + + return ( + + + + + ); +} + +ModelSelector.Trigger = ModelSelectorTrigger; +ModelSelector.Content = ModelSelectorContent; diff --git a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx new file mode 100644 index 0000000000..1ce7f40cca --- /dev/null +++ b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx @@ -0,0 +1,592 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { Input } from "@/components/ui/input"; +import { Spinner } from "@/components/ui/spinner"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { listGgufVariants } from "@/features/chat/api/chat-api"; +import type { GgufVariantDetail } from "@/features/chat/types/api"; +import { + useDebouncedValue, + useGpuInfo, + useHfModelSearch, + useInfiniteScroll, + useRecommendedModelVram, +} from "@/hooks"; +import { cn, formatCompact } from "@/lib/utils"; +import type { VramFitStatus } from "@/lib/vram"; +import { checkVramFit, estimateLoadingVram } from "@/lib/vram"; +import { Search01Icon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; +import type { + LoraModelOption, + ModelOption, + ModelSelectorChangeMeta, +} from "./types"; + +function dedupe(values: string[]): string[] { + return [...new Set(values.filter(Boolean))]; +} + +function ListLabel({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + +/** Format bytes to a human-readable size string. */ +function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + const value = bytes / 1024 ** i; + return `${value.toFixed(value < 10 ? 1 : 0)} ${units[i]}`; +} + +function ModelRow({ + label, + meta, + selected, + onClick, + vramStatus, + vramEst, + gpuGb, + tooltipText, +}: { + label: string; + meta?: string; + selected?: boolean; + onClick: () => void; + vramStatus?: VramFitStatus | null; + vramEst?: number; + gpuGb?: number; + tooltipText?: ReactNode; +}) { + const exceeds = vramStatus === "exceeds"; + const showVramTooltip = + vramEst != null && vramEst > 0 && gpuGb != null && gpuGb > 0; + const vramTooltipText = + showVramTooltip && vramStatus + ? exceeds + ? `Needs ~${vramEst}GB VRAM (GPU: ${gpuGb}GB)` + : vramStatus === "tight" + ? `~${vramEst}GB VRAM (tight fit on ${gpuGb}GB)` + : `~${vramEst}GB VRAM` + : null; + + const content = ( + + ); + + if (vramTooltipText) { + return ( + + {content} + + {label} + {vramTooltipText} + + + ); + } + + if (tooltipText) { + return ( + + {content} + + {tooltipText} + + + ); + } + return content; +} + +// ── GGUF Variant Expander ──────────────────────────────────── + +function GgufVariantExpander({ + repoId, + onSelect, + gpuGb, +}: { + repoId: string; + onSelect: (id: string, meta: ModelSelectorChangeMeta) => void; + gpuGb?: number; +}) { + const [variants, setVariants] = useState(null); + const [defaultVariant, setDefaultVariant] = useState(null); + const [hasVision, setHasVision] = useState(false); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let canceled = false; + setLoading(true); + setError(null); + + listGgufVariants(repoId) + .then((res) => { + if (canceled) return; + setVariants(res.variants); + setDefaultVariant(res.default_variant); + setHasVision(res.has_vision); + }) + .catch((err) => { + if (canceled) return; + setError(err instanceof Error ? err.message : "Failed to load variants"); + }) + .finally(() => { + if (!canceled) setLoading(false); + }); + + return () => { + canceled = true; + }; + }, [repoId]); + + const handleVariantClick = useCallback( + (quant: string) => { + onSelect(repoId, { + source: "hub", + isLora: false, + ggufVariant: quant, + }); + }, + [repoId, onSelect], + ); + + if (loading) { + return ( +
+ + Loading variants… +
+ ); + } + + if (error) { + return ( +
{error}
+ ); + } + + if (!variants || variants.length === 0) { + return ( +
+ No GGUF variants found. +
+ ); + } + + return ( +
+
+ + Quantizations + + {hasVision && ( + Vision + )} +
+ {variants.map((v) => { + const sizeGb = v.size_bytes / (1024 ** 3); + const fitStatus = gpuGb != null && gpuGb > 0 && sizeGb > 0 + ? checkVramFit(sizeGb, gpuGb) + : null; + return ( + + ); + })} +
+ ); +} + +// ── Detect GGUF repos by naming convention ──────────────────── + +function isGgufRepo(id: string): boolean { + return id.toUpperCase().includes("-GGUF"); +} + +// ── Hub Model Picker ────────────────────────────────────────── + +export function HubModelPicker({ + models, + value, + onSelect, +}: { + models: ModelOption[]; + value?: string; + onSelect: (id: string, meta: ModelSelectorChangeMeta) => void; +}) { + const gpu = useGpuInfo(); + const [query, setQuery] = useState(""); + const debouncedQuery = useDebouncedValue(query); + const { results, isLoading, isLoadingMore, fetchMore } = useHfModelSearch( + debouncedQuery, + ); + + // Track which GGUF repo is expanded for variant selection + const [expandedGguf, setExpandedGguf] = useState(null); + + const recommendedIds = useMemo( + () => dedupe([...models.map((model) => model.id), value ?? ""]), + [models, value], + ); + + const { paramCountById: recommendedParamCountById } = + useRecommendedModelVram(recommendedIds); + + const showHfSection = debouncedQuery.trim().length > 0; + const recommendedSet = useMemo(() => new Set(recommendedIds), [recommendedIds]); + + const hfIds = useMemo(() => { + if (!showHfSection) return []; + return results + .map((result) => result.id) + .filter((id) => !recommendedSet.has(id)); + }, [recommendedSet, results, showHfSection]); + + const metricsById = useMemo( + () => + new Map( + results + .filter((result) => result.totalParams) + .map((result) => [result.id, formatCompact(result.totalParams!)]), + ), + [results], + ); + + const vramMap = useMemo(() => { + const map = new Map< + string, + { est: number; status: VramFitStatus | null; detail: string | null } + >(); + for (const r of results) { + const detail = r.totalParams ? formatCompact(r.totalParams) : null; + if (r.totalParams) { + const est = estimateLoadingVram(r.totalParams, "qlora"); + const status = gpu.available + ? checkVramFit(est, gpu.memoryTotalGb) + : null; + map.set(r.id, { est, status, detail }); + } else { + map.set(r.id, { est: 0, status: null, detail }); + } + } + return map; + }, [results, gpu]); + + const recommendedVramMap = useMemo(() => { + const map = new Map< + string, + { est: number; status: VramFitStatus | null; detail: string | null } + >(); + for (const id of recommendedIds) { + const totalParams = recommendedParamCountById.get(id); + if (totalParams) { + const est = estimateLoadingVram(totalParams, "qlora"); + const status = gpu.available + ? checkVramFit(est, gpu.memoryTotalGb) + : null; + const detail = formatCompact(totalParams); + map.set(id, { est, status, detail }); + } + } + return map; + }, [recommendedIds, recommendedParamCountById, gpu]); + + const { scrollRef, sentinelRef } = useInfiniteScroll(fetchMore, results.length); + + /** Handle clicking a model row — GGUF repos expand, others load directly. */ + const handleModelClick = useCallback( + (id: string) => { + if (isGgufRepo(id)) { + // Toggle GGUF variant expander + setExpandedGguf((prev) => (prev === id ? null : id)); + } else { + onSelect(id, { source: "hub", isLora: false }); + } + }, + [onSelect], + ); + + return ( +
+
+ + setQuery(event.target.value)} + placeholder="Search Hugging Face models" + className="h-9 pl-8 pr-8" + /> + {isLoading && ( + + )} +
+ +
+
+ {!showHfSection ? ( + <> + Recommended + {recommendedIds.length === 0 ? ( +
+ No default models. +
+ ) : ( + recommendedIds.map((id) => { + const vram = recommendedVramMap.get(id); + return ( +
+ handleModelClick(id)} + vramStatus={isGgufRepo(id) ? null : vram?.status ?? null} + vramEst={isGgufRepo(id) ? undefined : vram?.est} + gpuGb={gpu.available ? gpu.memoryTotalGb : undefined} + /> + {expandedGguf === id && ( + + )} +
+ ); + }) + )} + + ) : null} + + {showHfSection ? ( + <> + Hugging Face + {hfIds.length === 0 && !isLoading ? ( +
+ No matching models. +
+ ) : ( + hfIds.map((id) => { + const vram = vramMap.get(id); + return ( +
+ handleModelClick(id)} + vramStatus={isGgufRepo(id) ? null : vram?.status ?? null} + vramEst={isGgufRepo(id) ? undefined : vram?.est} + gpuGb={gpu.available ? gpu.memoryTotalGb : undefined} + /> + {expandedGguf === id && ( + + )} +
+ ); + }) + )} +
+ {isLoadingMore ? ( +
+ +
+ ) : null} + + ) : null} +
+
+
+ ); +} + +export function LoraModelPicker({ + loraModels, + value, + onSelect, +}: { + loraModels: LoraModelOption[]; + value?: string; + onSelect: (id: string, meta: ModelSelectorChangeMeta) => void; +}) { + const [query, setQuery] = useState(""); + + const normalized = useMemo( + () => + loraModels + .map((model) => ({ + ...model, + baseModel: model.baseModel || model.description || "Unknown base model", + })) + .sort((a, b) => { + const aTime = a.updatedAt ?? -1; + const bTime = b.updatedAt ?? -1; + if (aTime !== bTime) return bTime - aTime; + const baseCmp = a.baseModel.localeCompare(b.baseModel); + if (baseCmp !== 0) return baseCmp; + return a.name.localeCompare(b.name); + }), + [loraModels], + ); + + const grouped = useMemo(() => { + const needle = query.trim().toLowerCase(); + const out = new Map(); + + for (const model of normalized) { + const searchText = `${model.name} ${model.baseModel} ${model.id}`.toLowerCase(); + if (needle && !searchText.includes(needle)) continue; + + const key = model.baseModel || "Unknown base model"; + const prev = out.get(key) ?? []; + prev.push(model); + out.set(key, prev); + } + + return [...out.entries()].sort((a, b) => { + const aLatest = Math.max(...a[1].map((model) => model.updatedAt ?? -1)); + const bLatest = Math.max(...b[1].map((model) => model.updatedAt ?? -1)); + if (aLatest !== bLatest) return bLatest - aLatest; + return a[0].localeCompare(b[0]); + }); + }, [normalized, query]); + + return ( +
+
+ + setQuery(event.target.value)} + placeholder="Search local adapters" + className="h-9 pl-8" + /> +
+ +
+
+ {grouped.length === 0 ? ( +
+ No adapters found. +
+ ) : ( + grouped.map(([baseModel, adapters], index) => ( +
+ {index > 0 ?
: null} + {baseModel} + {adapters.map((adapter) => { + const isExported = adapter.source === "exported"; + const isMerged = adapter.exportType === "merged"; + const isGguf = adapter.exportType === "gguf"; + const tag = isGguf + ? "GGUF" + : isExported + ? isMerged ? "Merged" : "LoRA" + : "LoRA"; + const meta = isExported ? `${tag} · Exported` : tag; + return ( + onSelect(adapter.id, { + source: isExported ? "exported" : "lora", + isLora: !isMerged && !isGguf, + })} + tooltipText={ + <> + {adapter.name} + + {adapter.id} + + + } + /> + ); + })} +
+ )) + )} +
+
+
+ ); +} diff --git a/studio/frontend/src/components/assistant-ui/model-selector/types.ts b/studio/frontend/src/components/assistant-ui/model-selector/types.ts new file mode 100644 index 0000000000..75efc67830 --- /dev/null +++ b/studio/frontend/src/components/assistant-ui/model-selector/types.ts @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import type { ReactNode } from "react"; + +export interface ModelOption { + id: string; + name: string; + description?: string; + icon?: ReactNode; +} + +export interface LoraModelOption extends ModelOption { + baseModel?: string; + updatedAt?: number; + source?: "training" | "exported"; + exportType?: "lora" | "merged" | "gguf"; +} + +export interface ModelSelectorChangeMeta { + source: "hub" | "lora" | "exported"; + isLora: boolean; + ggufVariant?: string; +} + diff --git a/studio/frontend/src/components/assistant-ui/reasoning.tsx b/studio/frontend/src/components/assistant-ui/reasoning.tsx new file mode 100644 index 0000000000..12d5799a16 --- /dev/null +++ b/studio/frontend/src/components/assistant-ui/reasoning.tsx @@ -0,0 +1,378 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"use client"; + +/* eslint-disable react-refresh/only-export-components */ + +import { MarkdownText } from "@/components/assistant-ui/markdown-text"; +import { AnimatedShinyText } from "@/components/ui/animated-shiny-text"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { cn } from "@/lib/utils"; +import { + type ReasoningGroupComponent, + type ReasoningMessagePartComponent, + useAuiState, + useScrollLock, +} from "@assistant-ui/react"; +import { Idea01Icon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { type VariantProps, cva } from "class-variance-authority"; +import { ChevronDownIcon } from "lucide-react"; +import { + type CSSProperties, + type ComponentProps, + memo, + useCallback, + useEffect, + useRef, + useState, +} from "react"; +const ANIMATION_DURATION = 200; + +export const reasoningVariants = cva("aui-reasoning-root mb-4 w-full", { + variants: { + variant: { + outline: "rounded-lg border px-3 py-2", + ghost: "", + muted: "rounded-lg bg-muted/50 px-3 py-2", + }, + }, + defaultVariants: { + variant: "outline", + }, +}); + +export type ReasoningRootProps = Omit< + ComponentProps, + "open" | "onOpenChange" +> & + VariantProps & { + open?: boolean; + onOpenChange?: (open: boolean) => void; + defaultOpen?: boolean; + }; + +function ReasoningRoot({ + className, + variant, + open: controlledOpen, + onOpenChange: controlledOnOpenChange, + defaultOpen = false, + children, + ...props +}: ReasoningRootProps) { + const collapsibleRef = useRef(null); + const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen); + const lockScroll = useScrollLock(collapsibleRef, ANIMATION_DURATION); + + const isControlled = controlledOpen !== undefined; + const isOpen = isControlled ? controlledOpen : uncontrolledOpen; + + const handleOpenChange = useCallback( + (open: boolean) => { + if (!open) { + lockScroll(); + } + if (!isControlled) { + setUncontrolledOpen(open); + } + controlledOnOpenChange?.(open); + }, + [lockScroll, isControlled, controlledOnOpenChange], + ); + + return ( + + {children} + + ); +} + +function ReasoningFade({ className, ...props }: ComponentProps<"div">) { + return ( +
+ ); +} + +function ReasoningFadeTop({ className, ...props }: ComponentProps<"div">) { + return ( +
+ ); +} + +function ReasoningTrigger({ + active, + duration, + className, + ...props +}: ComponentProps & { + active?: boolean; + duration?: number; +}) { + return ( + + + + {active ? ( + Thinking... + ) : ( + Thought for {duration ?? 0} seconds + )} + + + + ); +} + +function ReasoningContent({ + className, + children, + streaming, + ...props +}: ComponentProps & { streaming?: boolean }) { + return ( + + {streaming && } + {children} + + + ); +} + +function ReasoningText({ + className, + streaming, + children, + ...props +}: ComponentProps<"div"> & { streaming?: boolean }) { + const scrollRef = useRef(null); + + useEffect(() => { + if (!(streaming && scrollRef.current)) { + return; + } + const el = scrollRef.current; + const observer = new MutationObserver(() => { + el.scrollTop = el.scrollHeight; + }); + observer.observe(el, { + childList: true, + subtree: true, + characterData: true, + }); + el.scrollTop = el.scrollHeight; + return () => observer.disconnect(); + }, [streaming]); + + return ( +
+ {children} +
+ ); +} + +const ReasoningImpl: ReasoningMessagePartComponent = () => ; + +const ReasoningGroupImpl: ReasoningGroupComponent = ({ + children, + startIndex, + endIndex, +}) => { + const isReasoningStreaming = useAuiState(({ message }) => { + if (message.status?.type !== "running") { + return false; + } + const lastIndex = message.parts.length - 1; + if (lastIndex < 0) { + return false; + } + const lastType = message.parts[lastIndex]?.type; + if (lastType !== "reasoning") { + return false; + } + return lastIndex >= startIndex && lastIndex <= endIndex; + }); + + const persistedDuration = useAuiState(({ message }) => { + const d = (message.metadata?.custom as Record) + ?.reasoningDuration; + return typeof d === "number" ? d : 0; + }); + + const [manualOpen, setManualOpen] = useState(false); + const [duration, setDuration] = useState(0); + const startTimeRef = useRef(null); + + useEffect(() => { + if (isReasoningStreaming) { + if (startTimeRef.current === null) { + startTimeRef.current = Date.now(); + } + } else if (startTimeRef.current !== null) { + const elapsed = Math.round((Date.now() - startTimeRef.current) / 1000); + setDuration(elapsed); + startTimeRef.current = null; + } + }, [isReasoningStreaming]); + + const isOpen = isReasoningStreaming || manualOpen; + + const variant = isReasoningStreaming + ? "outline" + : manualOpen + ? "outline" + : "ghost"; + + const handleOpenChange = useCallback( + (open: boolean) => { + if (!isReasoningStreaming) { + setManualOpen(open); + } + }, + [isReasoningStreaming], + ); + + return ( + + + + + {children} + + + + ); +}; + +const Reasoning = memo( + ReasoningImpl, +) as unknown as ReasoningMessagePartComponent & { + Root: typeof ReasoningRoot; + Trigger: typeof ReasoningTrigger; + Content: typeof ReasoningContent; + Text: typeof ReasoningText; + Fade: typeof ReasoningFade; + FadeTop: typeof ReasoningFadeTop; +}; + +Reasoning.displayName = "Reasoning"; +Reasoning.Root = ReasoningRoot; +Reasoning.Trigger = ReasoningTrigger; +Reasoning.Content = ReasoningContent; +Reasoning.Text = ReasoningText; +Reasoning.Fade = ReasoningFade; +Reasoning.FadeTop = ReasoningFadeTop; + +const ReasoningGroup = memo(ReasoningGroupImpl); +ReasoningGroup.displayName = "ReasoningGroup"; + +export { + Reasoning, + ReasoningGroup, + ReasoningRoot, + ReasoningTrigger, + ReasoningContent, + ReasoningText, + ReasoningFade, + ReasoningFadeTop, +}; diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx new file mode 100644 index 0000000000..890267936b --- /dev/null +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -0,0 +1,562 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { + ComposerAddAttachment, + ComposerAttachments, + UserMessageAttachments, +} from "@/components/assistant-ui/attachment"; +import { MarkdownText } from "@/components/assistant-ui/markdown-text"; +import { Reasoning, ReasoningGroup } from "@/components/assistant-ui/reasoning"; +import { ToolFallback } from "@/components/assistant-ui/tool-fallback"; +import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button"; +import { Button } from "@/components/ui/button"; +import { sentAudioNames } from "@/features/chat/api/chat-adapter"; +import { AUDIO_ACCEPT, MAX_AUDIO_SIZE, fileToBase64 } from "@/lib/audio-utils"; +import { copyToClipboard } from "@/lib/copy-to-clipboard"; +import { cn } from "@/lib/utils"; +import { + ActionBarMorePrimitive, + ActionBarPrimitive, + AuiIf, + BranchPickerPrimitive, + ComposerPrimitive, + ErrorPrimitive, + MessagePrimitive, + SuggestionPrimitive, + ThreadPrimitive, + useAui, + useAuiEvent, + useAuiState, +} from "@assistant-ui/react"; +import { motion } from "framer-motion"; +import { + ArrowDownIcon, + ArrowUpIcon, + CheckIcon, + ChevronLeftIcon, + ChevronRightIcon, + CopyIcon, + DownloadIcon, + HeadphonesIcon, + MicIcon, + MoreHorizontalIcon, + PencilIcon, + RefreshCwIcon, + SquareIcon, + XIcon, +} from "lucide-react"; +import { type FC, useCallback, useRef, useState } from "react"; +import { useChatRuntimeStore } from "@/features/chat/stores/chat-runtime-store"; + +export const Thread: FC<{ hideComposer?: boolean; hideWelcome?: boolean }> = ({ + hideComposer, + hideWelcome, +}) => { + return ( + + + {!hideWelcome && ( + thread.isEmpty}> + + + )} + + + + + + !thread.isEmpty}> + {!hideComposer && } + + + + + ); +}; + +const ThreadScrollToBottom: FC = () => { + return ( + + + + + + ); +}; + +const SuggestionItem: FC = () => { + const aui = useAui(); + const prompt = useAuiState(({ suggestion }) => suggestion.prompt); + const isDisabled = useAuiState(({ thread }) => thread.isDisabled); + const isRunning = useAuiState(({ thread }) => thread.isRunning); + + return ( + + ); +}; + +const ThreadWelcome: FC<{ hideComposer?: boolean }> = ({ hideComposer }) => { + return ( +
+
+
+
+ Sloth mascot +

+ Test Your Fine-tuned Model +

+

+ Start a conversation to see how your model performs. +

+
+
+ +
+ {!hideComposer && } +
+
+
+ ); +}; + +const ComposerAnimated: FC = () => { + return ( + + + + ); +}; + +const PendingAudioChip: FC = () => { + const audioName = useChatRuntimeStore((s) => s.pendingAudioName); + const clearPendingAudio = useChatRuntimeStore((s) => s.clearPendingAudio); + if (!audioName) return null; + return ( +
+
+ + {audioName} + +
+
+ ); +}; + +const Composer: FC = () => { + return ( + + + + + + + + + ); +}; + +const ComposerAudioUpload: FC = () => { + const audioInputRef = useRef(null); + const setPendingAudio = useChatRuntimeStore((s) => s.setPendingAudio); + const activeModel = useChatRuntimeStore((s) => { + const checkpoint = s.params.checkpoint; + return s.models.find((m) => m.id === checkpoint); + }); + + const handleAudioFile = useCallback( + async (file: File) => { + if (file.size > MAX_AUDIO_SIZE) return; + try { + const base64 = await fileToBase64(file); + setPendingAudio(base64, file.name); + } catch { + // skip + } + }, + [setPendingAudio], + ); + + if (!activeModel?.hasAudioInput) return null; + + return ( + <> + { + const file = e.target.files?.[0]; + if (file) handleAudioFile(file); + e.target.value = ""; + }} + /> + audioInputRef.current?.click()} + aria-label="Upload audio" + > + + + + ); +}; + +const ComposerAction: FC = () => { + return ( +
+
+ + +
+
+ + + + + + + + + + + + + + + !thread.isRunning}> + + + + + + + thread.isRunning}> + + + + +
+
+ ); +}; + +const MessageError: FC = () => { + return ( + + + + + + ); +}; + +const AssistantMessage: FC = () => { + return ( + +
+ + +
+ +
+ + +
+
+ ); +}; + +const COPY_RESET_MS = 2000; + +const CopyButton: FC = () => { + const aui = useAui(); + const [copied, setCopied] = useState(false); + const resetTimeoutRef = useRef | null>(null); + + const handleCopy = () => { + const text = aui.message().getCopyText(); + if (copyToClipboard(text)) { + setCopied(true); + if (resetTimeoutRef.current) clearTimeout(resetTimeoutRef.current); + resetTimeoutRef.current = setTimeout(() => { + setCopied(false); + resetTimeoutRef.current = null; + }, COPY_RESET_MS); + } + }; + + return ( + + {copied ? : } + + ); +}; + +const AssistantActionBar: FC = () => { + return ( + + + + + + + + + + + + + + + + + + Export as Markdown + + + + + + ); +}; + +const UserMessageAudio: FC = () => { + const audioName = useAuiState(({ message }) => sentAudioNames.get(message.id)); + if (!audioName) return null; + return ( +
+
+ + {audioName} +
+
+ ); +}; + +const UserMessage: FC = () => { + return ( + + + + +
+
+ +
+
+ +
+
+ + +
+ ); +}; + +const UserActionBar: FC = () => { + return ( + + + + + + + + + ); +}; + +const EditComposer: FC = () => { + const aui = useAui(); + const resendAfterCancelRef = useRef(false); + + useAuiEvent("thread.runEnd", () => { + if (!resendAfterCancelRef.current) { + return; + } + resendAfterCancelRef.current = false; + aui.composer().send(); + }); + + return ( + + + +
+ + + + +
+
+
+ ); +}; + +const BranchPicker: FC = ({ + className, + ...rest +}) => { + return ( + + + + + + + + / + + + + + + + + ); +}; diff --git a/studio/frontend/src/components/assistant-ui/tool-fallback.tsx b/studio/frontend/src/components/assistant-ui/tool-fallback.tsx new file mode 100644 index 0000000000..f232e5a1e5 --- /dev/null +++ b/studio/frontend/src/components/assistant-ui/tool-fallback.tsx @@ -0,0 +1,343 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"use client"; + +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { cn } from "@/lib/utils"; +import { + type ToolCallMessagePartComponent, + type ToolCallMessagePartStatus, + useScrollLock, +} from "@assistant-ui/react"; +import { + AlertCircleIcon, + CheckIcon, + ChevronDownIcon, + LoaderIcon, + XCircleIcon, +} from "lucide-react"; +import { + type CSSProperties, + type ComponentProps, + type ElementType, + memo, + useCallback, + useRef, + useState, +} from "react"; + +const ANIMATION_DURATION = 200; + +export type ToolFallbackRootProps = Omit< + ComponentProps, + "open" | "onOpenChange" +> & { + open?: boolean; + onOpenChange?: (open: boolean) => void; + defaultOpen?: boolean; +}; + +function ToolFallbackRoot({ + className, + open: controlledOpen, + onOpenChange: controlledOnOpenChange, + defaultOpen = false, + children, + ...props +}: ToolFallbackRootProps) { + const collapsibleRef = useRef(null); + const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen); + const lockScroll = useScrollLock(collapsibleRef, ANIMATION_DURATION); + + const isControlled = controlledOpen !== undefined; + const isOpen = isControlled ? controlledOpen : uncontrolledOpen; + + const handleOpenChange = useCallback( + (open: boolean) => { + if (!open) { + lockScroll(); + } + if (!isControlled) { + setUncontrolledOpen(open); + } + controlledOnOpenChange?.(open); + }, + [lockScroll, isControlled, controlledOnOpenChange], + ); + + return ( + + {children} + + ); +} + +type ToolStatus = ToolCallMessagePartStatus["type"]; + +const statusIconMap: Record = { + running: LoaderIcon, + complete: CheckIcon, + incomplete: XCircleIcon, + "requires-action": AlertCircleIcon, +}; + +function ToolFallbackTrigger({ + toolName, + status, + className, + ...props +}: ComponentProps & { + toolName: string; + status?: ToolCallMessagePartStatus; +}) { + const statusType = status?.type ?? "complete"; + const isRunning = statusType === "running"; + const isCancelled = + status?.type === "incomplete" && status.reason === "cancelled"; + + const Icon = statusIconMap[statusType]; + const label = isCancelled ? "Cancelled tool" : "Used tool"; + + return ( + + + + + {label}: {toolName} + + {isRunning && ( + + {label}: {toolName} + + )} + + + + ); +} + +function ToolFallbackContent({ + className, + children, + ...props +}: ComponentProps) { + return ( + +
{children}
+
+ ); +} + +function ToolFallbackArgs({ + argsText, + className, + ...props +}: ComponentProps<"div"> & { + argsText?: string; +}) { + if (!argsText) { + return null; + } + + return ( +
+
+        {argsText}
+      
+
+ ); +} + +function ToolFallbackResult({ + result, + className, + ...props +}: ComponentProps<"div"> & { + result?: unknown; +}) { + if (result === undefined) { + return null; + } + + return ( +
+

Result:

+
+        {typeof result === "string" ? result : JSON.stringify(result, null, 2)}
+      
+
+ ); +} + +function ToolFallbackError({ + status, + className, + ...props +}: ComponentProps<"div"> & { + status?: ToolCallMessagePartStatus; +}) { + if (status?.type !== "incomplete") { + return null; + } + + const error = status.error; + const errorText = error + ? typeof error === "string" + ? error + : JSON.stringify(error) + : null; + + if (!errorText) { + return null; + } + + const isCancelled = status.reason === "cancelled"; + const headerText = isCancelled ? "Cancelled reason:" : "Error:"; + + return ( +
+

+ {headerText} +

+

+ {errorText} +

+
+ ); +} + +const ToolFallbackImpl: ToolCallMessagePartComponent = ({ + toolName, + argsText, + result, + status, +}) => { + const isCancelled = + status?.type === "incomplete" && status.reason === "cancelled"; + + return ( + + + + + + {!isCancelled && } + + + ); +}; + +const ToolFallback = memo( + ToolFallbackImpl, +) as unknown as ToolCallMessagePartComponent & { + Root: typeof ToolFallbackRoot; + Trigger: typeof ToolFallbackTrigger; + Content: typeof ToolFallbackContent; + Args: typeof ToolFallbackArgs; + Result: typeof ToolFallbackResult; + Error: typeof ToolFallbackError; +}; + +ToolFallback.displayName = "ToolFallback"; +ToolFallback.Root = ToolFallbackRoot; +ToolFallback.Trigger = ToolFallbackTrigger; +ToolFallback.Content = ToolFallbackContent; +ToolFallback.Args = ToolFallbackArgs; +ToolFallback.Result = ToolFallbackResult; +ToolFallback.Error = ToolFallbackError; + +export { + ToolFallback, + ToolFallbackRoot, + ToolFallbackTrigger, + ToolFallbackContent, + ToolFallbackArgs, + ToolFallbackResult, + ToolFallbackError, +}; diff --git a/studio/frontend/src/components/assistant-ui/tooltip-icon-button.tsx b/studio/frontend/src/components/assistant-ui/tooltip-icon-button.tsx new file mode 100644 index 0000000000..e498999068 --- /dev/null +++ b/studio/frontend/src/components/assistant-ui/tooltip-icon-button.tsx @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"use client"; + +import { Slottable } from "@radix-ui/react-slot"; +import { type ComponentPropsWithRef, forwardRef } from "react"; + +import { Button } from "@/components/ui/button"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; + +export type TooltipIconButtonProps = ComponentPropsWithRef & { + tooltip: string; + side?: "top" | "bottom" | "left" | "right"; +}; + +export const TooltipIconButton = forwardRef< + HTMLButtonElement, + TooltipIconButtonProps +>(({ children, tooltip, side = "bottom", className, ...rest }, ref) => { + return ( + + + + + {tooltip} + + ); +}); + +TooltipIconButton.displayName = "TooltipIconButton"; diff --git a/studio/frontend/src/components/example.tsx b/studio/frontend/src/components/example.tsx new file mode 100644 index 0000000000..57bd18e1c6 --- /dev/null +++ b/studio/frontend/src/components/example.tsx @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { cn } from "@/lib/utils"; +import type { ComponentProps } from "react"; + +function ExampleWrapper({ className, ...props }: ComponentProps<"div">) { + return ( +
+
+
+ ); +} + +function Example({ + title, + children, + className, + containerClassName, + ...props +}: ComponentProps<"div"> & { + title?: string; + containerClassName?: string; +}) { + return ( +
+ {title && ( +
+ {title} +
+ )} +
+ {children} +
+
+ ); +} + +export { ExampleWrapper, Example }; diff --git a/studio/frontend/src/components/layout/dashboard-grid.tsx b/studio/frontend/src/components/layout/dashboard-grid.tsx new file mode 100644 index 0000000000..1125daa29e --- /dev/null +++ b/studio/frontend/src/components/layout/dashboard-grid.tsx @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +const colsVariants = { + 3: "lg:grid-cols-3", + 4: "lg:grid-cols-4", +} as const; + +function DashboardGrid({ + className, + cols = 3, + ...props +}: React.ComponentProps<"div"> & { cols?: 3 | 4 }) { + return ( +
+ ); +} + +export { DashboardGrid }; diff --git a/studio/frontend/src/components/layout/dashboard-layout.tsx b/studio/frontend/src/components/layout/dashboard-layout.tsx new file mode 100644 index 0000000000..263d49bff9 --- /dev/null +++ b/studio/frontend/src/components/layout/dashboard-layout.tsx @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function DashboardLayout({ + className, + children, + ...props +}: React.ComponentProps<"div">) { + return ( +
+
{children}
+
+ ); +} + +export { DashboardLayout }; diff --git a/studio/frontend/src/components/layout/index.ts b/studio/frontend/src/components/layout/index.ts new file mode 100644 index 0000000000..37f0c3999c --- /dev/null +++ b/studio/frontend/src/components/layout/index.ts @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +export { DashboardLayout } from "./dashboard-layout"; +export { DashboardGrid } from "./dashboard-grid"; diff --git a/studio/frontend/src/components/markdown/markdown-preview.tsx b/studio/frontend/src/components/markdown/markdown-preview.tsx new file mode 100644 index 0000000000..e0f1f96669 --- /dev/null +++ b/studio/frontend/src/components/markdown/markdown-preview.tsx @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { cn } from "@/lib/utils"; +import { code } from "@streamdown/code"; +import { math } from "@streamdown/math"; +import { mermaid } from "@streamdown/mermaid"; +import { memo, type ReactElement } from "react"; +import { Streamdown } from "streamdown"; +import "katex/dist/katex.min.css"; + +const MARKDOWN_PLUGINS = { code, math, mermaid } as const; + +type MarkdownPreviewProps = { + markdown: string; + className?: string; + plain?: boolean; +}; + +function MarkdownPreviewImpl({ + markdown, + className, + plain = false, +}: MarkdownPreviewProps): ReactElement { + const markdownClassName = + "w-full max-w-none min-w-0 space-y-2 [overflow-wrap:anywhere] [&_*]:max-w-none [&_p]:w-full [&_ul]:w-full [&_ol]:w-full [&_li]:w-full [&_h1]:w-full [&_h2]:w-full [&_h3]:w-full [&_h4]:w-full [&_h5]:w-full [&_h6]:w-full [&_pre]:w-full [&_table]:w-full [&_p]:break-words [&_li]:break-words [&_code]:break-words [&_pre]:whitespace-pre-wrap [&_pre]:break-words"; + + return ( +
+ + {markdown.trim() ? markdown : "_Empty note_"} + +
+ ); +} + +export const MarkdownPreview = memo(MarkdownPreviewImpl); diff --git a/studio/frontend/src/components/markdown/mermaid-error.tsx b/studio/frontend/src/components/markdown/mermaid-error.tsx new file mode 100644 index 0000000000..0841570b8e --- /dev/null +++ b/studio/frontend/src/components/markdown/mermaid-error.tsx @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import type { MermaidErrorComponentProps } from "streamdown"; + +function hasSlashComment(chart: string): boolean { + return /(^|[^:])\/\/.*/m.test(chart); +} + +export function MermaidError({ + error, + chart, + retry, +}: MermaidErrorComponentProps) { + return ( +
+

Mermaid render failed

+

{error}

+ {hasSlashComment(chart) ? ( +

Hint: Mermaid comments use `%%`, not `//`.

+ ) : null} + +
+ ); +} diff --git a/studio/frontend/src/components/navbar.tsx b/studio/frontend/src/components/navbar.tsx new file mode 100644 index 0000000000..abdfa6a233 --- /dev/null +++ b/studio/frontend/src/components/navbar.tsx @@ -0,0 +1,288 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@/components/ui/hover-card"; +import { AnimatedThemeToggler } from "@/components/ui/animated-theme-toggler"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet"; +import { cn } from "@/lib/utils"; +import { + ArrowRight01Icon, + Book03Icon, + BubbleChatIcon, + ChefHatIcon, + CursorInfo02Icon, + PackageIcon, + ZapIcon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { useTrainingRuntimeStore } from "@/features/training"; +import { Link, useRouterState } from "@tanstack/react-router"; +import { motion } from "motion/react"; +import { useState } from "react"; +import { TOUR_OPEN_EVENT } from "@/features/tour"; + +const NAV_ITEMS = [ + { label: "Studio", href: "/studio", icon: ZapIcon, enabled: true }, + { label: "Recipes", href: "/data-recipes", icon: ChefHatIcon, enabled: true }, + { label: "Export", href: "/export", icon: PackageIcon, enabled: true }, + { label: "Chat", href: "/chat", icon: BubbleChatIcon, enabled: true }, +]; + +function getTourId(pathname: string): "studio" | "chat" | "export" | null { + if (pathname === "/studio") return "studio"; + if (pathname === "/chat") return "chat"; + if (pathname === "/export") return "export"; + return null; +} + +export function Navbar() { + const pathname = useRouterState({ select: (s) => s.location.pathname }); + const isTrainingRunning = useTrainingRuntimeStore((s) => s.isTrainingRunning); + const [mobileOpen, setMobileOpen] = useState(false); + + const tourId = getTourId(pathname); + + const openTour = () => { + if (!tourId) return; + window.dispatchEvent( + new CustomEvent(TOUR_OPEN_EVENT, { detail: { id: tourId } }), + ); + }; + + return ( +
+
+ {/* Left: logo */} + + Unsloth + Unsloth + + + {/* Center: pill nav */} + + + {/* Right: docs/tour desktop */} + + + {/* Right: mobile */} +
+ {tourId ? ( + + ) : null} + + + + + + + Navigate + +
+ {NAV_ITEMS.filter((item) => item.enabled).map((item) => { + const active = pathname === item.href; + const disabledByTraining = + isTrainingRunning && item.href !== "/studio"; + if (disabledByTraining) { + return ( + + {item.label} + + ); + } + return ( + setMobileOpen(false)} + className={cn( + "rounded-md border px-3 py-2 text-sm font-medium", + active + ? "border-foreground bg-foreground text-background" + : "border-border text-foreground hover:bg-accent", + )} + > + {item.label} + + ); + })} + setMobileOpen(false)} + > + Learn more (Docs) + + {tourId ? ( + + ) : null} +
+
+
+
+
+
+ ); +} diff --git a/studio/frontend/src/components/section-card.tsx b/studio/frontend/src/components/section-card.tsx new file mode 100644 index 0000000000..cd8eb3d503 --- /dev/null +++ b/studio/frontend/src/components/section-card.tsx @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { cn } from "@/lib/utils"; +import type { ReactNode } from "react"; + +interface SectionCardProps { + icon: ReactNode; + title: string; + description: string; + accent?: "emerald" | "indigo" | "orange" | "blue"; + featured?: boolean; + className?: string; + badge?: string; + headerAction?: ReactNode; + children: ReactNode; +} + +const accentStyles = { + emerald: { + border: "ring-emerald-500/20", + iconBox: + "ring-emerald-200 bg-emerald-50 text-emerald-600 dark:ring-emerald-800 dark:bg-emerald-950 dark:text-emerald-400", + }, + indigo: { + border: "ring-indigo-500/20", + iconBox: + "ring-indigo-200 bg-indigo-50 text-indigo-600 dark:ring-indigo-800 dark:bg-indigo-950 dark:text-indigo-400", + }, + orange: { + border: "ring-orange-500/20", + iconBox: + "ring-orange-200 bg-orange-50 text-orange-600 dark:ring-orange-800 dark:bg-orange-950 dark:text-orange-400", + }, + blue: { + border: "ring-blue-500/20", + iconBox: + "ring-blue-200 bg-blue-50 text-blue-600 dark:ring-blue-800 dark:bg-blue-950 dark:text-blue-400", + }, +}; + +export function SectionCard({ + icon, + title, + description, + accent = "emerald", + featured, + className, + badge, + headerAction, + children, +}: SectionCardProps) { + const styles = accentStyles[accent]; + + return ( +
+ {featured && ( +
+ )} + {/* Header */} +
+
+ {icon} +
+
+
+

{title}

+ {badge && ( + + {badge} + + )} +
+

{description}

+
+ {headerAction &&
{headerAction}
} +
+ {/* Content */} + {children} +
+ ); +} diff --git a/studio/frontend/src/components/ui/accordion.tsx b/studio/frontend/src/components/ui/accordion.tsx new file mode 100644 index 0000000000..7754c78a11 --- /dev/null +++ b/studio/frontend/src/components/ui/accordion.tsx @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"use client"; + +import { Accordion as AccordionPrimitive } from "radix-ui"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; +import { ArrowDown01Icon, ArrowUp01Icon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; + +function Accordion({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AccordionItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AccordionTrigger({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + + ); +} + +function AccordionContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + +
+ {children} +
+
+ ); +} + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/studio/frontend/src/components/ui/alert-dialog.tsx b/studio/frontend/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000000..f5c1dbacca --- /dev/null +++ b/studio/frontend/src/components/ui/alert-dialog.tsx @@ -0,0 +1,202 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { AlertDialog as AlertDialogPrimitive } from "radix-ui"; +import type * as React from "react"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +function AlertDialog({ + ...props +}: React.ComponentProps) { + return ; +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogContent({ + className, + size = "default", + overlayClassName, + ...props +}: React.ComponentProps & { + size?: "default" | "sm"; + overlayClassName?: string; +}) { + return ( + + + + + ); +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogMedia({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AlertDialogAction({ + className, + variant = "default", + size = "default", + ...props +}: React.ComponentProps & + Pick, "variant" | "size">) { + return ( + + ); +} + +function AlertDialogCancel({ + className, + variant = "outline", + size = "default", + ...props +}: React.ComponentProps & + Pick, "variant" | "size">) { + return ( + + ); +} + +export { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogOverlay, + AlertDialogPortal, + AlertDialogTitle, + AlertDialogTrigger, +}; diff --git a/studio/frontend/src/components/ui/alert.tsx b/studio/frontend/src/components/ui/alert.tsx new file mode 100644 index 0000000000..a4a5f4c4b7 --- /dev/null +++ b/studio/frontend/src/components/ui/alert.tsx @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { type VariantProps, cva } from "class-variance-authority"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +const alertVariants = cva( + "grid gap-0.5 rounded-lg border px-4 py-3 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2.5 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4 w-full relative group/alert", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ); +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
svg]/alert:col-start-2 [&_a]:hover:text-foreground [&_a]:underline [&_a]:underline-offset-3", + className, + )} + {...props} + /> + ); +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { Alert, AlertTitle, AlertDescription, AlertAction }; diff --git a/studio/frontend/src/components/ui/animated-shiny-text.tsx b/studio/frontend/src/components/ui/animated-shiny-text.tsx new file mode 100644 index 0000000000..4c650f1003 --- /dev/null +++ b/studio/frontend/src/components/ui/animated-shiny-text.tsx @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import type { ComponentPropsWithoutRef, CSSProperties, FC } from "react" + +import { cn } from "@/lib/utils" + +export interface AnimatedShinyTextProps extends ComponentPropsWithoutRef<"span"> { + shimmerWidth?: number +} + +export const AnimatedShinyText: FC = ({ + children, + className, + shimmerWidth = 100, + ...props +}) => { + return ( + + {children} + + ) +} diff --git a/studio/frontend/src/components/ui/animated-theme-toggler.tsx b/studio/frontend/src/components/ui/animated-theme-toggler.tsx new file mode 100644 index 0000000000..24f3c68ec9 --- /dev/null +++ b/studio/frontend/src/components/ui/animated-theme-toggler.tsx @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { useCallback, useEffect, useRef, useState } from "react" +import { Moon, Sun } from "lucide-react" +import { flushSync } from "react-dom" + +import { cn } from "@/lib/utils" + +interface AnimatedThemeTogglerProps extends React.ComponentPropsWithoutRef<"button"> { + duration?: number +} + +export const AnimatedThemeToggler = ({ + className, + duration = 400, + ...props +}: AnimatedThemeTogglerProps) => { + const [isDark, setIsDark] = useState(false) + const buttonRef = useRef(null) + + useEffect(() => { + const updateTheme = () => { + setIsDark(document.documentElement.classList.contains("dark")) + } + + updateTheme() + + const observer = new MutationObserver(updateTheme) + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }) + + return () => observer.disconnect() + }, []) + + const toggleTheme = useCallback(async () => { + if (!buttonRef.current) return + + await document.startViewTransition(() => { + flushSync(() => { + const newTheme = !isDark + setIsDark(newTheme) + document.documentElement.classList.toggle("dark") + localStorage.setItem("theme", newTheme ? "dark" : "light") + }) + }).ready + + const { top, left, width, height } = + buttonRef.current.getBoundingClientRect() + const x = left + width / 2 + const y = top + height / 2 + const maxRadius = Math.hypot( + Math.max(left, window.innerWidth - left), + Math.max(top, window.innerHeight - top) + ) + + document.documentElement.animate( + { + clipPath: [ + `circle(0px at ${x}px ${y}px)`, + `circle(${maxRadius}px at ${x}px ${y}px)`, + ], + }, + { + duration, + easing: "ease-in-out", + pseudoElement: "::view-transition-new(root)", + } + ) + }, [isDark, duration]) + + return ( + + ) +} diff --git a/studio/frontend/src/components/ui/aspect-ratio.tsx b/studio/frontend/src/components/ui/aspect-ratio.tsx new file mode 100644 index 0000000000..cb605f01eb --- /dev/null +++ b/studio/frontend/src/components/ui/aspect-ratio.tsx @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { AspectRatio as AspectRatioPrimitive } from "radix-ui"; + +function AspectRatio({ + ...props +}: React.ComponentProps) { + return ; +} + +export { AspectRatio }; diff --git a/studio/frontend/src/components/ui/avatar.tsx b/studio/frontend/src/components/ui/avatar.tsx new file mode 100644 index 0000000000..2250bb849a --- /dev/null +++ b/studio/frontend/src/components/ui/avatar.tsx @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { Avatar as AvatarPrimitive } from "radix-ui"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +function Avatar({ + className, + size = "default", + ...props +}: React.ComponentProps & { + size?: "default" | "sm" | "lg"; +}) { + return ( + + ); +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) { + return ( + svg]:hidden", + "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", + "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", + className, + )} + {...props} + /> + ); +} + +function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AvatarGroupCount({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3 ring-background relative flex shrink-0 items-center justify-center ring-2", + className, + )} + {...props} + /> + ); +} + +export { + Avatar, + AvatarImage, + AvatarFallback, + AvatarGroup, + AvatarGroupCount, + AvatarBadge, +}; diff --git a/studio/frontend/src/components/ui/badge.tsx b/studio/frontend/src/components/ui/badge.tsx new file mode 100644 index 0000000000..3951ae9de0 --- /dev/null +++ b/studio/frontend/src/components/ui/badge.tsx @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +/* eslint-disable react-refresh/only-export-components */ + +import { type VariantProps, cva } from "class-variance-authority"; +import { Slot } from "radix-ui"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; + +export const badgeVariants = cva( + "h-5 gap-1 rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium transition-all has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&>svg]:size-3! inline-flex items-center justify-center w-fit whitespace-nowrap shrink-0 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive overflow-hidden group/badge", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + secondary: + "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", + destructive: + "bg-destructive/10 [a]:hover:bg-destructive/20 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 text-destructive dark:bg-destructive/20", + outline: + "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground bg-input/30", + ghost: + "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", + link: "text-primary underline-offset-4 hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +export function Badge({ + className, + variant = "default", + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { + asChild?: boolean; + }): React.ReactElement { + const Comp = asChild ? Slot.Root : "span"; + + return ( + + ); +} diff --git a/studio/frontend/src/components/ui/breadcrumb.tsx b/studio/frontend/src/components/ui/breadcrumb.tsx new file mode 100644 index 0000000000..dc026994ce --- /dev/null +++ b/studio/frontend/src/components/ui/breadcrumb.tsx @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { Slot } from "radix-ui"; +import type * as React from "react"; + +import { cn } from "@/lib/utils"; +import { + ArrowRight01Icon, + MoreHorizontalCircle01Icon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; + +function Breadcrumb({ className, ...props }: React.ComponentProps<"nav">) { + return ( +