diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index ff10a4f8e4d641220ae8ae91f7406dd7d97f835e..0000000000000000000000000000000000000000 --- a/.dockerignore +++ /dev/null @@ -1,21 +0,0 @@ -node_modules -npm-debug.log -.git -.gitignore -Dockerfile -.dockerignore -.nyc_output -coverage -.nyc_output -.coverage -.env -.env.local -.env.*.local -credentials.json -*.md -LICENSE -jest.config.js -babel.config.js -tests/ -run-tests.js -test-summary.js \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 3153972369ce383b315b8280c9de3113068eccff..88f8c56243a70b54ab04d5d7993bac5beacb929a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,21 +1,27 @@ -FROM node:20-alpine - -RUN apk add --no-cache tar git procps - +# ── Stage 1: Build Admin UI ── +FROM node:22-alpine AS frontend-builder +RUN apk add --no-cache git +RUN git clone --depth 1 https://github.com/hank9999/kiro.rs.git /app +WORKDIR /app/admin-ui +RUN npm install -g pnpm && pnpm install && pnpm build + +# ── Stage 2: Build Rust Binary ── +FROM rust:1.82-alpine AS builder +RUN apk add --no-cache musl-dev openssl-dev openssl-libs-static git +RUN git clone --depth 1 https://github.com/hank9999/kiro.rs.git /app +COPY --from=frontend-builder /app/admin-ui/dist /app/admin-ui/dist WORKDIR /app +RUN cargo build --release -COPY package*.json ./ -RUN npm install --omit=dev - -COPY . . - -# Create directories -RUN mkdir -p /app/logs /app/configs +# ── Stage 3: Runtime ── +FROM alpine:3.21 +RUN apk add --no-cache ca-certificates +WORKDIR /app +COPY --from=builder /app/target/release/kiro-rs /app/kiro-rs +COPY entrypoint.sh /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh -# HuggingFace Spaces requires port 7860 EXPOSE 7860 +VOLUME ["/app/config"] -HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ - CMD node healthcheck.js || exit 1 - -CMD ["node", "src/core/master.js", "--port", "7860"] +ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/LICENSE b/LICENSE deleted file mode 100644 index f288702d2fa16d3cdf0035b15a9fcbc552cd88e7..0000000000000000000000000000000000000000 --- a/LICENSE +++ /dev/null @@ -1,674 +0,0 @@ - GNU GENERAL PUBLIC LICENSE - Version 3, 29 June 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 General Public License is a free, copyleft license for -software and other kinds of works. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -the GNU General Public License is 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. We, the Free Software Foundation, use the -GNU General Public License for most of our software; it applies also to -any other work released this way by its authors. You can apply it to -your programs, too. - - 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. - - To protect your rights, we need to prevent others from denying you -these rights or asking you to surrender the rights. Therefore, you have -certain responsibilities if you distribute copies of the software, or if -you modify it: responsibilities to respect the freedom of others. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must pass on to the recipients the same -freedoms that you received. You must make sure that they, too, receive -or can get the source code. And you must show them these terms so they -know their rights. - - Developers that use the GNU GPL protect your rights with two steps: -(1) assert copyright on the software, and (2) offer you this License -giving you legal permission to copy, distribute and/or modify it. - - For the developers' and authors' protection, the GPL clearly explains -that there is no warranty for this free software. For both users' and -authors' sake, the GPL requires that modified versions be marked as -changed, so that their problems will not be attributed erroneously to -authors of previous versions. - - Some devices are designed to deny users access to install or run -modified versions of the software inside them, although the manufacturer -can do so. This is fundamentally incompatible with the aim of -protecting users' freedom to change the software. The systematic -pattern of such abuse occurs in the area of products for individuals to -use, which is precisely where it is most unacceptable. Therefore, we -have designed this version of the GPL to prohibit the practice for those -products. If such problems arise substantially in other domains, we -stand ready to extend this provision to those domains in future versions -of the GPL, as needed to protect the freedom of users. - - Finally, every program is threatened constantly by software patents. -States should not allow patents to restrict development and use of -software on general-purpose computers, but in those that do, we wish to -avoid the special danger that patents applied to a free program could -make it effectively proprietary. To prevent this, the GPL assures that -patents cannot be used to render the program non-free. - - 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 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. Use with the GNU Affero General Public License. - - 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 Affero 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 special requirements of the GNU Affero General Public License, -section 13, concerning interaction through a network will apply to the -combination as such. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU 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 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 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 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 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 General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If the program does terminal interaction, make it output a short -notice like this when it starts in an interactive mode: - - Copyright (C) - This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, your program's commands -might be different; for a GUI interface, you would use an "about box". - - 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 GPL, see -. - - The GNU General Public License does not permit incorporating your program -into proprietary programs. If your program is a subroutine library, you -may consider it more useful to permit linking proprietary applications with -the library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. But first, please read -. diff --git a/README.md b/README.md index b297427a5061aae89868958d177c943689f31237..e7144f83dce3033574d914da759d71bae7335edc 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,8 @@ --- -title: kiro2api -emoji: 🔌 +title: Kiro API Proxy +emoji: 🔑 colorFrom: blue -colorTo: purple +colorTo: indigo sdk: docker -pinned: false app_port: 7860 --- - -# kiro2api - -OpenAI/Anthropic-compatible API proxy for Kiro (Amazon Q Developer) powered by [aiclient-2-api](https://github.com/justlovemaki/aiclient-2-api). diff --git a/VERSION b/VERSION deleted file mode 100644 index 714d7cb790524038f357669a0a7e83701b2dd14b..0000000000000000000000000000000000000000 --- a/VERSION +++ /dev/null @@ -1 +0,0 @@ -2.11.5.2 diff --git a/configs/api-potluck-data.json.example b/configs/api-potluck-data.json.example deleted file mode 100644 index ed1dc8da87bdf3ca5acc9a98ff5db4153e66a036..0000000000000000000000000000000000000000 --- a/configs/api-potluck-data.json.example +++ /dev/null @@ -1,29 +0,0 @@ -{ - "config": { - "defaultDailyLimit": 500, - "bonusPerCredential": 300, - "bonusValidityDays": 30, - "persistInterval": 5000 - }, - "users": { - "maki_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx": { - "credentials": [ - { - "id": "cred_0000000000000_xxxxxx", - "path": "configs/kiro/example_kiro-auth-token/example_kiro-auth-token.json", - "provider": "claude-kiro-oauth", - "authMethod": "refresh-token", - "addedAt": "2026-01-01T00:00:00.000Z" - } - ], - "credentialBonuses": [ - { - "credentialId": "cred_0000000000000_xxxxxx", - "grantedAt": "2026-01-01T00:00:00.000Z", - "usedCount": 0 - } - ], - "createdAt": "2026-01-01T00:00:00.000Z" - } - } -} diff --git a/configs/api-potluck-keys.json.example b/configs/api-potluck-keys.json.example deleted file mode 100644 index 8b77965edce369f30d9b25df60ec8e0c578a8e76..0000000000000000000000000000000000000000 --- a/configs/api-potluck-keys.json.example +++ /dev/null @@ -1,16 +0,0 @@ -{ - "keys": { - "maki_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx": { - "id": "maki_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", - "name": "示例用户", - "createdAt": "2026-01-01T00:00:00.000Z", - "dailyLimit": 500, - "todayUsage": 0, - "totalUsage": 0, - "lastResetDate": "2026-01-01", - "lastUsedAt": null, - "enabled": true, - "bonusRemaining": 0 - } - } -} diff --git a/configs/config.json b/configs/config.json deleted file mode 100644 index aca7cc9044aac484f218c6fb3676c2a599e45d4c..0000000000000000000000000000000000000000 --- a/configs/config.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "REQUIRED_API_KEY": "sk-kiro2api", - "SERVER_PORT": 7860, - "HOST": "0.0.0.0", - "MODEL_PROVIDER": "claude-kiro-oauth", - "SYSTEM_PROMPT_FILE_PATH": "", - "SYSTEM_PROMPT_MODE": "overwrite", - "PROMPT_LOG_BASE_NAME": "prompt_log", - "PROMPT_LOG_MODE": "none", - "REQUEST_MAX_RETRIES": 3, - "REQUEST_BASE_DELAY": 1000, - "CRON_NEAR_MINUTES": 1, - "CRON_REFRESH_TOKEN": true, - "PROVIDER_POOLS_FILE_PATH": "configs/provider_pools.json", - "MAX_ERROR_COUNT": 3, - "providerFallbackChain": {}, - "modelFallbackMapping": {}, - "PROXY_URL": null, - "PROXY_ENABLED_PROVIDERS": [], - "LOG_ENABLED": true, - "LOG_OUTPUT_MODE": "all", - "LOG_LEVEL": "info", - "LOG_DIR": "logs", - "LOG_INCLUDE_REQUEST_ID": true, - "LOG_INCLUDE_TIMESTAMP": true, - "LOG_MAX_FILE_SIZE": 10485760, - "LOG_MAX_FILES": 5, - "TLS_SIDECAR_ENABLED": false, - "TLS_SIDECAR_PORT": 9090 -} diff --git a/configs/config.json.example b/configs/config.json.example deleted file mode 100644 index a3f590421089908df6012162dd1684a249be7335..0000000000000000000000000000000000000000 --- a/configs/config.json.example +++ /dev/null @@ -1,63 +0,0 @@ -{ - "REQUIRED_API_KEY": "123456", - "SERVER_PORT": 3000, - "HOST": "0.0.0.0", - "MODEL_PROVIDER": "gemini-cli-oauth", - "SYSTEM_PROMPT_FILE_PATH": "configs/input_system_prompt.txt", - "SYSTEM_PROMPT_MODE": "overwrite", - "PROMPT_LOG_BASE_NAME": "prompt_log", - "PROMPT_LOG_MODE": "none", - "REQUEST_MAX_RETRIES": 3, - "REQUEST_BASE_DELAY": 1000, - "CRON_NEAR_MINUTES": 1, - "CRON_REFRESH_TOKEN": false, - "PROVIDER_POOLS_FILE_PATH": "configs/provider_pools.json", - "MAX_ERROR_COUNT": 3, - "GROK_COOKIE_TOKEN": "your-sso-cookie-token", - "GROK_CF_CLEARANCE": "your-cf-clearance-cookie", - "GROK_USER_AGENT": "Mozilla/5.0 ...", - "GROK_BASE_URL": "https://grok.com", - "providerFallbackChain": { - "gemini-cli-oauth": ["gemini-antigravity"], - "gemini-antigravity": ["gemini-cli-oauth"], - "claude-kiro-oauth": ["claude-custom"], - "claude-custom": ["claude-kiro-oauth"] - }, - "modelFallbackMapping": { - "gemini-claude-opus-4-5-thinking": { - "targetProviderType": "claude-kiro-oauth", - "targetModel": "claude-opus-4-5" - }, - "gemini-claude-sonnet-4-5-thinking": { - "targetProviderType": "claude-kiro-oauth", - "targetModel": "claude-sonnet-4-5" - }, - "gemini-claude-sonnet-4-5": { - "targetProviderType": "claude-kiro-oauth", - "targetModel": "claude-sonnet-4-5" - }, - "claude-opus-4-5": { - "targetProviderType": "gemini-antigravity", - "targetModel": "gemini-claude-opus-4-5-thinking" - }, - "claude-sonnet-4-5": { - "targetProviderType": "gemini-antigravity", - "targetModel": "gemini-claude-sonnet-4-5" - } - }, - "PROXY_URL": "http://127.0.0.1:1089", - "PROXY_ENABLED_PROVIDERS": [ - "gemini-cli-oauth", - "gemini-antigravity" - ], - "LOG_ENABLED": true, - "LOG_OUTPUT_MODE": "all", - "LOG_LEVEL": "info", - "LOG_DIR": "logs", - "LOG_INCLUDE_REQUEST_ID": true, - "LOG_INCLUDE_TIMESTAMP": true, - "LOG_MAX_FILE_SIZE": 10485760, - "LOG_MAX_FILES": 10, - "TLS_SIDECAR_ENABLED": false, - "TLS_SIDECAR_PORT": 9090 -} diff --git a/configs/kiro/chatgpt_account1_kiro-auth-token/chatgpt_account1_kiro-auth-token.json b/configs/kiro/chatgpt_account1_kiro-auth-token/chatgpt_account1_kiro-auth-token.json deleted file mode 100644 index 67df17cee899521be1d03eaebfdf2b46c8827b2b..0000000000000000000000000000000000000000 --- a/configs/kiro/chatgpt_account1_kiro-auth-token/chatgpt_account1_kiro-auth-token.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "accessToken": "aoaAAAAAGm84HYPHOFvOn_ATRhHeZMUuLcDZDbu_-hQrPp473ye8B5xlec8-gRa79ZftJpCSeNkJALBNwDuVc7wAkBkc0:MGUCMQDjWbPSbPmIX4c+42/YduE1MW3xBKwG4r32W2DCk8BYCBwvD638JC+R3VG4KYdWAE0CMGHsWX+omV0sLGLmX/jmmXOG1rzzXy81I///G7jJE2iYy4rPYXFwM/4Tujjufozm8A", - "refreshToken": "aorAAAAAGozeWAnmIAixMWulOIdkLKdTGAv5fe87WhdVZEfvvM7pWbuZLdqc2SaUFCrxxRm6MTp9Vm40nBH1nfrgwBkc0:MGQCMFN2i+2A0Ol6PaQ+7poIrguBIFxilhiO73eD/iMpgNADCXzc6eLmLurejfvf6yZ8GQIwY7EnLvAmpIlmR7FW2uhxC/6vLQoHHYnRJkx23sC8aqA+X03j24cDmXyxwlR5azIk", - "clientId": "gabI3snTPwTQZGF1TzuWc3VzLWVhc3QtMQ", - "clientSecret": "eyJraWQiOiJrZXktMTU2NDAyODA5OSIsImFsZyI6IkhTMzg0In0.eyJzZXJpYWxpemVkIjoie1wiY2xpZW50SWRcIjp7XCJ2YWx1ZVwiOlwiZ2FiSTNzblRQd1RRWkdGMVR6dVdjM1Z6TFdWaGMzUXRNUVwifSxcImlkZW1wb3RlbnRLZXlcIjpudWxsLFwidGVuYW50SWRcIjpudWxsLFwiY2xpZW50TmFtZVwiOlwiQW1hem9uIFEgRGV2ZWxvcGVyIGZvciBjb21tYW5kIGxpbmVcIixcImJhY2tmaWxsVmVyc2lvblwiOm51bGwsXCJjbGllbnRUeXBlXCI6XCJQVUJMSUNcIixcInRlbXBsYXRlQXJuXCI6bnVsbCxcInRlbXBsYXRlQ29udGV4dFwiOm51bGwsXCJleHBpcmF0aW9uVGltZXN0YW1wXCI6MTc4MTc1ODMxMC42NDQxMjIwNzYsXCJjcmVhdGVkVGltZXN0YW1wXCI6MTc3Mzk4MjMxMC42NDQxMjIwNzYsXCJ1cGRhdGVkVGltZXN0YW1wXCI6MTc3Mzk4MjMxMC42NDQxMjIwNzYsXCJjcmVhdGVkQnlcIjpudWxsLFwidXBkYXRlZEJ5XCI6bnVsbCxcInN0YXR1c1wiOm51bGwsXCJpbml0aWF0ZUxvZ2luVXJpXCI6bnVsbCxcImVudGl0bGVkUmVzb3VyY2VJZFwiOm51bGwsXCJlbnRpdGxlZFJlc291cmNlQ29udGFpbmVySWRcIjpudWxsLFwiZXh0ZXJuYWxJZFwiOm51bGwsXCJzb2Z0d2FyZUlkXCI6bnVsbCxcInNjb3Blc1wiOlt7XCJmdWxsU2NvcGVcIjpcImNvZGV3aGlzcGVyZXI6Y29tcGxldGlvbnNcIixcInN0YXR1c1wiOlwiSU5JVElBTFwiLFwiYXBwbGljYXRpb25Bcm5cIjpudWxsLFwiZnJpZW5kbHlJZFwiOlwiY29kZXdoaXNwZXJlclwiLFwidXNlQ2FzZUFjdGlvblwiOlwiY29tcGxldGlvbnNcIixcInNjb3BlVHlwZVwiOlwiQUNDRVNTX1NDT1BFXCIsXCJ0eXBlXCI6XCJJbW11dGFibGVBY2Nlc3NTY29wZVwifSx7XCJmdWxsU2NvcGVcIjpcImNvZGV3aGlzcGVyZXI6YW5hbHlzaXNcIixcInN0YXR1c1wiOlwiSU5JVElBTFwiLFwiYXBwbGljYXRpb25Bcm5cIjpudWxsLFwiZnJpZW5kbHlJZFwiOlwiY29kZXdoaXNwZXJlclwiLFwidXNlQ2FzZUFjdGlvblwiOlwiYW5hbHlzaXNcIixcInNjb3BlVHlwZVwiOlwiQUNDRVNTX1NDT1BFXCIsXCJ0eXBlXCI6XCJJbW11dGFibGVBY2Nlc3NTY29wZVwifSx7XCJmdWxsU2NvcGVcIjpcImNvZGV3aGlzcGVyZXI6Y29udmVyc2F0aW9uc1wiLFwic3RhdHVzXCI6XCJJTklUSUFMXCIsXCJhcHBsaWNhdGlvbkFyblwiOm51bGwsXCJmcmllbmRseUlkXCI6XCJjb2Rld2hpc3BlcmVyXCIsXCJ1c2VDYXNlQWN0aW9uXCI6XCJjb252ZXJzYXRpb25zXCIsXCJzY29wZVR5cGVcIjpcIkFDQ0VTU19TQ09QRVwiLFwidHlwZVwiOlwiSW1tdXRhYmxlQWNjZXNzU2NvcGVcIn1dLFwiYXV0aGVudGljYXRpb25Db25maWd1cmF0aW9uXCI6bnVsbCxcInNoYWRvd0F1dGhlbnRpY2F0aW9uQ29uZmlndXJhdGlvblwiOm51bGwsXCJlbmFibGVkR3JhbnRzXCI6bnVsbCxcImVuZm9yY2VBdXRoTkNvbmZpZ3VyYXRpb25cIjpudWxsLFwib3duZXJBY2NvdW50SWRcIjpudWxsLFwic3NvSW5zdGFuY2VBY2NvdW50SWRcIjpudWxsLFwidXNlckNvbnNlbnRcIjpudWxsLFwibm9uSW50ZXJhY3RpdmVTZXNzaW9uc0VuYWJsZWRcIjpudWxsLFwiYXNzb2NpYXRlZEluc3RhbmNlQXJuXCI6bnVsbCxcImdyb3VwU2NvcGVzQnlGcmllbmRseUlkXCI6e1wiY29kZXdoaXNwZXJlclwiOlt7XCJmdWxsU2NvcGVcIjpcImNvZGV3aGlzcGVyZXI6Y29udmVyc2F0aW9uc1wiLFwic3RhdHVzXCI6XCJJTklUSUFMXCIsXCJhcHBsaWNhdGlvbkFyblwiOm51bGwsXCJmcmllbmRseUlkXCI6XCJjb2Rld2hpc3BlcmVyXCIsXCJ1c2VDYXNlQWN0aW9uXCI6XCJjb252ZXJzYXRpb25zXCIsXCJzY29wZVR5cGVcIjpcIkFDQ0VTU19TQ09QRVwiLFwidHlwZVwiOlwiSW1tdXRhYmxlQWNjZXNzU2NvcGVcIn0se1wiZnVsbFNjb3BlXCI6XCJjb2Rld2hpc3BlcmVyOmFuYWx5c2lzXCIsXCJzdGF0dXNcIjpcIklOSVRJQUxcIixcImFwcGxpY2F0aW9uQXJuXCI6bnVsbCxcImZyaWVuZGx5SWRcIjpcImNvZGV3aGlzcGVyZXJcIixcInVzZUNhc2VBY3Rpb25cIjpcImFuYWx5c2lzXCIsXCJzY29wZVR5cGVcIjpcIkFDQ0VTU19TQ09QRVwiLFwidHlwZVwiOlwiSW1tdXRhYmxlQWNjZXNzU2NvcGVcIn0se1wiZnVsbFNjb3BlXCI6XCJjb2Rld2hpc3BlcmVyOmNvbXBsZXRpb25zXCIsXCJzdGF0dXNcIjpcIklOSVRJQUxcIixcImFwcGxpY2F0aW9uQXJuXCI6bnVsbCxcImZyaWVuZGx5SWRcIjpcImNvZGV3aGlzcGVyZXJcIixcInVzZUNhc2VBY3Rpb25cIjpcImNvbXBsZXRpb25zXCIsXCJzY29wZVR5cGVcIjpcIkFDQ0VTU19TQ09QRVwiLFwidHlwZVwiOlwiSW1tdXRhYmxlQWNjZXNzU2NvcGVcIn1dfSxcInNob3VsZEdldFZhbHVlRnJvbVRlbXBsYXRlXCI6dHJ1ZSxcImhhc1JlcXVlc3RlZFNjb3Blc1wiOmZhbHNlLFwiY29udGFpbnNPbmx5U3NvU2NvcGVzXCI6ZmFsc2UsXCJzc29TY29wZXNcIjpbXSxcImlzVjFCYWNrZmlsbGVkXCI6ZmFsc2UsXCJpc1YyQmFja2ZpbGxlZFwiOmZhbHNlLFwiaXNWM0JhY2tmaWxsZWRcIjpmYWxzZSxcImlzVjRCYWNrZmlsbGVkXCI6ZmFsc2UsXCJpc0V4cGlyZWRcIjpmYWxzZSxcImlzQmFja2ZpbGxlZFwiOmZhbHNlLFwiaGFzSW5pdGlhbFNjb3Blc1wiOnRydWUsXCJhcmVBbGxTY29wZXNDb25zZW50ZWRUb1wiOmZhbHNlfSJ9.gsmsFkKFajDcDb4-c31lLTGxYQIqHKTzgZvwB4Wfta0xuDBNw0gzThEXSDZlqP6q", - "authMethod": "builder-id", - "idcRegion": "us-east-1", - "expiresAt": "2026-03-20T06:00:00Z" -} \ No newline at end of file diff --git a/configs/kiro/chatgpt_account2_kiro-auth-token/chatgpt_account2_kiro-auth-token.json b/configs/kiro/chatgpt_account2_kiro-auth-token/chatgpt_account2_kiro-auth-token.json deleted file mode 100644 index a65505f111914b5b94416211c269c90b7180a5e7..0000000000000000000000000000000000000000 --- a/configs/kiro/chatgpt_account2_kiro-auth-token/chatgpt_account2_kiro-auth-token.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "accessToken": "aoaAAAAAGm84HYLp9bxse52TpenBsslXdOVeCse0scBl5EXeX46G47SUJ7cg1Ok2VFQ8uNIg41dAzJ0NV9z7lWvmYBkc0:MGQCMC7JUwaYATAtN9fqgYsXBA7ajXJd0SuVa5jdmpHZ1b+unFh71kJdXsRs4Rs3VIQ+MwIwcCL2gGDRXPli8wGzvlgrtNti8orkDFfkqHkH7pyHJLoDu1bYq7iefaHErMY8kAlZ", - "refreshToken": "aorAAAAAGozeWIYaVvTd3pwQl7-_GyGoAvrkgXW5puoFCn1Ck_kxYwNi2Yswz3Z2NaTSkfpP5LpQDYVCu6Yw43oYwBkc0:MGUCMCIe9f1tl2C3QXQda5IdFeBpJPHcReZIr3Zt8bms2hLijzdTtVfzsKQT7a0q9Sq/JAIxAMAmLFxl6kOqqPNfbWVy5dunHn+9+K3O/OXT/lk+1LjOWT1fNkY/+thpKYHM16bVzw", - "clientId": "1LFoSWRMsTHl8MdMQKrMM3VzLWVhc3QtMQ", - "clientSecret": "eyJraWQiOiJrZXktMTU2NDAyODA5OSIsImFsZyI6IkhTMzg0In0.eyJzZXJpYWxpemVkIjoie1wiY2xpZW50SWRcIjp7XCJ2YWx1ZVwiOlwiMUxGb1NXUk1zVEhsOE1kTVFLck1NM1Z6TFdWaGMzUXRNUVwifSxcImlkZW1wb3RlbnRLZXlcIjpudWxsLFwidGVuYW50SWRcIjpudWxsLFwiY2xpZW50TmFtZVwiOlwiQW1hem9uIFEgRGV2ZWxvcGVyIGZvciBjb21tYW5kIGxpbmVcIixcImJhY2tmaWxsVmVyc2lvblwiOm51bGwsXCJjbGllbnRUeXBlXCI6XCJQVUJMSUNcIixcInRlbXBsYXRlQXJuXCI6bnVsbCxcInRlbXBsYXRlQ29udGV4dFwiOm51bGwsXCJleHBpcmF0aW9uVGltZXN0YW1wXCI6MTc4MTc1ODMxMS4zNjI2MDY5NTMsXCJjcmVhdGVkVGltZXN0YW1wXCI6MTc3Mzk4MjMxMS4zNjI2MDY5NTMsXCJ1cGRhdGVkVGltZXN0YW1wXCI6MTc3Mzk4MjMxMS4zNjI2MDY5NTMsXCJjcmVhdGVkQnlcIjpudWxsLFwidXBkYXRlZEJ5XCI6bnVsbCxcInN0YXR1c1wiOm51bGwsXCJpbml0aWF0ZUxvZ2luVXJpXCI6bnVsbCxcImVudGl0bGVkUmVzb3VyY2VJZFwiOm51bGwsXCJlbnRpdGxlZFJlc291cmNlQ29udGFpbmVySWRcIjpudWxsLFwiZXh0ZXJuYWxJZFwiOm51bGwsXCJzb2Z0d2FyZUlkXCI6bnVsbCxcInNjb3Blc1wiOlt7XCJmdWxsU2NvcGVcIjpcImNvZGV3aGlzcGVyZXI6Y29tcGxldGlvbnNcIixcInN0YXR1c1wiOlwiSU5JVElBTFwiLFwiYXBwbGljYXRpb25Bcm5cIjpudWxsLFwiZnJpZW5kbHlJZFwiOlwiY29kZXdoaXNwZXJlclwiLFwidXNlQ2FzZUFjdGlvblwiOlwiY29tcGxldGlvbnNcIixcInNjb3BlVHlwZVwiOlwiQUNDRVNTX1NDT1BFXCIsXCJ0eXBlXCI6XCJJbW11dGFibGVBY2Nlc3NTY29wZVwifSx7XCJmdWxsU2NvcGVcIjpcImNvZGV3aGlzcGVyZXI6YW5hbHlzaXNcIixcInN0YXR1c1wiOlwiSU5JVElBTFwiLFwiYXBwbGljYXRpb25Bcm5cIjpudWxsLFwiZnJpZW5kbHlJZFwiOlwiY29kZXdoaXNwZXJlclwiLFwidXNlQ2FzZUFjdGlvblwiOlwiYW5hbHlzaXNcIixcInNjb3BlVHlwZVwiOlwiQUNDRVNTX1NDT1BFXCIsXCJ0eXBlXCI6XCJJbW11dGFibGVBY2Nlc3NTY29wZVwifSx7XCJmdWxsU2NvcGVcIjpcImNvZGV3aGlzcGVyZXI6Y29udmVyc2F0aW9uc1wiLFwic3RhdHVzXCI6XCJJTklUSUFMXCIsXCJhcHBsaWNhdGlvbkFyblwiOm51bGwsXCJmcmllbmRseUlkXCI6XCJjb2Rld2hpc3BlcmVyXCIsXCJ1c2VDYXNlQWN0aW9uXCI6XCJjb252ZXJzYXRpb25zXCIsXCJzY29wZVR5cGVcIjpcIkFDQ0VTU19TQ09QRVwiLFwidHlwZVwiOlwiSW1tdXRhYmxlQWNjZXNzU2NvcGVcIn1dLFwiYXV0aGVudGljYXRpb25Db25maWd1cmF0aW9uXCI6bnVsbCxcInNoYWRvd0F1dGhlbnRpY2F0aW9uQ29uZmlndXJhdGlvblwiOm51bGwsXCJlbmFibGVkR3JhbnRzXCI6bnVsbCxcImVuZm9yY2VBdXRoTkNvbmZpZ3VyYXRpb25cIjpudWxsLFwib3duZXJBY2NvdW50SWRcIjpudWxsLFwic3NvSW5zdGFuY2VBY2NvdW50SWRcIjpudWxsLFwidXNlckNvbnNlbnRcIjpudWxsLFwibm9uSW50ZXJhY3RpdmVTZXNzaW9uc0VuYWJsZWRcIjpudWxsLFwiYXNzb2NpYXRlZEluc3RhbmNlQXJuXCI6bnVsbCxcInNob3VsZEdldFZhbHVlRnJvbVRlbXBsYXRlXCI6dHJ1ZSxcImhhc1JlcXVlc3RlZFNjb3Blc1wiOmZhbHNlLFwiY29udGFpbnNPbmx5U3NvU2NvcGVzXCI6ZmFsc2UsXCJzc29TY29wZXNcIjpbXSxcImlzVjFCYWNrZmlsbGVkXCI6ZmFsc2UsXCJpc1YyQmFja2ZpbGxlZFwiOmZhbHNlLFwiaXNWM0JhY2tmaWxsZWRcIjpmYWxzZSxcImlzVjRCYWNrZmlsbGVkXCI6ZmFsc2UsXCJncm91cFNjb3Blc0J5RnJpZW5kbHlJZFwiOntcImNvZGV3aGlzcGVyZXJcIjpbe1wiZnVsbFNjb3BlXCI6XCJjb2Rld2hpc3BlcmVyOmFuYWx5c2lzXCIsXCJzdGF0dXNcIjpcIklOSVRJQUxcIixcImFwcGxpY2F0aW9uQXJuXCI6bnVsbCxcImZyaWVuZGx5SWRcIjpcImNvZGV3aGlzcGVyZXJcIixcInVzZUNhc2VBY3Rpb25cIjpcImFuYWx5c2lzXCIsXCJzY29wZVR5cGVcIjpcIkFDQ0VTU19TQ09QRVwiLFwidHlwZVwiOlwiSW1tdXRhYmxlQWNjZXNzU2NvcGVcIn0se1wiZnVsbFNjb3BlXCI6XCJjb2Rld2hpc3BlcmVyOmNvbnZlcnNhdGlvbnNcIixcInN0YXR1c1wiOlwiSU5JVElBTFwiLFwiYXBwbGljYXRpb25Bcm5cIjpudWxsLFwiZnJpZW5kbHlJZFwiOlwiY29kZXdoaXNwZXJlclwiLFwidXNlQ2FzZUFjdGlvblwiOlwiY29udmVyc2F0aW9uc1wiLFwic2NvcGVUeXBlXCI6XCJBQ0NFU1NfU0NPUEVcIixcInR5cGVcIjpcIkltbXV0YWJsZUFjY2Vzc1Njb3BlXCJ9LHtcImZ1bGxTY29wZVwiOlwiY29kZXdoaXNwZXJlcjpjb21wbGV0aW9uc1wiLFwic3RhdHVzXCI6XCJJTklUSUFMXCIsXCJhcHBsaWNhdGlvbkFyblwiOm51bGwsXCJmcmllbmRseUlkXCI6XCJjb2Rld2hpc3BlcmVyXCIsXCJ1c2VDYXNlQWN0aW9uXCI6XCJjb21wbGV0aW9uc1wiLFwic2NvcGVUeXBlXCI6XCJBQ0NFU1NfU0NPUEVcIixcInR5cGVcIjpcIkltbXV0YWJsZUFjY2Vzc1Njb3BlXCJ9XX0sXCJpc0V4cGlyZWRcIjpmYWxzZSxcImlzQmFja2ZpbGxlZFwiOmZhbHNlLFwiaGFzSW5pdGlhbFNjb3Blc1wiOnRydWUsXCJhcmVBbGxTY29wZXNDb25zZW50ZWRUb1wiOmZhbHNlfSJ9.ZFTKp-PJaA7vKXI3UiBkqbnO-CvjX2kJw75UUIraS04b8HveyUsInBDcc40yKKf1", - "authMethod": "builder-id", - "idcRegion": "us-east-1", - "expiresAt": "2026-03-20T06:00:00Z" -} \ No newline at end of file diff --git a/configs/kiro/fresh1_kiro-auth-token/fresh1_kiro-auth-token.json b/configs/kiro/fresh1_kiro-auth-token/fresh1_kiro-auth-token.json deleted file mode 100644 index 717c0bfe95f33c1120a68baa1f08c4eb0e60bf05..0000000000000000000000000000000000000000 --- a/configs/kiro/fresh1_kiro-auth-token/fresh1_kiro-auth-token.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "accessToken": "aoaAAAAAGm8epsqQIJk5Bj1QoXwTAMGHRMumJWCVJ13MJODTLwCdljXWm-8jmbsXAghY3niBcLEuNr9xTljmhrN-oBkc0:MGUCMQCH/mlgYPdL1PfKCnU1eSRf5NEMyhPCW/2+Y8K2bFmMH2gMFAE6ftTRveCFSe+kTjQCMC/u4EdVSdTr9Bui6OsE280xUPAH7CSDCIHNTTPk2CiYbVtge/oYlRxVoNQizk1AMQ", - "refreshToken": "aorAAAAAGozE4YEfji2Ny6mvPuscUKbyPuUc2XD3Nf7SdFbQcE1Z2qoUOR-gZI8cUKubWvDXOivKqghUnyZOMXp7sBkc0:MGQCMCrPdIeyBVkVlB0rtjlIvHhlPURSovG8mcs9qmDBx1VYH5LPdcWvQd74O3XFYCGMfwIwIESOUIiPVFFAMKs+N1YgEoe13glxpAP7Ri0memy2/WdqY0cFEFNqMq/CndDx0dbJ", - "clientId": "H6JYDDvxcMALs2QKZfIKgHVzLWVhc3QtMQ", - "clientSecret": "eyJraWQiOiJrZXktMTU2NDAyODA5OSIsImFsZyI6IkhTMzg0In0.eyJzZXJpYWxpemVkIjoie1wiY2xpZW50SWRcIjp7XCJ2YWx1ZVwiOlwiSDZKWUREdnhjTUFMczJRS1pmSUtnSFZ6TFdWaGMzUXRNUVwifSxcImlkZW1wb3RlbnRLZXlcIjpudWxsLFwidGVuYW50SWRcIjpudWxsLFwiY2xpZW50TmFtZVwiOlwiQW1hem9uIFEgRGV2ZWxvcGVyIGZvciBjb21tYW5kIGxpbmVcIixcImJhY2tmaWxsVmVyc2lvblwiOm51bGwsXCJjbGllbnRUeXBlXCI6XCJQVUJMSUNcIixcInRlbXBsYXRlQXJuXCI6bnVsbCxcInRlbXBsYXRlQ29udGV4dFwiOm51bGwsXCJleHBpcmF0aW9uVGltZXN0YW1wXCI6MTc4MTczMjIzNS43OTM5MDAyNzIsXCJjcmVhdGVkVGltZXN0YW1wXCI6MTc3Mzk1NjIzNS43OTM5MDAyNzIsXCJ1cGRhdGVkVGltZXN0YW1wXCI6MTc3Mzk1NjIzNS43OTM5MDAyNzIsXCJjcmVhdGVkQnlcIjpudWxsLFwidXBkYXRlZEJ5XCI6bnVsbCxcInN0YXR1c1wiOm51bGwsXCJpbml0aWF0ZUxvZ2luVXJpXCI6bnVsbCxcImVudGl0bGVkUmVzb3VyY2VJZFwiOm51bGwsXCJlbnRpdGxlZFJlc291cmNlQ29udGFpbmVySWRcIjpudWxsLFwiZXh0ZXJuYWxJZFwiOm51bGwsXCJzb2Z0d2FyZUlkXCI6bnVsbCxcInNjb3Blc1wiOlt7XCJmdWxsU2NvcGVcIjpcImNvZGV3aGlzcGVyZXI6Y29tcGxldGlvbnNcIixcInN0YXR1c1wiOlwiSU5JVElBTFwiLFwiYXBwbGljYXRpb25Bcm5cIjpudWxsLFwidXNlQ2FzZUFjdGlvblwiOlwiY29tcGxldGlvbnNcIixcImZyaWVuZGx5SWRcIjpcImNvZGV3aGlzcGVyZXJcIixcInR5cGVcIjpcIkltbXV0YWJsZUFjY2Vzc1Njb3BlXCIsXCJzY29wZVR5cGVcIjpcIkFDQ0VTU19TQ09QRVwifSx7XCJmdWxsU2NvcGVcIjpcImNvZGV3aGlzcGVyZXI6YW5hbHlzaXNcIixcInN0YXR1c1wiOlwiSU5JVElBTFwiLFwiYXBwbGljYXRpb25Bcm5cIjpudWxsLFwidXNlQ2FzZUFjdGlvblwiOlwiYW5hbHlzaXNcIixcImZyaWVuZGx5SWRcIjpcImNvZGV3aGlzcGVyZXJcIixcInR5cGVcIjpcIkltbXV0YWJsZUFjY2Vzc1Njb3BlXCIsXCJzY29wZVR5cGVcIjpcIkFDQ0VTU19TQ09QRVwifSx7XCJmdWxsU2NvcGVcIjpcImNvZGV3aGlzcGVyZXI6Y29udmVyc2F0aW9uc1wiLFwic3RhdHVzXCI6XCJJTklUSUFMXCIsXCJhcHBsaWNhdGlvbkFyblwiOm51bGwsXCJ1c2VDYXNlQWN0aW9uXCI6XCJjb252ZXJzYXRpb25zXCIsXCJmcmllbmRseUlkXCI6XCJjb2Rld2hpc3BlcmVyXCIsXCJ0eXBlXCI6XCJJbW11dGFibGVBY2Nlc3NTY29wZVwiLFwic2NvcGVUeXBlXCI6XCJBQ0NFU1NfU0NPUEVcIn1dLFwiYXV0aGVudGljYXRpb25Db25maWd1cmF0aW9uXCI6bnVsbCxcInNoYWRvd0F1dGhlbnRpY2F0aW9uQ29uZmlndXJhdGlvblwiOm51bGwsXCJlbmFibGVkR3JhbnRzXCI6bnVsbCxcImVuZm9yY2VBdXRoTkNvbmZpZ3VyYXRpb25cIjpudWxsLFwib3duZXJBY2NvdW50SWRcIjpudWxsLFwic3NvSW5zdGFuY2VBY2NvdW50SWRcIjpudWxsLFwidXNlckNvbnNlbnRcIjpudWxsLFwibm9uSW50ZXJhY3RpdmVTZXNzaW9uc0VuYWJsZWRcIjpudWxsLFwiYXNzb2NpYXRlZEluc3RhbmNlQXJuXCI6bnVsbCxcImNvbnRhaW5zT25seVNzb1Njb3Blc1wiOmZhbHNlLFwic3NvU2NvcGVzXCI6W10sXCJpc1YxQmFja2ZpbGxlZFwiOmZhbHNlLFwiaXNWMkJhY2tmaWxsZWRcIjpmYWxzZSxcImlzVjNCYWNrZmlsbGVkXCI6ZmFsc2UsXCJpc1Y0QmFja2ZpbGxlZFwiOmZhbHNlLFwiaXNFeHBpcmVkXCI6ZmFsc2UsXCJpc0JhY2tmaWxsZWRcIjpmYWxzZSxcImhhc0luaXRpYWxTY29wZXNcIjp0cnVlLFwiYXJlQWxsU2NvcGVzQ29uc2VudGVkVG9cIjpmYWxzZSxcImdyb3VwU2NvcGVzQnlGcmllbmRseUlkXCI6e1wiY29kZXdoaXNwZXJlclwiOlt7XCJmdWxsU2NvcGVcIjpcImNvZGV3aGlzcGVyZXI6Y29udmVyc2F0aW9uc1wiLFwic3RhdHVzXCI6XCJJTklUSUFMXCIsXCJhcHBsaWNhdGlvbkFyblwiOm51bGwsXCJ1c2VDYXNlQWN0aW9uXCI6XCJjb252ZXJzYXRpb25zXCIsXCJmcmllbmRseUlkXCI6XCJjb2Rld2hpc3BlcmVyXCIsXCJ0eXBlXCI6XCJJbW11dGFibGVBY2Nlc3NTY29wZVwiLFwic2NvcGVUeXBlXCI6XCJBQ0NFU1NfU0NPUEVcIn0se1wiZnVsbFNjb3BlXCI6XCJjb2Rld2hpc3BlcmVyOmNvbXBsZXRpb25zXCIsXCJzdGF0dXNcIjpcIklOSVRJQUxcIixcImFwcGxpY2F0aW9uQXJuXCI6bnVsbCxcInVzZUNhc2VBY3Rpb25cIjpcImNvbXBsZXRpb25zXCIsXCJmcmllbmRseUlkXCI6XCJjb2Rld2hpc3BlcmVyXCIsXCJ0eXBlXCI6XCJJbW11dGFibGVBY2Nlc3NTY29wZVwiLFwic2NvcGVUeXBlXCI6XCJBQ0NFU1NfU0NPUEVcIn0se1wiZnVsbFNjb3BlXCI6XCJjb2Rld2hpc3BlcmVyOmFuYWx5c2lzXCIsXCJzdGF0dXNcIjpcIklOSVRJQUxcIixcImFwcGxpY2F0aW9uQXJuXCI6bnVsbCxcInVzZUNhc2VBY3Rpb25cIjpcImFuYWx5c2lzXCIsXCJmcmllbmRseUlkXCI6XCJjb2Rld2hpc3BlcmVyXCIsXCJ0eXBlXCI6XCJJbW11dGFibGVBY2Nlc3NTY29wZVwiLFwic2NvcGVUeXBlXCI6XCJBQ0NFU1NfU0NPUEVcIn1dfSxcInNob3VsZEdldFZhbHVlRnJvbVRlbXBsYXRlXCI6dHJ1ZSxcImhhc1JlcXVlc3RlZFNjb3Blc1wiOmZhbHNlfSJ9.57MdZkhyW6vz98g6JDcRSqh1c8z1Ta1eyH6d9YbsW3TUTrfJ6WxobBzkaP69aK4r", - "authMethod": "builder-id", - "idcRegion": "us-east-1", - "expiresAt": "2026-03-20T07:00:00Z" -} \ No newline at end of file diff --git a/configs/kiro/personal_kiro-auth-token/personal_kiro-auth-token.json b/configs/kiro/personal_kiro-auth-token/personal_kiro-auth-token.json deleted file mode 100644 index 7faf5c56c1902ea4f352fab07212d177bb5782ad..0000000000000000000000000000000000000000 --- a/configs/kiro/personal_kiro-auth-token/personal_kiro-auth-token.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "accessToken": "aoaAAAAAGm82R4v3zSqMlkmiPwSHdKAAQAbZ6yu4TywrlVXmFU7XMVtQQb3phAA-gOci2L0S62LjsBj-9NFLMeQPMBkc0:MGUCMQDT2c8RFXspAv/U52k+YkcOeYIge/+GA+axl/jxo48IfcXYRM2RnclaR816zx0v/rkCMGEoQS1GOAZAZN7pUJzu5xgYKo62NwzNNNoUEyyGcPwujYtWRQFbNKgAvlL0O7VFGg", - "refreshToken": "", - "authMethod": "social", - "region": "us-east-1", - "expiresAt": "2026-03-20T06:00:00Z" -} diff --git a/configs/plugins.json b/configs/plugins.json deleted file mode 100644 index 88b3330248bb612cc18889ba3d19a75d61615e3c..0000000000000000000000000000000000000000 --- a/configs/plugins.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "plugins": { - "api-potluck": {"enabled": false}, - "default-auth": {"enabled": true} - } -} diff --git a/configs/plugins.json.example b/configs/plugins.json.example deleted file mode 100644 index fe13d1920ec39e6e089259941b1990ae9cc84e2f..0000000000000000000000000000000000000000 --- a/configs/plugins.json.example +++ /dev/null @@ -1,12 +0,0 @@ -{ - "plugins": { - "api-potluck": { - "enabled": false, - "description": "API 大锅饭 - Key 管理和用量统计插件" - }, - "default-auth": { - "enabled": true, - "description": "默认 API Key 认证插件(内置)" - } - } -} \ No newline at end of file diff --git a/configs/provider_pools.json b/configs/provider_pools.json deleted file mode 100644 index e6c9b4255e7c64cfdbf10703903aa919fdb8bbb6..0000000000000000000000000000000000000000 --- a/configs/provider_pools.json +++ /dev/null @@ -1,43 +0,0 @@ -{ - "claude-kiro-oauth": [ - { - "customName": "Personal Account (cs.shenhao)", - "KIRO_OAUTH_CREDS_FILE_PATH": "configs/kiro/personal_kiro-auth-token/personal_kiro-auth-token.json", - "uuid": "personal-0000-4000-a000-000000000000", - "checkModelName": null, - "checkHealth": false, - "isHealthy": true, - "isDisabled": false, - "lastUsed": null, - "usageCount": 0, - "errorCount": 0, - "lastErrorTime": null - }, - { - "customName": "ChatGPT Mail Account 1 (kr63b7278504@prestige-leadership.org)", - "KIRO_OAUTH_CREDS_FILE_PATH": "configs/kiro/chatgpt_account1_kiro-auth-token/chatgpt_account1_kiro-auth-token.json", - "uuid": "chatgpt-0001-4000-a000-000000000001", - "checkModelName": null, - "checkHealth": true, - "isHealthy": true, - "isDisabled": false, - "lastUsed": null, - "usageCount": 0, - "errorCount": 0, - "lastErrorTime": null - }, - { - "customName": "ChatGPT Mail Account 2 (kr0a92d5ad15@mailsa.biz.id)", - "KIRO_OAUTH_CREDS_FILE_PATH": "configs/kiro/chatgpt_account2_kiro-auth-token/chatgpt_account2_kiro-auth-token.json", - "uuid": "chatgpt-0002-4000-a000-000000000002", - "checkModelName": null, - "checkHealth": true, - "isHealthy": true, - "isDisabled": false, - "lastUsed": null, - "usageCount": 0, - "errorCount": 0, - "lastErrorTime": null - } - ] -} \ No newline at end of file diff --git a/configs/provider_pools.json.example b/configs/provider_pools.json.example deleted file mode 100644 index 98e56433f4a223699d2e8e0f3b934ad6fb3dd1e7..0000000000000000000000000000000000000000 --- a/configs/provider_pools.json.example +++ /dev/null @@ -1,213 +0,0 @@ -{ - "openai-custom": [ - { - "customName": "OpenAI节点1", - "OPENAI_API_KEY": "sk-openai-key1", - "OPENAI_BASE_URL": "https://api.openai.com/v1", - "checkModelName": null, - "checkHealth": false, - "notSupportedModels": ["gpt-4-turbo"], - "uuid": "2f579c65-d3c5-41b1-9985-9f6e3d7bf39c", - "isHealthy": true, - "isDisabled": false, - "lastUsed": null, - "usageCount": 0, - "errorCount": 0, - "lastErrorTime": null - }, - { - "customName": "OpenAI节点2", - "OPENAI_API_KEY": "sk-openai-key2", - "OPENAI_BASE_URL": "https://api.openai.com/v1", - "checkModelName": null, - "checkHealth": false, - "notSupportedModels": ["gpt-4-turbo", "gpt-4"], - "uuid": "e284628d-302f-456d-91f3-6095386fb3b8", - "isHealthy": true, - "isDisabled": true, - "lastUsed": null, - "usageCount": 0, - "errorCount": 0, - "lastErrorTime": null - } - ], - "openaiResponses-custom": [ - { - "customName": "OpenAI Responses节点", - "OPENAI_API_KEY": "sk-openai-key", - "OPENAI_BASE_URL": "https://api.openai.com/v1", - "checkModelName": null, - "checkHealth": false, - "uuid": "e284628d-302f-456d-91f3-609538678968", - "isHealthy": true, - "isDisabled": false, - "lastUsed": null, - "usageCount": 0, - "errorCount": 0, - "lastErrorTime": null - } - ], - "gemini-cli-oauth": [ - { - "customName": "Gemini OAuth节点1", - "GEMINI_OAUTH_CREDS_FILE_PATH": "./credentials1.json", - "PROJECT_ID": "your-project-id-1", - "checkModelName": null, - "checkHealth": false, - "uuid": "ac200154-26b8-4f5f-8650-e8cc738b06e3", - "isHealthy": true, - "isDisabled": false, - "lastUsed": null, - "usageCount": 0, - "errorCount": 0, - "lastErrorTime": null - }, - { - "customName": "Gemini OAuth节点2", - "GEMINI_OAUTH_CREDS_FILE_PATH": "./credentials2.json", - "PROJECT_ID": "your-project-id-2", - "checkModelName": null, - "checkHealth": false, - "uuid": "4f8afcc2-a9bb-4b96-bb50-3b9667a71f54", - "isHealthy": true, - "isDisabled": false, - "lastUsed": null, - "usageCount": 0, - "errorCount": 0, - "lastErrorTime": null - } - ], - "claude-custom": [ - { - "customName": "Claude节点1", - "CLAUDE_API_KEY": "sk-claude-key1", - "CLAUDE_BASE_URL": "https://api.anthropic.com", - "checkModelName": null, - "checkHealth": false, - "uuid": "bb87047a-3b1d-4249-adbb-1087ecd58128", - "isHealthy": true, - "isDisabled": false, - "lastUsed": null, - "usageCount": 0, - "errorCount": 0, - "lastErrorTime": null - }, - { - "customName": "Claude节点2", - "CLAUDE_API_KEY": "sk-claude-key2", - "CLAUDE_BASE_URL": "https://api.anthropic.com", - "checkModelName": null, - "checkHealth": false, - "uuid": "7c2002c6-122a-4db0-af06-8a0ff433801a", - "isHealthy": true, - "isDisabled": false, - "lastUsed": null, - "usageCount": 0, - "errorCount": 0, - "lastErrorTime": null - } - ], - "claude-kiro-oauth": [ - { - "customName": "Kiro OAuth节点1", - "KIRO_OAUTH_CREDS_FILE_PATH": "./kiro_creds1.json", - "uuid": "2c69d0ac-b86f-43d8-9d17-0d300afc5cfd", - "checkModelName": null, - "checkHealth": false, - "isHealthy": true, - "isDisabled": false, - "lastUsed": null, - "usageCount": 0, - "errorCount": 0, - "lastErrorTime": null - }, - { - "customName": "Kiro OAuth节点2", - "KIRO_OAUTH_CREDS_FILE_PATH": "./kiro_creds2.json", - "uuid": "7482abe6-8083-4288-bb7d-d8ecb7c461e2", - "checkModelName": null, - "checkHealth": false, - "isHealthy": true, - "isDisabled": false, - "lastUsed": null, - "usageCount": 0, - "errorCount": 0, - "lastErrorTime": null - } - ], - "openai-qwen-oauth": [ - { - "customName": "Qwen OAuth节点", - "QWEN_OAUTH_CREDS_FILE_PATH": "./qwen_creds.json", - "uuid": "658a2114-c4c9-d713-b8d4-ceabf0e0bf18", - "checkModelName": null, - "checkHealth": false, - "isHealthy": true, - "isDisabled": false, - "lastUsed": null, - "usageCount": 0, - "errorCount": 0, - "lastErrorTime": null - } - ], - "gemini-antigravity": [ - { - "customName": "Antigravity节点1", - "ANTIGRAVITY_OAUTH_CREDS_FILE_PATH": "./antigravity_creds1.json", - "PROJECT_ID": "antigravity-project-1", - "uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "checkModelName": null, - "checkHealth": false, - "isHealthy": true, - "isDisabled": false, - "lastUsed": null, - "usageCount": 0, - "errorCount": 0, - "lastErrorTime": null - }, - { - "customName": "Antigravity节点2", - "ANTIGRAVITY_OAUTH_CREDS_FILE_PATH": "./antigravity_creds2.json", - "PROJECT_ID": "antigravity-project-2", - "uuid": "f0e9d8c7-b6a5-4321-fedc-ba9876543210", - "checkModelName": null, - "checkHealth": false, - "isHealthy": true, - "isDisabled": false, - "lastUsed": null, - "usageCount": 0, - "errorCount": 0, - "lastErrorTime": null - } - ], - "openai-iflow": [ - { - "customName": "iFlow Token节点1", - "IFLOW_TOKEN_FILE_PATH": "./configs/iflow/iflow_token.json", - "IFLOW_BASE_URL": "https://apis.iflow.cn/v1", - "uuid": "11223344-5566-7788-99aa-bbccddeeff00", - "checkModelName": "gpt-4o", - "checkHealth": false, - "isHealthy": true, - "isDisabled": false, - "lastUsed": null, - "usageCount": 0, - "errorCount": 0, - "lastErrorTime": null - }, - { - "customName": "iFlow Token节点2", - "IFLOW_TOKEN_FILE_PATH": "./configs/iflow/iflow_token2.json", - "IFLOW_BASE_URL": "https://apis.iflow.cn/v1", - "uuid": "aabbccdd-eeff-0011-2233-445566778899", - "checkModelName": "gpt-4o", - "checkHealth": false, - "isHealthy": true, - "isDisabled": false, - "lastUsed": null, - "usageCount": 0, - "errorCount": 0, - "lastErrorTime": null - } - ] -} \ No newline at end of file diff --git a/configs/pwd b/configs/pwd deleted file mode 100644 index 9309cd5ce06ce95365e0e8604daaa223bb67f042..0000000000000000000000000000000000000000 --- a/configs/pwd +++ /dev/null @@ -1 +0,0 @@ -kiro2024 diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000000000000000000000000000000000000..d46af0393e95ed7b0c927e0e7b9dc4bb3e54548c --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,28 @@ +#!/bin/sh +set -e + +CONFIG_DIR="/app/config" +mkdir -p "${CONFIG_DIR}" + +API_KEY="${API_KEY:-sk-kiro2api}" +ADMIN_API_KEY="${ADMIN_API_KEY:-sk-admin-kiro2api}" +PROXY="${HTTPS_PROXY:-${ALL_PROXY:-}}" + +cat > "${CONFIG_DIR}/config.json" < "${CONFIG_DIR}/credentials.json" + +echo "Config generated (port=7860, proxy=${PROXY:+set}${PROXY:-none})" +exec ./kiro-rs -c "${CONFIG_DIR}/config.json" --credentials "${CONFIG_DIR}/credentials.json" diff --git a/healthcheck.js b/healthcheck.js deleted file mode 100644 index a1d2d7ffb9de24b7b048d63ec2c8f5efd861bc37..0000000000000000000000000000000000000000 --- a/healthcheck.js +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Docker健康检查脚本 - * 用于检查API服务器是否正常运行 - */ - -import http from 'http'; - -// 从环境变量获取主机和端口,如果没有设置则使用默认值 -const HOST = process.env.HOST || 'localhost'; -const PORT = process.env.SERVER_PORT || 3000; - -// 发送HTTP请求到健康检查端点 -const options = { - hostname: HOST, - port: PORT, - path: '/health', - method: 'GET', - timeout: 2000 // 2秒超时 -}; - -const req = http.request(options, (res) => { - // 如果状态码是200,表示服务健康 - if (res.statusCode === 200) { - console.log('Health check passed'); - process.exit(0); - } else { - console.log(`Health check failed with status code: ${res.statusCode}`); - process.exit(1); - } -}); - -// 处理请求错误 -req.on('error', (e) => { - console.error(`Health check failed: ${e.message}`); - process.exit(1); -}); - -// 设置超时处理 -req.on('timeout', () => { - console.error('Health check timed out'); - req.destroy(); - process.exit(1); -}); - -// 结束请求 -req.end(); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 8e81846b3079343452d9e9b1d454b369b39e48c3..0000000000000000000000000000000000000000 --- a/package-lock.json +++ /dev/null @@ -1,6699 +0,0 @@ -{ - "name": "AIClient2API", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "dependencies": { - "@anthropic-ai/tokenizer": "^0.0.4", - "adm-zip": "^0.5.16", - "axios": "^1.10.0", - "deepmerge": "^4.3.1", - "dotenv": "^16.4.5", - "google-auth-library": "^10.1.0", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.6", - "lodash": "^4.17.21", - "multer": "^2.0.2", - "open": "^10.2.0", - "socks-proxy-agent": "^8.0.5", - "undici": "^7.12.0", - "uuid": "^11.1.0", - "ws": "^8.19.0" - }, - "devDependencies": { - "@babel/preset-env": "^7.28.0", - "@jest/globals": "^29.7.0", - "babel-jest": "^30.0.5", - "babel-plugin-transform-import-meta": "^2.3.3", - "jest": "^29.7.0", - "jest-environment-node": "^29.7.0", - "supertest": "^6.3.3" - } - }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@anthropic-ai/tokenizer": { - "version": "0.0.4", - "resolved": "https://registry.npmmirror.com/@anthropic-ai/tokenizer/-/tokenizer-0.0.4.tgz", - "integrity": "sha512-EHRKbxlxlc8W4KCBEseByJ7YwyYCmgu9OyN59H9+IYIGPoKv8tXyQXinkeGDI+cI8Tiuz9wk2jZb/kK7AyvL7g==", - "license": "Apache-2.0", - "dependencies": { - "@types/node": "^18.11.18", - "tiktoken": "^1.0.10" - } - }, - "node_modules/@anthropic-ai/tokenizer/node_modules/@types/node": { - "version": "18.19.130", - "resolved": "https://registry.npmmirror.com/@types/node/-/node-18.19.130.tgz", - "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", - "license": "MIT", - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "node_modules/@anthropic-ai/tokenizer/node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "license": "MIT" - }, - "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", - "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz", - "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.0", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.27.3", - "@babel/helpers": "^7.27.6", - "@babel/parser": "^7.28.0", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.0", - "@babel/types": "^7.28.0", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/generator": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz", - "integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.28.0", - "@babel/types": "^7.28.0", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", - "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.27.3" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.27.2", - "@babel/helper-validator-option": "^7.27.1", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.27.1.tgz", - "integrity": "sha512-QwGAmuvM17btKU5VqXfb+Giw4JcN0hjuufz3DYnpeVDvZLAObloM77bhMXiqry3Iio+Ai4phVRDwl6WU10+r5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-member-expression-to-functions": "^7.27.1", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/helper-replace-supers": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/traverse": "^7.27.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-create-regexp-features-plugin": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.27.1.tgz", - "integrity": "sha512-uVDC72XVf8UbrH5qQTc18Agb8emwjTiZrQE11Nv3CuBEZmVvTwwE9CBUEvHku06gQCAyYf8Nv6ja1IN+6LMbxQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "regexpu-core": "^6.2.0", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.5.tgz", - "integrity": "sha512-uJnGFcPsWQK8fvjgGP5LZUZZsYGIoPeRjSF5PGwrelYgq7Q15/Ft9NGFp1zglwgIv//W0uG4BevRuSJRyylZPg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-plugin-utils": "^7.27.1", - "debug": "^4.4.1", - "lodash.debounce": "^4.0.8", - "resolve": "^1.22.10" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz", - "integrity": "sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz", - "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.27.3" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", - "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-remap-async-to-generator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz", - "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-wrap-function": "^7.27.1", - "@babel/traverse": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-replace-supers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.27.1.tgz", - "integrity": "sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-member-expression-to-functions": "^7.27.1", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/traverse": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", - "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-wrap-function": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.27.1.tgz", - "integrity": "sha512-NFJK2sHUvrjo8wAU/nQTWU890/zB2jj0qBcCbZbbf+005cAsv6tMjXz31fBign6M5ov1o0Bllu+9nbqkfsjjJQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.27.1", - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.28.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.2.tgz", - "integrity": "sha512-/V9771t+EgXz62aCcyofnQhGM8DQACbRhvzKFsXKC9QM+5MadF8ZmIm0crDMaz3+o0h0zXfJnd4EhbYbxsrcFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", - "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.27.1.tgz", - "integrity": "sha512-QPG3C9cCVRQLxAVwmefEmwdTanECuUBMQZ/ym5kiw3XKCGA7qkuQLcjWWHcrD/GKbn/WmJwaezfuuAOcyKlRPA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz", - "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz", - "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz", - "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/plugin-transform-optional-chaining": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.13.0" - } - }, - "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.27.1.tgz", - "integrity": "sha512-6BpaYGDavZqkI6yT+KSPdpZFfpnd68UKXbcjI9pJ13pvHhPrCKWOOLp+ysvMeA+DxnhuPpgIaRpxRxo5A9t5jw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-proposal-private-property-in-object": { - "version": "7.21.0-placeholder-for-preset-env.2", - "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", - "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-async-generators": { - "version": "7.8.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", - "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-bigint": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", - "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-class-properties": { - "version": "7.12.13", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", - "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.12.13" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-class-static-block": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", - "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-import-assertions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.27.1.tgz", - "integrity": "sha512-UT/Jrhw57xg4ILHLFnzFpPDlMbcdEicaAtjPQpbj9wa8T4r5KVWCimHcL/460g8Ht0DMxDyjsLgiWSkVjnwPFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", - "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-import-meta": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", - "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-json-strings": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", - "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", - "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-logical-assignment-operators": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", - "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", - "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-numeric-separator": { - "version": "7.10.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", - "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.10.4" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-object-rest-spread": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", - "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-optional-catch-binding": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", - "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-optional-chaining": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", - "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-private-property-in-object": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", - "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-top-level-await": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", - "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", - "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-unicode-sets-regex": { - "version": "7.18.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", - "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.18.6", - "@babel/helper-plugin-utils": "^7.18.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-transform-arrow-functions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", - "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-async-generator-functions": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.28.0.tgz", - "integrity": "sha512-BEOdvX4+M765icNPZeidyADIvQ1m1gmunXufXxvRESy/jNNyfovIqUyE7MVgGBjWktCoJlzvFA1To2O4ymIO3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-remap-async-to-generator": "^7.27.1", - "@babel/traverse": "^7.28.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-async-to-generator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.27.1.tgz", - "integrity": "sha512-NREkZsZVJS4xmTr8qzE5y8AfIPqsdQfRuUiLRTEzb7Qii8iFWCyDKaUV2c0rCuh4ljDZ98ALHP/PetiBV2nddA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-remap-async-to-generator": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-block-scoped-functions": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz", - "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.0.tgz", - "integrity": "sha512-gKKnwjpdx5sER/wl0WN0efUBFzF/56YZO0RJrSYP4CljXnP31ByY7fol89AzomdlLNzI36AvOTmYHsnZTCkq8Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-class-properties": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.27.1.tgz", - "integrity": "sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-class-static-block": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.27.1.tgz", - "integrity": "sha512-s734HmYU78MVzZ++joYM+NkJusItbdRcbm+AGRgJCt3iA+yux0QpD9cBVdz3tKyrjVYWRl7j0mHSmv4lhV0aoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.12.0" - } - }, - "node_modules/@babel/plugin-transform-classes": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.0.tgz", - "integrity": "sha512-IjM1IoJNw72AZFlj33Cu8X0q2XK/6AaVC3jQu+cgQ5lThWD5ajnuUAml80dqRmOhmPkTH8uAwnpMu9Rvj0LTRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-globals": "^7.28.0", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-replace-supers": "^7.27.1", - "@babel/traverse": "^7.28.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-computed-properties": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.27.1.tgz", - "integrity": "sha512-lj9PGWvMTVksbWiDT2tW68zGS/cyo4AkZ/QTp0sQT0mjPopCmrSkzxeXkznjqBxzDI6TclZhOJbBmbBLjuOZUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/template": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.0.tgz", - "integrity": "sha512-v1nrSMBiKcodhsyJ4Gf+Z0U/yawmJDBOTpEB3mcQY52r9RIyPneGyAS/yM6seP/8I+mWI3elOMtT5dB8GJVs+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.28.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-dotall-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.27.1.tgz", - "integrity": "sha512-gEbkDVGRvjj7+T1ivxrfgygpT7GUd4vmODtYpbs0gZATdkX8/iSnOtZSxiZnsgm1YjTgjI6VKBGSJJevkrclzw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-duplicate-keys": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz", - "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.27.1.tgz", - "integrity": "sha512-hkGcueTEzuhB30B3eJCbCYeCaaEQOmQR0AdvzpD4LoN0GXMWzzGSuRrxR2xTnCrvNbVwK9N6/jQ92GSLfiZWoQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-transform-dynamic-import": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz", - "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-explicit-resource-management": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.0.tgz", - "integrity": "sha512-K8nhUcn3f6iB+P3gwCv/no7OdzOZQcKchW6N389V6PD8NUWKZHzndOd9sPDVbMoBsbmjMqlB4L9fm+fEFNVlwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/plugin-transform-destructuring": "^7.28.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-exponentiation-operator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.27.1.tgz", - "integrity": "sha512-uspvXnhHvGKf2r4VVtBpeFnuDWsJLQ6MF6lGJLC89jBR1uoVeqM416AZtTuhTezOfgHicpJQmoD5YUakO/YmXQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-export-namespace-from": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", - "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-for-of": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", - "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-function-name": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz", - "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-compilation-targets": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/traverse": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-json-strings": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.27.1.tgz", - "integrity": "sha512-6WVLVJiTjqcQauBhn1LkICsR2H+zm62I3h9faTDKt1qP4jn2o72tSvqMwtGFKGTpojce0gJs+76eZ2uCHRZh0Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz", - "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-logical-assignment-operators": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.27.1.tgz", - "integrity": "sha512-SJvDs5dXxiae4FbSL1aBJlG4wvl594N6YEVVn9e3JGulwioy6z3oPjx/sQBO3Y4NwUu5HNix6KJ3wBZoewcdbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-member-expression-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz", - "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-amd": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz", - "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.27.1.tgz", - "integrity": "sha512-OJguuwlTYlN0gBZFRPqwOGNWssZjfIUdS7HMYtN8c1KmwpwHFBwTeFZrg9XZa+DFTitWOW5iTAG7tyCUPsCCyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.27.1.tgz", - "integrity": "sha512-w5N1XzsRbc0PQStASMksmUeqECuzKuTJer7kFagK8AXgpCMkeDMO5S+aaFb7A51ZYDF7XI34qsTX+fkHiIm5yA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-umd": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz", - "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-transforms": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.27.1.tgz", - "integrity": "sha512-SstR5JYy8ddZvD6MhV0tM/j16Qds4mIpJTOd1Yu9J9pJjH93bxHECF7pgtc28XvkzTD6Pxcm/0Z73Hvk7kb3Ng==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-transform-new-target": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz", - "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.27.1.tgz", - "integrity": "sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-numeric-separator": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.27.1.tgz", - "integrity": "sha512-fdPKAcujuvEChxDBJ5c+0BTaS6revLV7CJL08e4m3de8qJfNIuCc2nc7XJYOjBoTMJeqSmwXJ0ypE14RCjLwaw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-object-rest-spread": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.0.tgz", - "integrity": "sha512-9VNGikXxzu5eCiQjdE4IZn8sb9q7Xsk5EXLDBKUYg1e/Tve8/05+KJEtcxGxAgCY5t/BpKQM+JEL/yT4tvgiUA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/plugin-transform-destructuring": "^7.28.0", - "@babel/plugin-transform-parameters": "^7.27.7", - "@babel/traverse": "^7.28.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-object-super": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz", - "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-replace-supers": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-optional-catch-binding": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.27.1.tgz", - "integrity": "sha512-txEAEKzYrHEX4xSZN4kJ+OfKXFVSWKB2ZxM9dpcE3wT7smwkNmXo5ORRlVzMVdJbD+Q8ILTgSD7959uj+3Dm3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-optional-chaining": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.27.1.tgz", - "integrity": "sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-parameters": { - "version": "7.27.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz", - "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-private-methods": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.27.1.tgz", - "integrity": "sha512-10FVt+X55AjRAYI9BrdISN9/AQWHqldOeZDUoLyif1Kn05a56xVBXb8ZouL8pZ9jem8QpXaOt8TS7RHUIS+GPA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-private-property-in-object": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.27.1.tgz", - "integrity": "sha512-5J+IhqTi1XPa0DXF83jYOaARrX+41gOewWbkPyjMNRDqgOCqdffGh8L3f/Ek5utaEBZExjSAzcyjmV9SSAWObQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.1", - "@babel/helper-create-class-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-property-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz", - "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-regenerator": { - "version": "7.28.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.28.1.tgz", - "integrity": "sha512-P0QiV/taaa3kXpLY+sXla5zec4E+4t4Aqc9ggHlfZ7a2cp8/x/Gv08jfwEtn9gnnYIMvHx6aoOZ8XJL8eU71Dg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-regexp-modifiers": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.27.1.tgz", - "integrity": "sha512-TtEciroaiODtXvLZv4rmfMhkCv8jx3wgKpL68PuiPh2M4fvz5jhsA7697N1gMvkvr/JTF13DrFYyEbY9U7cVPA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/plugin-transform-reserved-words": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz", - "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-shorthand-properties": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", - "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-spread": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.27.1.tgz", - "integrity": "sha512-kpb3HUqaILBJcRFVhFUs6Trdd4mkrzcGXss+6/mxUd273PfbWqSDHRzMT2234gIg2QYfAjvXLSquP1xECSg09Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-sticky-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz", - "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-template-literals": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", - "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-typeof-symbol": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz", - "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-unicode-escapes": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", - "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-unicode-property-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.27.1.tgz", - "integrity": "sha512-uW20S39PnaTImxp39O5qFlHLS9LJEmANjMG7SxIhap8rCHqu0Ik+tLEPX5DKmHn6CsWQ7j3lix2tFOa5YtL12Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-unicode-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz", - "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-unicode-sets-regex": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.27.1.tgz", - "integrity": "sha512-EtkOujbc4cgvb0mlpQefi4NTPBzhSIevblFevACNLUspmrALgmEBdL/XfnyyITfd8fKBZrZys92zOWcik7j9Tw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.27.1", - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/preset-env": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.28.0.tgz", - "integrity": "sha512-VmaxeGOwuDqzLl5JUkIRM1X2Qu2uKGxHEQWh+cvvbl7JuJRgKGJSfsEF/bUaxFhJl/XAyxBe7q7qSuTbKFuCyg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.28.0", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-plugin-utils": "^7.27.1", - "@babel/helper-validator-option": "^7.27.1", - "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.27.1", - "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1", - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1", - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1", - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.27.1", - "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", - "@babel/plugin-syntax-import-assertions": "^7.27.1", - "@babel/plugin-syntax-import-attributes": "^7.27.1", - "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", - "@babel/plugin-transform-arrow-functions": "^7.27.1", - "@babel/plugin-transform-async-generator-functions": "^7.28.0", - "@babel/plugin-transform-async-to-generator": "^7.27.1", - "@babel/plugin-transform-block-scoped-functions": "^7.27.1", - "@babel/plugin-transform-block-scoping": "^7.28.0", - "@babel/plugin-transform-class-properties": "^7.27.1", - "@babel/plugin-transform-class-static-block": "^7.27.1", - "@babel/plugin-transform-classes": "^7.28.0", - "@babel/plugin-transform-computed-properties": "^7.27.1", - "@babel/plugin-transform-destructuring": "^7.28.0", - "@babel/plugin-transform-dotall-regex": "^7.27.1", - "@babel/plugin-transform-duplicate-keys": "^7.27.1", - "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.27.1", - "@babel/plugin-transform-dynamic-import": "^7.27.1", - "@babel/plugin-transform-explicit-resource-management": "^7.28.0", - "@babel/plugin-transform-exponentiation-operator": "^7.27.1", - "@babel/plugin-transform-export-namespace-from": "^7.27.1", - "@babel/plugin-transform-for-of": "^7.27.1", - "@babel/plugin-transform-function-name": "^7.27.1", - "@babel/plugin-transform-json-strings": "^7.27.1", - "@babel/plugin-transform-literals": "^7.27.1", - "@babel/plugin-transform-logical-assignment-operators": "^7.27.1", - "@babel/plugin-transform-member-expression-literals": "^7.27.1", - "@babel/plugin-transform-modules-amd": "^7.27.1", - "@babel/plugin-transform-modules-commonjs": "^7.27.1", - "@babel/plugin-transform-modules-systemjs": "^7.27.1", - "@babel/plugin-transform-modules-umd": "^7.27.1", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.27.1", - "@babel/plugin-transform-new-target": "^7.27.1", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1", - "@babel/plugin-transform-numeric-separator": "^7.27.1", - "@babel/plugin-transform-object-rest-spread": "^7.28.0", - "@babel/plugin-transform-object-super": "^7.27.1", - "@babel/plugin-transform-optional-catch-binding": "^7.27.1", - "@babel/plugin-transform-optional-chaining": "^7.27.1", - "@babel/plugin-transform-parameters": "^7.27.7", - "@babel/plugin-transform-private-methods": "^7.27.1", - "@babel/plugin-transform-private-property-in-object": "^7.27.1", - "@babel/plugin-transform-property-literals": "^7.27.1", - "@babel/plugin-transform-regenerator": "^7.28.0", - "@babel/plugin-transform-regexp-modifiers": "^7.27.1", - "@babel/plugin-transform-reserved-words": "^7.27.1", - "@babel/plugin-transform-shorthand-properties": "^7.27.1", - "@babel/plugin-transform-spread": "^7.27.1", - "@babel/plugin-transform-sticky-regex": "^7.27.1", - "@babel/plugin-transform-template-literals": "^7.27.1", - "@babel/plugin-transform-typeof-symbol": "^7.27.1", - "@babel/plugin-transform-unicode-escapes": "^7.27.1", - "@babel/plugin-transform-unicode-property-regex": "^7.27.1", - "@babel/plugin-transform-unicode-regex": "^7.27.1", - "@babel/plugin-transform-unicode-sets-regex": "^7.27.1", - "@babel/preset-modules": "0.1.6-no-external-plugins", - "babel-plugin-polyfill-corejs2": "^0.4.14", - "babel-plugin-polyfill-corejs3": "^0.13.0", - "babel-plugin-polyfill-regenerator": "^0.6.5", - "core-js-compat": "^3.43.0", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/preset-modules": { - "version": "0.1.6-no-external-plugins", - "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", - "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.0.0", - "@babel/types": "^7.4.4", - "esutils": "^2.0.2" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz", - "integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.0", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.0", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.28.2", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz", - "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@bcoe/v8-coverage": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", - "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@istanbuljs/load-nyc-config": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", - "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "camelcase": "^5.3.1", - "find-up": "^4.1.0", - "get-package-type": "^0.1.0", - "js-yaml": "^3.13.1", - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@jest/console": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", - "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/core": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", - "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/console": "^29.7.0", - "@jest/reporters": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "exit": "^0.1.2", - "graceful-fs": "^4.2.9", - "jest-changed-files": "^29.7.0", - "jest-config": "^29.7.0", - "jest-haste-map": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-resolve-dependencies": "^29.7.0", - "jest-runner": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "jest-watcher": "^29.7.0", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/@jest/environment": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", - "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-mock": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/expect": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "expect": "^29.7.0", - "jest-snapshot": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/expect-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", - "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "jest-get-type": "^29.6.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/fake-timers": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", - "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@sinonjs/fake-timers": "^10.0.2", - "@types/node": "*", - "jest-message-util": "^29.7.0", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/globals": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", - "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/expect": "^29.7.0", - "@jest/types": "^29.6.3", - "jest-mock": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/pattern": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", - "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "jest-regex-util": "30.0.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/pattern/node_modules/jest-regex-util": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", - "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/reporters": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", - "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@jridgewell/trace-mapping": "^0.3.18", - "@types/node": "*", - "chalk": "^4.0.0", - "collect-v8-coverage": "^1.0.0", - "exit": "^0.1.2", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "istanbul-lib-coverage": "^3.0.0", - "istanbul-lib-instrument": "^6.0.0", - "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^4.0.0", - "istanbul-reports": "^3.1.3", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "jest-worker": "^29.7.0", - "slash": "^3.0.0", - "string-length": "^4.0.1", - "strip-ansi": "^6.0.0", - "v8-to-istanbul": "^9.0.1" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/@jest/schemas": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sinclair/typebox": "^0.27.8" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/source-map": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", - "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.18", - "callsites": "^3.0.0", - "graceful-fs": "^4.2.9" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/test-result": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", - "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/console": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "collect-v8-coverage": "^1.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/test-sequencer": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", - "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/test-result": "^29.7.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/transform": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", - "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.11.6", - "@jest/types": "^29.6.3", - "@jridgewell/trace-mapping": "^0.3.18", - "babel-plugin-istanbul": "^6.1.1", - "chalk": "^4.0.0", - "convert-source-map": "^2.0.0", - "fast-json-stable-stringify": "^2.1.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-util": "^29.7.0", - "micromatch": "^4.0.4", - "pirates": "^4.0.4", - "slash": "^3.0.0", - "write-file-atomic": "^4.0.2" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jest/types": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", - "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", - "@types/node": "*", - "@types/yargs": "^17.0.8", - "chalk": "^4.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.12", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", - "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", - "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.29", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", - "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@noble/hashes": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@paralleldrive/cuid2": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz", - "integrity": "sha512-ZOBkgDwEdoYVlSeRbYYXs0S9MejQofiVYoTbKzy/6GQa39/q5tQU2IX46+shYnUkpEl3wc+J6wRlar7r2EK2xA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@noble/hashes": "^1.1.5" - } - }, - "node_modules/@sinclair/typebox": { - "version": "0.27.8", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", - "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@sinonjs/commons": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", - "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "type-detect": "4.0.8" - } - }, - "node_modules/@sinonjs/fake-timers": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", - "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^3.0.0" - } - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.20.7", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz", - "integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.20.7" - } - }, - "node_modules/@types/graceful-fs": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", - "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/istanbul-lib-coverage": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", - "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/istanbul-lib-report": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", - "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/istanbul-lib-coverage": "*" - } - }, - "node_modules/@types/istanbul-reports": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", - "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/istanbul-lib-report": "*" - } - }, - "node_modules/@types/node": { - "version": "24.1.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.1.0.tgz", - "integrity": "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.8.0" - } - }, - "node_modules/@types/stack-utils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", - "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/yargs": { - "version": "17.0.33", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", - "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/yargs-parser": "*" - } - }, - "node_modules/@types/yargs-parser": { - "version": "21.0.3", - "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", - "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", - "dev": true, - "license": "ISC" - }, - "node_modules/adm-zip": { - "version": "0.5.16", - "resolved": "https://registry.npmmirror.com/adm-zip/-/adm-zip-0.5.16.tgz", - "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", - "license": "MIT", - "engines": { - "node": ">=12.0" - } - }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/ansi-escapes": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", - "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "type-fest": "^0.21.3" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/append-field": { - "version": "1.0.0", - "resolved": "https://registry.npmmirror.com/append-field/-/append-field-1.0.0.tgz", - "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", - "license": "MIT" - }, - "node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/asap": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", - "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", - "dev": true, - "license": "MIT" - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, - "node_modules/axios": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz", - "integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==", - "license": "MIT", - "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" - } - }, - "node_modules/babel-jest": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.0.5.tgz", - "integrity": "sha512-mRijnKimhGDMsizTvBTWotwNpzrkHr+VvZUQBof2AufXKB8NXrL1W69TG20EvOz7aevx6FTJIaBuBkYxS8zolg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/transform": "30.0.5", - "@types/babel__core": "^7.20.5", - "babel-plugin-istanbul": "^7.0.0", - "babel-preset-jest": "30.0.1", - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "slash": "^3.0.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.11.0" - } - }, - "node_modules/babel-jest/node_modules/@jest/schemas": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", - "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sinclair/typebox": "^0.34.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/babel-jest/node_modules/@jest/transform": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.0.5.tgz", - "integrity": "sha512-Vk8amLQCmuZyy6GbBht1Jfo9RSdBtg7Lks+B0PecnjI8J+PCLQPGh7uI8Q/2wwpW2gLdiAfiHNsmekKlywULqg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.27.4", - "@jest/types": "30.0.5", - "@jridgewell/trace-mapping": "^0.3.25", - "babel-plugin-istanbul": "^7.0.0", - "chalk": "^4.1.2", - "convert-source-map": "^2.0.0", - "fast-json-stable-stringify": "^2.1.0", - "graceful-fs": "^4.2.11", - "jest-haste-map": "30.0.5", - "jest-regex-util": "30.0.1", - "jest-util": "30.0.5", - "micromatch": "^4.0.8", - "pirates": "^4.0.7", - "slash": "^3.0.0", - "write-file-atomic": "^5.0.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/babel-jest/node_modules/@jest/types": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.0.5.tgz", - "integrity": "sha512-aREYa3aku9SSnea4aX6bhKn4bgv3AXkgijoQgbYV3yvbiGt6z+MQ85+6mIhx9DsKW2BuB/cLR/A+tcMThx+KLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/pattern": "30.0.1", - "@jest/schemas": "30.0.5", - "@types/istanbul-lib-coverage": "^2.0.6", - "@types/istanbul-reports": "^3.0.4", - "@types/node": "*", - "@types/yargs": "^17.0.33", - "chalk": "^4.1.2" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/babel-jest/node_modules/@sinclair/typebox": { - "version": "0.34.38", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.38.tgz", - "integrity": "sha512-HpkxMmc2XmZKhvaKIZZThlHmx1L0I/V1hWK1NubtlFnr6ZqdiOpV72TKudZUNQjZNsyDBay72qFEhEvb+bcwcA==", - "dev": true, - "license": "MIT" - }, - "node_modules/babel-jest/node_modules/babel-plugin-istanbul": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.0.tgz", - "integrity": "sha512-C5OzENSx/A+gt7t4VH1I2XsflxyPUmXRFPKBxt33xncdOmq7oROVM3bZv9Ysjjkv8OJYDMa+tKuKMvqU/H3xdw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@babel/helper-plugin-utils": "^7.0.0", - "@istanbuljs/load-nyc-config": "^1.0.0", - "@istanbuljs/schema": "^0.1.3", - "istanbul-lib-instrument": "^6.0.2", - "test-exclude": "^6.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/babel-jest/node_modules/ci-info": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.0.tgz", - "integrity": "sha512-l+2bNRMiQgcfILUi33labAZYIWlH1kWDp+ecNo5iisRKrbm0xcRyCww71/YU0Fkw0mAFpz9bJayXPjey6vkmaQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/babel-jest/node_modules/jest-haste-map": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.0.5.tgz", - "integrity": "sha512-dkmlWNlsTSR0nH3nRfW5BKbqHefLZv0/6LCccG0xFCTWcJu8TuEwG+5Cm75iBfjVoockmO6J35o5gxtFSn5xeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "30.0.5", - "@types/node": "*", - "anymatch": "^3.1.3", - "fb-watchman": "^2.0.2", - "graceful-fs": "^4.2.11", - "jest-regex-util": "30.0.1", - "jest-util": "30.0.5", - "jest-worker": "30.0.5", - "micromatch": "^4.0.8", - "walker": "^1.0.8" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "optionalDependencies": { - "fsevents": "^2.3.3" - } - }, - "node_modules/babel-jest/node_modules/jest-regex-util": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", - "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/babel-jest/node_modules/jest-util": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.5.tgz", - "integrity": "sha512-pvyPWssDZR0FlfMxCBoc0tvM8iUEskaRFALUtGQYzVEAqisAztmy+R8LnU14KT4XA0H/a5HMVTXat1jLne010g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "30.0.5", - "@types/node": "*", - "chalk": "^4.1.2", - "ci-info": "^4.2.0", - "graceful-fs": "^4.2.11", - "picomatch": "^4.0.2" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/babel-jest/node_modules/jest-worker": { - "version": "30.0.5", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.0.5.tgz", - "integrity": "sha512-ojRXsWzEP16NdUuBw/4H/zkZdHOa7MMYCk4E430l+8fELeLg/mqmMlRhjL7UNZvQrDmnovWZV4DxX03fZF48fQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@ungap/structured-clone": "^1.3.0", - "jest-util": "30.0.5", - "merge-stream": "^2.0.0", - "supports-color": "^8.1.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/babel-jest/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/babel-jest/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/babel-jest/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/babel-jest/node_modules/write-file-atomic": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", - "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/babel-plugin-istanbul": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", - "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@babel/helper-plugin-utils": "^7.0.0", - "@istanbuljs/load-nyc-config": "^1.0.0", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-instrument": "^5.0.4", - "test-exclude": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", - "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@babel/core": "^7.12.3", - "@babel/parser": "^7.14.7", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^6.3.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/babel-plugin-jest-hoist": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.0.1.tgz", - "integrity": "sha512-zTPME3pI50NsFW8ZBaVIOeAxzEY7XHlmWeXXu9srI+9kNfzCUTy8MFan46xOGZY8NZThMqq+e3qZUKsvXbasnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.27.3", - "@types/babel__core": "^7.20.5" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/babel-plugin-polyfill-corejs2": { - "version": "0.4.14", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.14.tgz", - "integrity": "sha512-Co2Y9wX854ts6U8gAAPXfn0GmAyctHuK8n0Yhfjd6t30g7yvKjspvvOo9yG+z52PZRgFErt7Ka2pYnXCjLKEpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.27.7", - "@babel/helper-define-polyfill-provider": "^0.6.5", - "semver": "^6.3.1" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.13.0", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz", - "integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.5", - "core-js-compat": "^3.43.0" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/babel-plugin-polyfill-regenerator": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.5.tgz", - "integrity": "sha512-ISqQ2frbiNU9vIJkzg7dlPpznPZ4jOiUQ1uSmB0fEHeowtN3COYRsXr/xexn64NpU13P06jc/L5TgiJXOgrbEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.5" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/babel-plugin-transform-import-meta": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-import-meta/-/babel-plugin-transform-import-meta-2.3.3.tgz", - "integrity": "sha512-bbh30qz1m6ZU1ybJoNOhA2zaDvmeXMnGNBMVMDOJ1Fni4+wMBoy/j7MTRVmqAUCIcy54/rEnr9VEBsfcgbpm3Q==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@babel/template": "^7.25.9", - "tslib": "^2.8.1" - }, - "peerDependencies": { - "@babel/core": "^7.10.0" - } - }, - "node_modules/babel-preset-current-node-syntax": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.1.0.tgz", - "integrity": "sha512-ldYss8SbBlWva1bs28q78Ju5Zq1F+8BrqBZZ0VFhLBvhh6lCpC2o3gDJi/5DRLs9FgYZCnmPYIVFU4lRXCkyUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/plugin-syntax-async-generators": "^7.8.4", - "@babel/plugin-syntax-bigint": "^7.8.3", - "@babel/plugin-syntax-class-properties": "^7.12.13", - "@babel/plugin-syntax-class-static-block": "^7.14.5", - "@babel/plugin-syntax-import-attributes": "^7.24.7", - "@babel/plugin-syntax-import-meta": "^7.10.4", - "@babel/plugin-syntax-json-strings": "^7.8.3", - "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", - "@babel/plugin-syntax-numeric-separator": "^7.10.4", - "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", - "@babel/plugin-syntax-optional-chaining": "^7.8.3", - "@babel/plugin-syntax-private-property-in-object": "^7.14.5", - "@babel/plugin-syntax-top-level-await": "^7.14.5" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/babel-preset-jest": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.0.1.tgz", - "integrity": "sha512-+YHejD5iTWI46cZmcc/YtX4gaKBtdqCHCVfuVinizVpbmyjO3zYmeuyFdfA8duRqQZfgCAMlsfmkVbJ+e2MAJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "babel-plugin-jest-hoist": "30.0.1", - "babel-preset-current-node-syntax": "^1.1.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.11.0" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/bignumber.js": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", - "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.25.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", - "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "caniuse-lite": "^1.0.30001726", - "electron-to-chromium": "^1.5.173", - "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/bser": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", - "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "node-int64": "^0.4.0" - } - }, - "node_modules/buffer-equal-constant-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "license": "BSD-3-Clause" - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "license": "MIT" - }, - "node_modules/bundle-name": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", - "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", - "license": "MIT", - "dependencies": { - "run-applescript": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/busboy": { - "version": "1.6.0", - "resolved": "https://registry.npmmirror.com/busboy/-/busboy-1.6.0.tgz", - "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", - "dependencies": { - "streamsearch": "^1.1.0" - }, - "engines": { - "node": ">=10.16.0" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001727", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz", - "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/char-regex": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", - "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cjs-module-lexer": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", - "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/co": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", - "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", - "dev": true, - "license": "MIT", - "engines": { - "iojs": ">= 1.0.0", - "node": ">= 0.12.0" - } - }, - "node_modules/collect-v8-coverage": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", - "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/component-emitter": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", - "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, - "node_modules/concat-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/concat-stream/-/concat-stream-2.0.0.tgz", - "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", - "engines": [ - "node >= 6.0" - ], - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^3.0.2", - "typedarray": "^0.0.6" - } - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cookiejar": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", - "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", - "dev": true, - "license": "MIT" - }, - "node_modules/core-js-compat": { - "version": "3.44.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.44.0.tgz", - "integrity": "sha512-JepmAj2zfl6ogy34qfWtcE7nHKAJnKsQFRn++scjVS2bZFllwptzw61BZcZFYBPpUznLfAvh0LGhxKppk04ClA==", - "dev": true, - "license": "MIT", - "dependencies": { - "browserslist": "^4.25.1" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, - "node_modules/create-jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", - "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "exit": "^0.1.2", - "graceful-fs": "^4.2.9", - "jest-config": "^29.7.0", - "jest-util": "^29.7.0", - "prompts": "^2.0.1" - }, - "bin": { - "create-jest": "bin/create-jest.js" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/data-uri-to-buffer": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", - "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/dedent": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.6.0.tgz", - "integrity": "sha512-F1Z+5UCFpmQUzJa11agbyPVMbpgT/qA3/SKyJ1jyBgm7dUcUEa8v9JwDkerSQXfakBwFljIxhOJqGkjUwZ9FSA==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "babel-plugin-macros": "^3.1.0" - }, - "peerDependenciesMeta": { - "babel-plugin-macros": { - "optional": true - } - } - }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/default-browser": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.2.1.tgz", - "integrity": "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==", - "license": "MIT", - "dependencies": { - "bundle-name": "^4.1.0", - "default-browser-id": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/default-browser-id": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.0.tgz", - "integrity": "sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/define-lazy-prop": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", - "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/detect-newline": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", - "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/dezalgo": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", - "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", - "dev": true, - "license": "ISC", - "dependencies": { - "asap": "^2.0.0", - "wrappy": "1" - } - }, - "node_modules/diff-sequences": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", - "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ecdsa-sig-formatter": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - } - }, - "node_modules/electron-to-chromium": { - "version": "1.5.190", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.190.tgz", - "integrity": "sha512-k4McmnB2091YIsdCgkS0fMVMPOJgxl93ltFzaryXqwip1AaxeDqKCGLxkXODDA5Ab/D+tV5EL5+aTx76RvLRxw==", - "dev": true, - "license": "ISC" - }, - "node_modules/emittery": { - "version": "0.13.1", - "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", - "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sindresorhus/emittery?sponsor=1" - } - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, - "license": "BSD-2-Clause", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/esutils": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "dev": true, - "license": "MIT", - "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" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/exit": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", - "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", - "dev": true, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/expect": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/expect-utils": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/extend": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", - "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", - "license": "MIT" - }, - "node_modules/fast-json-stable-stringify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-safe-stringify": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", - "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", - "dev": true, - "license": "MIT" - }, - "node_modules/fb-watchman": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", - "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "bser": "2.1.1" - } - }, - "node_modules/fetch-blob": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", - "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "paypal", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "dependencies": { - "node-domexception": "^1.0.0", - "web-streams-polyfill": "^3.0.3" - }, - "engines": { - "node": "^12.20 || >= 14.13" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/follow-redirects": { - "version": "1.15.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", - "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/formdata-polyfill": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", - "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "license": "MIT", - "dependencies": { - "fetch-blob": "^3.1.2" - }, - "engines": { - "node": ">=12.20.0" - } - }, - "node_modules/formidable": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.5.tgz", - "integrity": "sha512-Oz5Hwvwak/DCaXVVUtPn4oLMLLy1CdclLKO1LFgU7XzDpVMUU5UjlSLpGMocyQNNk8F6IJW9M/YdooSn2MRI+Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@paralleldrive/cuid2": "^2.2.2", - "dezalgo": "^1.0.4", - "once": "^1.4.0", - "qs": "^6.11.0" - }, - "funding": { - "url": "https://ko-fi.com/tunnckoCore/commissions" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, - "license": "ISC" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gaxios": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.1.tgz", - "integrity": "sha512-Odju3uBUJyVCkW64nLD4wKLhbh93bh6vIg/ZIXkWiLPBrdgtc65+tls/qml+un3pr6JqYVFDZbbmLDQT68rTOQ==", - "license": "Apache-2.0", - "dependencies": { - "extend": "^3.0.2", - "https-proxy-agent": "^7.0.1", - "node-fetch": "^3.3.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/gcp-metadata": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-7.0.1.tgz", - "integrity": "sha512-UcO3kefx6dCcZkgcTGgVOTFb7b1LlQ02hY1omMjjrrBzkajRMCFgYOjs7J71WqnuG1k2b+9ppGL7FsOfhZMQKQ==", - "license": "Apache-2.0", - "dependencies": { - "gaxios": "^7.0.0", - "google-logging-utils": "^1.0.0", - "json-bigint": "^1.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "dev": true, - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "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" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-package-type": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", - "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/google-auth-library": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.1.0.tgz", - "integrity": "sha512-GspVjZj1RbyRWpQ9FbAXMKjFGzZwDKnUHi66JJ+tcjcu5/xYAP1pdlWotCuIkMwjfVsxxDvsGZXGLzRt72D0sQ==", - "license": "Apache-2.0", - "dependencies": { - "base64-js": "^1.3.0", - "ecdsa-sig-formatter": "^1.0.11", - "gaxios": "^7.0.0", - "gcp-metadata": "^7.0.0", - "google-logging-utils": "^1.0.0", - "gtoken": "^8.0.0", - "jws": "^4.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/google-logging-utils": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.1.tgz", - "integrity": "sha512-rcX58I7nqpu4mbKztFeOAObbomBbHU2oIb/d3tJfF3dizGSApqtSwYJigGCooHdnMyQBIw8BrWyK96w3YXgr6A==", - "license": "Apache-2.0", - "engines": { - "node": ">=14" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/gtoken": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-8.0.0.tgz", - "integrity": "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==", - "license": "MIT", - "dependencies": { - "gaxios": "^7.0.0", - "jws": "^4.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true, - "license": "MIT" - }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10.17.0" - } - }, - "node_modules/import-local": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", - "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pkg-dir": "^4.2.0", - "resolve-cwd": "^3.0.0" - }, - "bin": { - "import-local-fixture": "fixtures/cli.js" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/imurmurhash": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", - "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ip-address": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", - "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-core-module": { - "version": "2.16.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", - "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-docker": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", - "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", - "license": "MIT", - "bin": { - "is-docker": "cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-generator-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", - "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/is-inside-container": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", - "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", - "license": "MIT", - "dependencies": { - "is-docker": "^3.0.0" - }, - "bin": { - "is-inside-container": "cli.js" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-wsl": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", - "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", - "license": "MIT", - "dependencies": { - "is-inside-container": "^1.0.0" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-instrument": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", - "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@babel/core": "^7.23.9", - "@babel/parser": "^7.23.9", - "@istanbuljs/schema": "^0.1.3", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^7.5.4" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-instrument/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-source-maps": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", - "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0", - "source-map": "^0.6.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-reports": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", - "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", - "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/core": "^29.7.0", - "@jest/types": "^29.6.3", - "import-local": "^3.0.2", - "jest-cli": "^29.7.0" - }, - "bin": { - "jest": "bin/jest.js" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/jest-changed-files": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", - "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", - "dev": true, - "license": "MIT", - "dependencies": { - "execa": "^5.0.0", - "jest-util": "^29.7.0", - "p-limit": "^3.1.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-circus": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", - "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/expect": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "co": "^4.6.0", - "dedent": "^1.0.0", - "is-generator-fn": "^2.0.0", - "jest-each": "^29.7.0", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "p-limit": "^3.1.0", - "pretty-format": "^29.7.0", - "pure-rand": "^6.0.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-cli": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", - "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/core": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "create-jest": "^29.7.0", - "exit": "^0.1.2", - "import-local": "^3.0.2", - "jest-config": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "yargs": "^17.3.1" - }, - "bin": { - "jest": "bin/jest.js" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/jest-config": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", - "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.11.6", - "@jest/test-sequencer": "^29.7.0", - "@jest/types": "^29.6.3", - "babel-jest": "^29.7.0", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "deepmerge": "^4.2.2", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-circus": "^29.7.0", - "jest-environment-node": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-runner": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "micromatch": "^4.0.4", - "parse-json": "^5.2.0", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@types/node": "*", - "ts-node": ">=9.0.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "ts-node": { - "optional": true - } - } - }, - "node_modules/jest-config/node_modules/babel-jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", - "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/transform": "^29.7.0", - "@types/babel__core": "^7.1.14", - "babel-plugin-istanbul": "^6.1.1", - "babel-preset-jest": "^29.6.3", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.8.0" - } - }, - "node_modules/jest-config/node_modules/babel-plugin-jest-hoist": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", - "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.3.3", - "@babel/types": "^7.3.3", - "@types/babel__core": "^7.1.14", - "@types/babel__traverse": "^7.0.6" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-config/node_modules/babel-preset-jest": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", - "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", - "dev": true, - "license": "MIT", - "dependencies": { - "babel-plugin-jest-hoist": "^29.6.3", - "babel-preset-current-node-syntax": "^1.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/jest-diff": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", - "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.0.0", - "diff-sequences": "^29.6.3", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-docblock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", - "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "detect-newline": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-each": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", - "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", - "jest-util": "^29.7.0", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-environment-node": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", - "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-get-type": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", - "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-haste-map": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", - "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/graceful-fs": "^4.1.3", - "@types/node": "*", - "anymatch": "^3.0.3", - "fb-watchman": "^2.0.0", - "graceful-fs": "^4.2.9", - "jest-regex-util": "^29.6.3", - "jest-util": "^29.7.0", - "jest-worker": "^29.7.0", - "micromatch": "^4.0.4", - "walker": "^1.0.8" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "optionalDependencies": { - "fsevents": "^2.3.2" - } - }, - "node_modules/jest-leak-detector": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", - "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", - "dev": true, - "license": "MIT", - "dependencies": { - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-matcher-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", - "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.0.0", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-message-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", - "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.12.13", - "@jest/types": "^29.6.3", - "@types/stack-utils": "^2.0.0", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-mock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", - "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-util": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-pnp-resolver": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", - "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - }, - "peerDependencies": { - "jest-resolve": "*" - }, - "peerDependenciesMeta": { - "jest-resolve": { - "optional": true - } - } - }, - "node_modules/jest-regex-util": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", - "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-resolve": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", - "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-pnp-resolver": "^1.2.2", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "resolve": "^1.20.0", - "resolve.exports": "^2.0.0", - "slash": "^3.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-resolve-dependencies": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", - "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", - "dev": true, - "license": "MIT", - "dependencies": { - "jest-regex-util": "^29.6.3", - "jest-snapshot": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-runner": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", - "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/console": "^29.7.0", - "@jest/environment": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "emittery": "^0.13.1", - "graceful-fs": "^4.2.9", - "jest-docblock": "^29.7.0", - "jest-environment-node": "^29.7.0", - "jest-haste-map": "^29.7.0", - "jest-leak-detector": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-resolve": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-util": "^29.7.0", - "jest-watcher": "^29.7.0", - "jest-worker": "^29.7.0", - "p-limit": "^3.1.0", - "source-map-support": "0.5.13" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-runtime": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", - "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/globals": "^29.7.0", - "@jest/source-map": "^29.6.3", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "cjs-module-lexer": "^1.0.0", - "collect-v8-coverage": "^1.0.0", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-mock": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "slash": "^3.0.0", - "strip-bom": "^4.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-snapshot": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", - "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.11.6", - "@babel/generator": "^7.7.2", - "@babel/plugin-syntax-jsx": "^7.7.2", - "@babel/plugin-syntax-typescript": "^7.7.2", - "@babel/types": "^7.3.3", - "@jest/expect-utils": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "babel-preset-current-node-syntax": "^1.0.0", - "chalk": "^4.0.0", - "expect": "^29.7.0", - "graceful-fs": "^4.2.9", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "natural-compare": "^1.4.0", - "pretty-format": "^29.7.0", - "semver": "^7.5.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-snapshot/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/jest-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-validate": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", - "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "^29.6.3", - "camelcase": "^6.2.0", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", - "leven": "^3.1.0", - "pretty-format": "^29.7.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-validate/node_modules/camelcase": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", - "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/jest-watcher": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", - "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", - "emittery": "^0.13.1", - "jest-util": "^29.7.0", - "string-length": "^4.0.1" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-worker": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", - "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "jest-util": "^29.7.0", - "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-worker/node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json-bigint": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", - "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", - "license": "MIT", - "dependencies": { - "bignumber.js": "^9.0.0" - } - }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true, - "license": "MIT" - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/jwa": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "license": "MIT", - "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/jws": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", - "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", - "license": "MIT", - "dependencies": { - "jwa": "^2.0.0", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/kleur": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", - "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/leven": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", - "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, - "license": "MIT" - }, - "node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "license": "MIT" - }, - "node_modules/lodash.debounce": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", - "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", - "dev": true, - "license": "MIT" - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/make-dir/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/makeerror": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", - "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tmpl": "1.0.5" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true, - "license": "MIT" - }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/mime": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", - "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", - "dev": true, - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmmirror.com/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "license": "MIT", - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/multer": { - "version": "2.0.2", - "resolved": "https://registry.npmmirror.com/multer/-/multer-2.0.2.tgz", - "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", - "license": "MIT", - "dependencies": { - "append-field": "^1.0.0", - "busboy": "^1.6.0", - "concat-stream": "^2.0.0", - "mkdirp": "^0.5.6", - "object-assign": "^4.1.1", - "type-is": "^1.6.18", - "xtend": "^4.0.2" - }, - "engines": { - "node": ">= 10.16.0" - } - }, - "node_modules/natural-compare": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", - "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "deprecated": "Use your platform's native DOMException instead", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "engines": { - "node": ">=10.5.0" - } - }, - "node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "license": "MIT", - "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" - } - }, - "node_modules/node-int64": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", - "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", - "dev": true, - "license": "MIT" - }, - "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", - "dev": true, - "license": "MIT" - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/open": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", - "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", - "license": "MIT", - "dependencies": { - "default-browser": "^5.2.1", - "define-lazy-prop": "^3.0.0", - "is-inside-container": "^1.0.0", - "wsl-utils": "^0.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/p-locate/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, - "license": "MIT", - "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" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pirates": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", - "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/pkg-dir": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", - "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "find-up": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/prompts": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", - "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "kleur": "^3.0.3", - "sisteransi": "^1.0.5" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" - }, - "node_modules/pure-rand": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", - "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ], - "license": "MIT" - }, - "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, - "node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/regenerate": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", - "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", - "dev": true, - "license": "MIT" - }, - "node_modules/regenerate-unicode-properties": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.0.tgz", - "integrity": "sha512-DqHn3DwbmmPVzeKj9woBadqmXxLvQoQIwu7nopMc72ztvxVmVk2SBhSnx67zuye5TP+lJsb/TBQsjLKhnDf3MA==", - "dev": true, - "license": "MIT", - "dependencies": { - "regenerate": "^1.4.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/regexpu-core": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.2.0.tgz", - "integrity": "sha512-H66BPQMrv+V16t8xtmq+UC0CBpiTBA60V8ibS1QVReIp8T1z8hwFxqcGzm9K6lgsN7sB5edVH8a+ze6Fqm4weA==", - "dev": true, - "license": "MIT", - "dependencies": { - "regenerate": "^1.4.2", - "regenerate-unicode-properties": "^10.2.0", - "regjsgen": "^0.8.0", - "regjsparser": "^0.12.0", - "unicode-match-property-ecmascript": "^2.0.0", - "unicode-match-property-value-ecmascript": "^2.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/regjsgen": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", - "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/regjsparser": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.12.0.tgz", - "integrity": "sha512-cnE+y8bz4NhMjISKbgeVJtqNbtf5QpjZP+Bslo+UqkIt9QPnX9q095eiRRASJG1/tz6dlNr6Z5NsBiWYokp6EQ==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "jsesc": "~3.0.2" - }, - "bin": { - "regjsparser": "bin/parser" - } - }, - "node_modules/regjsparser/node_modules/jsesc": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", - "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/resolve": { - "version": "1.22.10", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", - "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-core-module": "^2.16.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-cwd": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", - "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "resolve-from": "^5.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/resolve.exports": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", - "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/run-applescript": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.0.0.tgz", - "integrity": "sha512-9by4Ij99JUr/MCFBUkDKLWK3G9HVXmabKz9U5MlIAIuvuzkiOicRYs8XJLxX+xahD+mLiiCYDqF9dKAgtzKP1A==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, - "license": "MIT", - "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" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, - "license": "MIT", - "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" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/sisteransi": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "dev": true, - "license": "MIT" - }, - "node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "license": "MIT", - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks": { - "version": "2.8.7", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", - "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", - "license": "MIT", - "dependencies": { - "ip-address": "^10.0.1", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks-proxy-agent": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", - "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "socks": "^2.8.3" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-support": { - "version": "0.5.13", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", - "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/stack-utils": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", - "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "escape-string-regexp": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/streamsearch": { - "version": "1.1.0", - "resolved": "https://registry.npmmirror.com/streamsearch/-/streamsearch-1.1.0.tgz", - "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/string_decoder": { - "version": "1.3.0", - "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz", - "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.2.0" - } - }, - "node_modules/string-length": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", - "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "char-regex": "^1.0.2", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-bom": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", - "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/strip-json-comments": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", - "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/superagent": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/superagent/-/superagent-8.1.2.tgz", - "integrity": "sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA==", - "deprecated": "Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net", - "dev": true, - "license": "MIT", - "dependencies": { - "component-emitter": "^1.3.0", - "cookiejar": "^2.1.4", - "debug": "^4.3.4", - "fast-safe-stringify": "^2.1.1", - "form-data": "^4.0.0", - "formidable": "^2.1.2", - "methods": "^1.1.2", - "mime": "2.6.0", - "qs": "^6.11.0", - "semver": "^7.3.8" - }, - "engines": { - "node": ">=6.4.0 <13 || >=14" - } - }, - "node_modules/superagent/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/supertest": { - "version": "6.3.4", - "resolved": "https://registry.npmjs.org/supertest/-/supertest-6.3.4.tgz", - "integrity": "sha512-erY3HFDG0dPnhw4U+udPfrzXa4xhSG+n4rxfRuZWCUvjFWwKl+OxWf/7zk50s84/fAAs7vf5QAb9uRa0cCykxw==", - "deprecated": "Please upgrade to supertest v7.1.3+, see release notes at https://github.com/forwardemail/supertest/releases/tag/v7.1.3 - maintenance is supported by Forward Email @ https://forwardemail.net", - "dev": true, - "license": "MIT", - "dependencies": { - "methods": "^1.1.2", - "superagent": "^8.1.2" - }, - "engines": { - "node": ">=6.4.0" - } - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/test-exclude": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", - "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", - "dev": true, - "license": "ISC", - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^7.1.4", - "minimatch": "^3.0.4" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/tiktoken": { - "version": "1.0.22", - "resolved": "https://registry.npmmirror.com/tiktoken/-/tiktoken-1.0.22.tgz", - "integrity": "sha512-PKvy1rVF1RibfF3JlXBSP0Jrcw2uq3yXdgcEXtKTYn3QJ/cBRBHDnrJ5jHky+MENZ6DIPwNUGWpkVx+7joCpNA==", - "license": "MIT" - }, - "node_modules/tmpl": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", - "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", - "dev": true, - "license": "BSD-3-Clause" - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "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==", - "dev": true, - "license": "0BSD" - }, - "node_modules/type-detect": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmmirror.com/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "license": "MIT", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/typedarray": { - "version": "0.0.6", - "resolved": "https://registry.npmmirror.com/typedarray/-/typedarray-0.0.6.tgz", - "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", - "license": "MIT" - }, - "node_modules/undici": { - "version": "7.12.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.12.0.tgz", - "integrity": "sha512-GrKEsc3ughskmGA9jevVlIOPMiiAHJ4OFUtaAH+NhfTUSiZ1wMPIQqQvAJUrJspFXJt3EBWgpAeoHEDVT1IBug==", - "license": "MIT", - "engines": { - "node": ">=20.18.1" - } - }, - "node_modules/undici-types": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", - "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", - "dev": true, - "license": "MIT" - }, - "node_modules/unicode-canonical-property-names-ecmascript": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", - "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/unicode-match-property-ecmascript": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", - "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "unicode-canonical-property-names-ecmascript": "^2.0.0", - "unicode-property-aliases-ecmascript": "^2.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/unicode-match-property-value-ecmascript": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.0.tgz", - "integrity": "sha512-4IehN3V/+kkr5YeSSDDQG8QLqO26XpL2XP3GQtqwlT/QYSECAwFztxVHjlbh0+gjJ3XmNLS0zDsbgs9jWKExLg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/unicode-property-aliases-ecmascript": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", - "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, - "node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/esm/bin/uuid" - } - }, - "node_modules/v8-to-istanbul": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", - "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", - "dev": true, - "license": "ISC", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.12", - "@types/istanbul-lib-coverage": "^2.0.1", - "convert-source-map": "^2.0.0" - }, - "engines": { - "node": ">=10.12.0" - } - }, - "node_modules/walker": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", - "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "makeerror": "1.0.12" - } - }, - "node_modules/web-streams-polyfill": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/write-file-atomic": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", - "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", - "dev": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4", - "signal-exit": "^3.0.7" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/wsl-utils": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", - "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", - "license": "MIT", - "dependencies": { - "is-wsl": "^3.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/xtend": { - "version": "4.0.2", - "resolved": "https://registry.npmmirror.com/xtend/-/xtend-4.0.2.tgz", - "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", - "license": "MIT", - "engines": { - "node": ">=0.4" - } - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" - }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, - "license": "MIT", - "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" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/yocto-queue": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", - "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - } - } -} diff --git a/package.json b/package.json deleted file mode 100644 index a967544b36326e61c027327cd19a2e7768e3f533..0000000000000000000000000000000000000000 --- a/package.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "type": "module", - "dependencies": { - "@anthropic-ai/tokenizer": "^0.0.4", - "adm-zip": "^0.5.16", - "axios": "^1.10.0", - "deepmerge": "^4.3.1", - "dotenv": "^16.4.5", - "google-auth-library": "^10.1.0", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.6", - "lodash": "^4.17.21", - "multer": "^2.0.2", - "open": "^10.2.0", - "socks-proxy-agent": "^8.0.5", - "undici": "^7.12.0", - "uuid": "^11.1.0", - "ws": "^8.19.0" - }, - "devDependencies": { - "@babel/preset-env": "^7.28.0", - "@jest/globals": "^29.7.0", - "babel-jest": "^30.0.5", - "babel-plugin-transform-import-meta": "^2.3.3", - "jest": "^29.7.0", - "jest-environment-node": "^29.7.0", - "supertest": "^6.3.3" - }, - "scripts": { - "start": "node src/core/master.js", - "start:standalone": "node src/services/api-server.js", - "start:dev": "node src/core/master.js --dev", - "test": "jest", - "test:watch": "jest --watch", - "test:coverage": "jest --coverage", - "test:verbose": "jest --verbose", - "test:silent": "jest --silent", - "test:unit": "node run-tests.js --unit", - "test:integration": "node run-tests.js --integration", - "test:summary": "node test-summary.js" - } -} diff --git a/src/auth/codex-oauth.js b/src/auth/codex-oauth.js deleted file mode 100644 index 6e4e4424151923c475b54f172f5831c80ebbaa69..0000000000000000000000000000000000000000 --- a/src/auth/codex-oauth.js +++ /dev/null @@ -1,1056 +0,0 @@ -import http from 'http'; -import logger from '../utils/logger.js'; -import fs from 'fs'; -import path from 'path'; -import crypto from 'crypto'; -import open from 'open'; -import axios from 'axios'; -import { broadcastEvent } from '../services/ui-manager.js'; -import { autoLinkProviderConfigs } from '../services/service-manager.js'; -import { CONFIG } from '../core/config-manager.js'; -import { getProxyConfigForProvider } from '../utils/proxy-utils.js'; - -/** - * Codex OAuth 配置 - */ -const CODEX_OAUTH_CONFIG = { - clientId: 'app_EMoamEEZ73f0CkXaXp7hrann', - authUrl: 'https://auth.openai.com/oauth/authorize', - tokenUrl: 'https://auth.openai.com/oauth/token', - redirectUri: 'http://localhost:1455/auth/callback', - port: 1455, - scopes: 'openid email profile offline_access', - logPrefix: '[Codex Auth]' -}; - -/** - * 活动的服务器实例管理(与 gemini-oauth 一致) - */ -const activeServers = new Map(); - -/** - * 关闭指定端口的活动服务器 - */ -async function closeActiveServer(provider, port = null) { - const existing = activeServers.get(provider); - - if (existing) { - try { - // 1. 使用 Promise.race() 添加 2 秒超时 - const closePromise = new Promise((resolve, reject) => { - existing.server.close((err) => { - if (err) reject(err); - else resolve(); - }); - }); - - const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => reject(new Error('Server close timeout after 2s')), 2000); - }); - - await Promise.race([closePromise, timeoutPromise]); - logger.info(`[Codex Auth] ${provider} server closed successfully`); - } catch (error) { - // 2. try-catch 捕获错误 - logger.warn(`[Codex Auth] Server close failed or timed out: ${error.message}`); - } finally { - // 3. finally 块强制清理,防止阻塞 - activeServers.delete(provider); - } - } - - if (port) { - for (const [p, info] of activeServers.entries()) { - if (info.port === port) { - // 递归调用处理端口冲突的情况 - await closeActiveServer(p); - } - } - } -} - -/** - * Codex OAuth 认证类 - * 实现 OAuth2 + PKCE 流程 - */ -class CodexAuth { - constructor(config) { - this.config = config; - - // 配置代理支持 - const axiosConfig = { timeout: 30000 }; - const proxyConfig = getProxyConfigForProvider(config, 'openai-codex-oauth'); - if (proxyConfig) { - axiosConfig.httpAgent = proxyConfig.httpAgent; - axiosConfig.httpsAgent = proxyConfig.httpsAgent; - logger.info('[Codex Auth] Proxy enabled for OAuth requests'); - } - - this.httpClient = axios.create(axiosConfig); - this.server = null; // 存储服务器实例 - } - - /** - * 生成 PKCE 代码 - * @returns {{verifier: string, challenge: string}} - */ - generatePKCECodes() { - // 生成 code verifier (96 随机字节 → 128 base64url 字符) - const verifier = crypto.randomBytes(96) - .toString('base64url'); - - // 生成 code challenge (SHA256 of verifier) - const challenge = crypto.createHash('sha256') - .update(verifier) - .digest('base64url'); - - return { verifier, challenge }; - } - - /** - * 生成授权 URL(不启动完整流程) - * @returns {{authUrl: string, state: string, pkce: Object, server: Object}} - */ - async generateAuthUrl() { - const pkce = this.generatePKCECodes(); - const state = crypto.randomBytes(16).toString('hex'); - - logger.info(`${CODEX_OAUTH_CONFIG.logPrefix} Generating auth URL...`); - - // 启动本地回调服务器 - const server = await this.startCallbackServer(); - this.server = server; - - // 构建授权 URL - const authUrl = new URL(CODEX_OAUTH_CONFIG.authUrl); - authUrl.searchParams.set('client_id', CODEX_OAUTH_CONFIG.clientId); - authUrl.searchParams.set('response_type', 'code'); - authUrl.searchParams.set('redirect_uri', CODEX_OAUTH_CONFIG.redirectUri); - authUrl.searchParams.set('scope', CODEX_OAUTH_CONFIG.scopes); - authUrl.searchParams.set('state', state); - authUrl.searchParams.set('code_challenge', pkce.challenge); - authUrl.searchParams.set('code_challenge_method', 'S256'); - authUrl.searchParams.set('prompt', 'login'); - authUrl.searchParams.set('id_token_add_organizations', 'true'); - authUrl.searchParams.set('codex_cli_simplified_flow', 'true'); - - return { - authUrl: authUrl.toString(), - state, - pkce, - server - }; - } - - /** - * 完成 OAuth 流程(在收到回调后调用) - * @param {string} code - 授权码 - * @param {string} state - 状态参数 - * @param {string} expectedState - 期望的状态参数 - * @param {Object} pkce - PKCE 代码 - * @returns {Promise} tokens 和凭据路径 - */ - async completeOAuthFlow(code, state, expectedState, pkce) { - // 验证 state - if (state !== expectedState) { - throw new Error('State mismatch - possible CSRF attack'); - } - - // 用 code 换取 tokens - const tokens = await this.exchangeCodeForTokens(code, pkce.verifier); - - // 解析 JWT 提取账户信息 - const claims = this.parseJWT(tokens.id_token); - - // 保存凭据(遵循 CLIProxyAPI 格式) - const credentials = { - id_token: tokens.id_token, - access_token: tokens.access_token, - refresh_token: tokens.refresh_token, - account_id: claims['https://api.openai.com/auth']?.chatgpt_account_id || claims.sub, - last_refresh: new Date().toISOString(), - email: claims.email, - type: 'codex', - expired: new Date(Date.now() + (tokens.expires_in || 3600) * 1000).toISOString() - }; - - // 保存凭据并获取路径 - const saveResult = await this.saveCredentials(credentials); - const credPath = saveResult.credsPath; - const relativePath = saveResult.relativePath; - - logger.info(`${CODEX_OAUTH_CONFIG.logPrefix} Authentication successful!`); - logger.info(`${CODEX_OAUTH_CONFIG.logPrefix} Email: ${credentials.email}`); - logger.info(`${CODEX_OAUTH_CONFIG.logPrefix} Account ID: ${credentials.account_id}`); - - // 关闭服务器 - if (this.server) { - this.server.close(); - this.server = null; - } - - return { - ...credentials, - credPath, - relativePath - }; - } - - /** - * 启动 OAuth 流程 - * @returns {Promise} 返回 tokens - */ - async startOAuthFlow() { - const pkce = this.generatePKCECodes(); - const state = crypto.randomBytes(16).toString('hex'); - - logger.info(`${CODEX_OAUTH_CONFIG.logPrefix} Starting OAuth flow...`); - - // 启动本地回调服务器 - const server = await this.startCallbackServer(); - - // 构建授权 URL - const authUrl = new URL(CODEX_OAUTH_CONFIG.authUrl); - authUrl.searchParams.set('client_id', CODEX_OAUTH_CONFIG.clientId); - authUrl.searchParams.set('response_type', 'code'); - authUrl.searchParams.set('redirect_uri', CODEX_OAUTH_CONFIG.redirectUri); - authUrl.searchParams.set('scope', CODEX_OAUTH_CONFIG.scopes); - authUrl.searchParams.set('state', state); - authUrl.searchParams.set('code_challenge', pkce.challenge); - authUrl.searchParams.set('code_challenge_method', 'S256'); - authUrl.searchParams.set('prompt', 'login'); - authUrl.searchParams.set('id_token_add_organizations', 'true'); - authUrl.searchParams.set('codex_cli_simplified_flow', 'true'); - - logger.info(`${CODEX_OAUTH_CONFIG.logPrefix} Opening browser for authentication...`); - logger.info(`${CODEX_OAUTH_CONFIG.logPrefix} If browser doesn't open, visit: ${authUrl.toString()}`); - - try { - await open(authUrl.toString()); - } catch (error) { - logger.warn(`${CODEX_OAUTH_CONFIG.logPrefix} Failed to open browser automatically:`, error.message); - } - - // 等待回调 - const result = await this.waitForCallback(server, state); - - // 用 code 换取 tokens - const tokens = await this.exchangeCodeForTokens(result.code, pkce.verifier); - - // 解析 JWT 提取账户信息 - const claims = this.parseJWT(tokens.id_token); - - // 保存凭据(遵循 CLIProxyAPI 格式) - const credentials = { - id_token: tokens.id_token, - access_token: tokens.access_token, - refresh_token: tokens.refresh_token, - account_id: claims['https://api.openai.com/auth']?.chatgpt_account_id || claims.sub, - last_refresh: new Date().toISOString(), - email: claims.email, - type: 'codex', - expired: new Date(Date.now() + (tokens.expires_in || 3600) * 1000).toISOString() - }; - - await this.saveCredentials(credentials); - - logger.info(`${CODEX_OAUTH_CONFIG.logPrefix} Authentication successful!`); - logger.info(`${CODEX_OAUTH_CONFIG.logPrefix} Email: ${credentials.email}`); - logger.info(`${CODEX_OAUTH_CONFIG.logPrefix} Account ID: ${credentials.account_id}`); - - return credentials; - } - - /** - * 启动回调服务器 - * @returns {Promise} - */ - async startCallbackServer() { - // 先清理该提供商或该端口的旧服务器 - await closeActiveServer('openai-codex-oauth', CODEX_OAUTH_CONFIG.port); - - return new Promise((resolve, reject) => { - const server = http.createServer(); - - server.on('request', (req, res) => { - if (req.url.startsWith('/auth/callback')) { - const url = new URL(req.url, `http://localhost:${CODEX_OAUTH_CONFIG.port}`); - const code = url.searchParams.get('code'); - const state = url.searchParams.get('state'); - const error = url.searchParams.get('error'); - const errorDescription = url.searchParams.get('error_description'); - - if (error) { - res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(` - - - - Authentication Failed - - - -

❌ Authentication Failed

-

${errorDescription || error}

-

You can close this window and try again.

- - - `); - server.emit('auth-error', new Error(errorDescription || error)); - } else if (code && state) { - res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(` - - - - Authentication Successful - - - - -

✅ Authentication Successful!

-

You can now close this window and return to the application.

-

This window will close automatically in 10 seconds.

- - - `); - server.emit('auth-success', { code, state }); - } - } else if (req.url === '/success') { - res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end('

Success!

'); - } - }); - - server.listen(CODEX_OAUTH_CONFIG.port, () => { - logger.info(`${CODEX_OAUTH_CONFIG.logPrefix} Callback server listening on port ${CODEX_OAUTH_CONFIG.port}`); - activeServers.set('openai-codex-oauth', { server, port: CODEX_OAUTH_CONFIG.port }); - resolve(server); - }); - - server.on('error', (error) => { - if (error.code === 'EADDRINUSE') { - reject(new Error(`Port ${CODEX_OAUTH_CONFIG.port} is already in use. Please close other applications using this port.`)); - } else { - reject(error); - } - }); - }); - } - - /** - * 等待 OAuth 回调 - * @param {http.Server} server - * @param {string} expectedState - * @returns {Promise<{code: string, state: string}>} - */ - async waitForCallback(server, expectedState) { - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - server.close(); - reject(new Error('Authentication timeout (10 minutes)')); - }, 10 * 60 * 1000); // 10 分钟 - - server.once('auth-success', (result) => { - clearTimeout(timeout); - server.close(); - - if (result.state !== expectedState) { - reject(new Error('State mismatch - possible CSRF attack')); - } else { - resolve(result); - } - }); - - server.once('auth-error', (error) => { - clearTimeout(timeout); - server.close(); - reject(error); - }); - }); - } - - /** - * 用授权码换取 tokens - * @param {string} code - * @param {string} codeVerifier - * @returns {Promise} - */ - async exchangeCodeForTokens(code, codeVerifier) { - logger.info(`${CODEX_OAUTH_CONFIG.logPrefix} Exchanging authorization code for tokens...`); - - try { - const response = await this.httpClient.post( - CODEX_OAUTH_CONFIG.tokenUrl, - new URLSearchParams({ - grant_type: 'authorization_code', - client_id: CODEX_OAUTH_CONFIG.clientId, - code: code, - redirect_uri: CODEX_OAUTH_CONFIG.redirectUri, - code_verifier: codeVerifier - }).toString(), - { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Accept': 'application/json' - } - } - ); - - return response.data; - } catch (error) { - logger.error(`${CODEX_OAUTH_CONFIG.logPrefix} Token exchange failed:`, error.response?.data || error.message); - throw new Error(`Failed to exchange code for tokens: ${error.response?.data?.error_description || error.message}`); - } - } - - /** - * 刷新 tokens - * @param {string} refreshToken - * @returns {Promise} - */ - async refreshTokens(refreshToken) { - logger.info(`${CODEX_OAUTH_CONFIG.logPrefix} Refreshing access token...`); - - try { - const response = await this.httpClient.post( - CODEX_OAUTH_CONFIG.tokenUrl, - new URLSearchParams({ - grant_type: 'refresh_token', - client_id: CODEX_OAUTH_CONFIG.clientId, - refresh_token: refreshToken - }).toString(), - { - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Accept': 'application/json' - } - } - ); - - const tokens = response.data; - const claims = this.parseJWT(tokens.id_token); - - return { - id_token: tokens.id_token, - access_token: tokens.access_token, - refresh_token: tokens.refresh_token || refreshToken, - account_id: claims['https://api.openai.com/auth']?.chatgpt_account_id || claims.sub, - last_refresh: new Date().toISOString(), - email: claims.email, - type: 'codex', - expired: new Date(Date.now() + (tokens.expires_in || 3600) * 1000).toISOString() - }; - } catch (error) { - logger.error(`${CODEX_OAUTH_CONFIG.logPrefix} Token refresh failed:`, error.response?.data || error.message); - throw new Error(`Failed to refresh tokens: ${error.response?.data?.error_description || error.message}`); - } - } - - /** - * 解析 JWT token - * @param {string} token - * @returns {Object} - */ - parseJWT(token) { - try { - const parts = token.split('.'); - if (parts.length !== 3) { - throw new Error('Invalid JWT token format'); - } - - // 解码 payload (base64url) - const payload = Buffer.from(parts[1], 'base64url').toString('utf8'); - return JSON.parse(payload); - } catch (error) { - logger.error(`${CODEX_OAUTH_CONFIG.logPrefix} Failed to parse JWT:`, error.message); - throw new Error(`Failed to parse JWT token: ${error.message}`); - } - } - - /** - * 保存凭据到文件 - * @param {Object} creds - * @returns {Promise} - */ - async saveCredentials(creds) { - const email = creds.email || this.config.CODEX_EMAIL || 'default'; - - // 优先使用配置中指定的路径,否则保存到 configs/codex 目录 - let credsPath; - if (this.config.CODEX_OAUTH_CREDS_FILE_PATH) { - credsPath = this.config.CODEX_OAUTH_CREDS_FILE_PATH; - } else { - // 保存到 configs/codex 目录(与其他供应商一致) - const projectDir = process.cwd(); - const targetDir = path.join(projectDir, 'configs', 'codex'); - await fs.promises.mkdir(targetDir, { recursive: true }); - const timestamp = Date.now(); - const filename = `${timestamp}_codex-${email}.json`; - credsPath = path.join(targetDir, filename); - } - - try { - const credsDir = path.dirname(credsPath); - await fs.promises.mkdir(credsDir, { recursive: true }); - await fs.promises.writeFile(credsPath, JSON.stringify(creds, null, 2), { mode: 0o600 }); - - const relativePath = path.relative(process.cwd(), credsPath); - logger.info(`${CODEX_OAUTH_CONFIG.logPrefix} Credentials saved to ${relativePath}`); - - // 返回保存路径供后续使用 - return { credsPath, relativePath }; - } catch (error) { - logger.error(`${CODEX_OAUTH_CONFIG.logPrefix} Failed to save credentials:`, error.message); - throw new Error(`Failed to save credentials: ${error.message}`); - } - } - - /** - * 加载凭据 - * @param {string} email - * @returns {Promise} - */ - async loadCredentials(email) { - // 优先使用配置中指定的路径,否则从 configs/codex 目录加载 - let credsPath; - if (this.config.CODEX_OAUTH_CREDS_FILE_PATH) { - credsPath = this.config.CODEX_OAUTH_CREDS_FILE_PATH; - } else { - // 从 configs/codex 目录加载(与其他供应商一致) - const projectDir = process.cwd(); - const targetDir = path.join(projectDir, 'configs', 'codex'); - - // 扫描目录找到匹配的凭据文件 - try { - const files = await fs.promises.readdir(targetDir); - const emailPattern = email || 'default'; - const matchingFile = files - .filter(f => f.includes(`codex-${emailPattern}`) && f.endsWith('.json')) - .sort() - .pop(); // 获取最新的文件 - - if (matchingFile) { - credsPath = path.join(targetDir, matchingFile); - } else { - return null; - } - } catch (error) { - if (error.code === 'ENOENT') { - return null; - } - throw error; - } - } - - try { - const data = await fs.promises.readFile(credsPath, 'utf8'); - return JSON.parse(data); - } catch (error) { - if (error.code === 'ENOENT') { - return null; // 文件不存在 - } - throw error; - } - } - - /** - * 检查凭据文件是否存在 - * @param {string} email - * @returns {Promise} - */ - async credentialsExist(email) { - // 优先使用配置中指定的路径,否则从 configs/codex 目录检查 - let credsPath; - if (this.config.CODEX_OAUTH_CREDS_FILE_PATH) { - credsPath = this.config.CODEX_OAUTH_CREDS_FILE_PATH; - } else { - const projectDir = process.cwd(); - const targetDir = path.join(projectDir, 'configs', 'codex'); - - try { - const files = await fs.promises.readdir(targetDir); - const emailPattern = email || 'default'; - const hasMatch = files.some(f => - f.includes(`codex-${emailPattern}`) && f.endsWith('.json') - ); - return hasMatch; - } catch (error) { - return false; - } - } - - try { - await fs.promises.access(credsPath); - return true; - } catch { - return false; - } - } - - /** - * 检查凭据是否已存在(基于 account_id 或 refresh_token) - * @param {string} accountId - * @param {string} refreshToken - * @returns {Promise<{isDuplicate: boolean, existingPath?: string}>} - */ - async checkDuplicate(accountId, refreshToken) { - const projectDir = process.cwd(); - const targetDir = path.join(projectDir, 'configs', 'codex'); - - try { - if (!fs.existsSync(targetDir)) { - return { isDuplicate: false }; - } - - const files = await fs.promises.readdir(targetDir); - for (const file of files) { - if (file.endsWith('.json')) { - try { - const fullPath = path.join(targetDir, file); - const content = await fs.promises.readFile(fullPath, 'utf8'); - const credentials = JSON.parse(content); - - if ((accountId && credentials.account_id === accountId) || (refreshToken && credentials.refresh_token === refreshToken)) { - const relativePath = path.relative(process.cwd(), fullPath); - return { - isDuplicate: true, - existingPath: relativePath - }; - } - } catch (e) { - // 忽略解析错误 - } - } - } - return { isDuplicate: false }; - } catch (error) { - logger.warn(`${CODEX_OAUTH_CONFIG.logPrefix} Error checking duplicates:`, error.message); - return { isDuplicate: false }; - } - } -} - -/** - * 批量导入 Codex Token 并生成凭据文件(流式版本) - * @param {Object[]} tokens - Token 对象数组 - * @param {Function} onProgress - 进度回调函数 - * @param {boolean} skipDuplicateCheck - 是否跳过重复检查 - * @returns {Promise} 批量处理结果 - */ -export async function batchImportCodexTokensStream(tokens, onProgress = null, skipDuplicateCheck = false) { - const auth = new CodexAuth({}); - const results = { - total: tokens.length, - success: 0, - failed: 0, - details: [] - }; - - for (let i = 0; i < tokens.length; i++) { - const tokenData = tokens[i]; - const progressData = { - index: i + 1, - total: tokens.length, - current: null - }; - - try { - // 验证 token 数据 - if (!tokenData.access_token || !tokenData.id_token) { - throw new Error('Token 缺少必需字段 (access_token 或 id_token)'); - } - - // 解析 JWT 提取账户信息 - const claims = auth.parseJWT(tokenData.id_token); - const accountId = claims['https://api.openai.com/auth']?.chatgpt_account_id || claims.sub; - const email = claims.email; - - // 检查重复 - if (!skipDuplicateCheck) { - const duplicateCheck = await auth.checkDuplicate(accountId, tokenData.refresh_token); - if (duplicateCheck.isDuplicate) { - progressData.current = { - index: i + 1, - success: false, - error: 'duplicate', - existingPath: duplicateCheck.existingPath - }; - results.failed++; - results.details.push(progressData.current); - if (onProgress) { - onProgress({ - ...progressData, - successCount: results.success, - failedCount: results.failed - }); - } - continue; - } - } - - // 构建凭据对象 - const credentials = { - id_token: tokenData.id_token, - access_token: tokenData.access_token, - refresh_token: tokenData.refresh_token, - account_id: accountId, - last_refresh: new Date().toISOString(), - email: email, - type: 'codex', - expired: new Date(Date.now() + (tokenData.expires_in || 3600) * 1000).toISOString() - }; - - // 保存凭据 - const saveResult = await auth.saveCredentials(credentials); - const relativePath = saveResult.relativePath; - - logger.info(`${CODEX_OAUTH_CONFIG.logPrefix} Token ${i + 1} imported: ${relativePath}`); - - progressData.current = { - index: i + 1, - success: true, - path: relativePath - }; - results.success++; - - // 自动关联到 Pools - await autoLinkProviderConfigs(CONFIG, { - onlyCurrentCred: true, - credPath: relativePath - }); - - } catch (error) { - logger.error(`${CODEX_OAUTH_CONFIG.logPrefix} Token ${i + 1} import failed:`, error.message); - - progressData.current = { - index: i + 1, - success: false, - error: error.message - }; - results.failed++; - } - - results.details.push(progressData.current); - - if (onProgress) { - onProgress({ - ...progressData, - successCount: results.success, - failedCount: results.failed - }); - } - } - - if (results.success > 0) { - broadcastEvent('oauth_batch_success', { - provider: 'openai-codex-oauth', - count: results.success, - timestamp: new Date().toISOString() - }); - } - - return results; -} - -/** - * 带重试的 Codex token 刷新 - * @param {string} refreshToken - * @param {Object} config - * @param {number} maxRetries - * @returns {Promise} - */ -export async function refreshCodexTokensWithRetry(refreshToken, config = {}, maxRetries = 3) { - const auth = new CodexAuth(config); - let lastError; - - for (let i = 0; i < maxRetries; i++) { - try { - return await auth.refreshTokens(refreshToken); - } catch (error) { - lastError = error; - logger.warn(`${CODEX_OAUTH_CONFIG.logPrefix} Retry ${i + 1}/${maxRetries} failed:`, error.message); - - if (i < maxRetries - 1) { - // 指数退避 - const delay = Math.min(1000 * Math.pow(2, i), 10000); - await new Promise(resolve => setTimeout(resolve, delay)); - } - } - } - - throw lastError; -} - -/** - * 处理 Codex OAuth 认证 - * @param {Object} currentConfig - 当前配置 - * @param {Object} options - 选项 - * @returns {Promise} 返回认证结果 - */ -export async function handleCodexOAuth(currentConfig, options = {}) { - const auth = new CodexAuth(currentConfig); - - try { - logger.info('[Codex Auth] Generating OAuth URL...'); - - // 清理所有旧的会话和服务器 - if (global.codexOAuthSessions && global.codexOAuthSessions.size > 0) { - logger.info('[Codex Auth] Cleaning up old OAuth sessions...'); - for (const [sessionId, session] of global.codexOAuthSessions.entries()) { - try { - // 清理定时器 - if (session.pollTimer) { - clearInterval(session.pollTimer); - } - // 不在这里显式关闭 server,由 startCallbackServer 中的 closeActiveServer 处理 - global.codexOAuthSessions.delete(sessionId); - } catch (error) { - logger.warn(`[Codex Auth] Failed to clean up session ${sessionId}:`, error.message); - } - } - } - - // 生成授权 URL 和启动回调服务器 - const { authUrl, state, pkce, server } = await auth.generateAuthUrl(); - - logger.info('[Codex Auth] OAuth URL generated successfully'); - - // 存储 OAuth 会话信息,供后续回调使用 - if (!global.codexOAuthSessions) { - global.codexOAuthSessions = new Map(); - } - - const sessionId = state; // 使用 state 作为 session ID - - // 轮询计数器 - let pollCount = 0; - const maxPollCount = 100; // 增加到约 5 分钟 (100 * 3s = 300s) - const pollInterval = 3000; // 轮询间隔(毫秒) - let pollTimer = null; - let isCompleted = false; - - // 创建会话对象 - const session = { - auth, - state, - pkce, - server, - pollTimer: null, - createdAt: Date.now() - }; - - global.codexOAuthSessions.set(sessionId, session); - - // 启动轮询日志 - pollTimer = setInterval(() => { - pollCount++; - if (pollCount <= maxPollCount && !isCompleted) { - logger.info(`[Codex Auth] Waiting for callback... (${pollCount}/${maxPollCount})`); - } - - if (pollCount >= maxPollCount && !isCompleted) { - clearInterval(pollTimer); - const totalSeconds = (maxPollCount * pollInterval) / 1000; - logger.info(`[Codex Auth] Polling timeout (${totalSeconds}s), releasing session for next authorization`); - - // 清理会话 - if (global.codexOAuthSessions.has(sessionId)) { - global.codexOAuthSessions.delete(sessionId); - } - } - }, pollInterval); - - // 将 pollTimer 存储到会话中 - session.pollTimer = pollTimer; - - // 监听回调服务器的 auth-success 事件,自动完成 OAuth 流程 - server.once('auth-success', async (result) => { - isCompleted = true; - if (pollTimer) { - clearInterval(pollTimer); - } - - try { - logger.info('[Codex Auth] Received auth callback, completing OAuth flow...'); - - const session = global.codexOAuthSessions.get(sessionId); - if (!session) { - logger.error('[Codex Auth] Session not found'); - return; - } - - // 完成 OAuth 流程 - const credentials = await auth.completeOAuthFlow(result.code, result.state, session.state, session.pkce); - - // 清理会话 - global.codexOAuthSessions.delete(sessionId); - - // 广播认证成功事件 - broadcastEvent('oauth_success', { - provider: 'openai-codex-oauth', - credPath: credentials.credPath, - relativePath: credentials.relativePath, - timestamp: new Date().toISOString(), - email: credentials.email, - accountId: credentials.account_id - }); - - // 自动关联新生成的凭据到 Pools - await autoLinkProviderConfigs(CONFIG, { - onlyCurrentCred: true, - credPath: credentials.relativePath - }); - - logger.info('[Codex Auth] OAuth flow completed successfully'); - } catch (error) { - logger.error('[Codex Auth] Failed to complete OAuth flow:', error.message); - - // 广播认证失败事件 - broadcastEvent('oauth_error', { - provider: 'openai-codex-oauth', - error: error.message, - timestamp: new Date().toISOString() - }); - } - }); - - // 监听 auth-error 事件 - server.once('auth-error', (error) => { - isCompleted = true; - if (pollTimer) { - clearInterval(pollTimer); - } - - logger.error('[Codex Auth] Auth error:', error.message); - global.codexOAuthSessions.delete(sessionId); - - broadcastEvent('oauth_error', { - provider: 'openai-codex-oauth', - error: error.message, - timestamp: new Date().toISOString() - }); - }); - - return { - success: true, - authUrl: authUrl, - authInfo: { - provider: 'openai-codex-oauth', - method: 'oauth2-pkce', - sessionId: sessionId, - redirectUri: CODEX_OAUTH_CONFIG.redirectUri, - port: CODEX_OAUTH_CONFIG.port, - instructions: [ - '1. 点击下方按钮在浏览器中打开授权链接', - '2. 使用您的 OpenAI 账户登录', - '3. 授权应用访问您的 Codex API', - '4. 授权成功后会自动保存凭据', - '5. 如果浏览器未自动跳转,请手动复制回调 URL' - ] - } - }; - } catch (error) { - logger.error('[Codex Auth] Failed to generate OAuth URL:', error.message); - - return { - success: false, - error: error.message, - authInfo: { - provider: 'openai-codex-oauth', - method: 'oauth2-pkce', - instructions: [ - `1. 确保端口 ${CODEX_OAUTH_CONFIG.port} 未被占用`, - '2. 确保可以访问 auth.openai.com', - '3. 确保浏览器可以正常打开', - '4. 如果问题持续,请检查网络连接' - ] - } - }; - } -} - -/** - * 处理 Codex OAuth 回调 - * @param {string} code - 授权码 - * @param {string} state - 状态参数 - * @returns {Promise} 返回认证结果 - */ -export async function handleCodexOAuthCallback(code, state) { - try { - if (!global.codexOAuthSessions || !global.codexOAuthSessions.has(state)) { - throw new Error('Invalid or expired OAuth session'); - } - - const session = global.codexOAuthSessions.get(state); - const { auth, state: expectedState, pkce } = session; - - logger.info('[Codex Auth] Processing OAuth callback...'); - - // 完成 OAuth 流程 - const result = await auth.completeOAuthFlow(code, state, expectedState, pkce); - - // 清理会话 - global.codexOAuthSessions.delete(state); - - // 广播认证成功事件(与 gemini 格式一致) - broadcastEvent('oauth_success', { - provider: 'openai-codex-oauth', - credPath: result.credPath, - relativePath: result.relativePath, - timestamp: new Date().toISOString(), - email: result.email, - accountId: result.account_id - }); - - // 自动关联新生成的凭据到 Pools - await autoLinkProviderConfigs(CONFIG, { - onlyCurrentCred: true, - credPath: result.relativePath - }); - - logger.info('[Codex Auth] OAuth callback processed successfully'); - - return { - success: true, - message: 'Codex authentication successful', - credentials: result, - email: result.email, - accountId: result.account_id, - credPath: result.credPath, - relativePath: result.relativePath - }; - } catch (error) { - logger.error('[Codex Auth] OAuth callback failed:', error.message); - - // 广播认证失败事件 - broadcastEvent('oauth_error', { - provider: 'openai-codex-oauth', - error: error.message, - timestamp: new Date().toISOString() - }); - - return { - success: false, - error: error.message - }; - } -} \ No newline at end of file diff --git a/src/auth/gemini-oauth.js b/src/auth/gemini-oauth.js deleted file mode 100644 index e7896bb2bd67a9cc7b84a9ed63ebfd810c64998e..0000000000000000000000000000000000000000 --- a/src/auth/gemini-oauth.js +++ /dev/null @@ -1,504 +0,0 @@ -import { OAuth2Client } from 'google-auth-library'; -import logger from '../utils/logger.js'; -import http from 'http'; -import fs from 'fs'; -import path from 'path'; -import os from 'os'; -import { broadcastEvent } from '../services/ui-manager.js'; -import { autoLinkProviderConfigs } from '../services/service-manager.js'; -import { CONFIG } from '../core/config-manager.js'; -import { getGoogleAuthProxyConfig } from '../utils/proxy-utils.js'; - -/** - * OAuth 提供商配置 - */ -const OAUTH_PROVIDERS = { - 'gemini-cli-oauth': { - clientId: '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com', - clientSecret: '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', - port: 8085, - credentialsDir: '.gemini', - credentialsFile: 'oauth_creds.json', - scope: ['https://www.googleapis.com/auth/cloud-platform'], - logPrefix: '[Gemini Auth]' - }, - 'gemini-antigravity': { - clientId: '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com', - clientSecret: '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', - port: 8086, - credentialsDir: '.antigravity', - credentialsFile: 'oauth_creds.json', - scope: ['https://www.googleapis.com/auth/cloud-platform'], - logPrefix: '[Antigravity Auth]' - } -}; - -/** - * 活动的服务器实例管理 - */ -const activeServers = new Map(); - -/** - * 生成 HTML 响应页面 - * @param {boolean} isSuccess - 是否成功 - * @param {string} message - 显示消息 - * @returns {string} HTML 内容 - */ -function generateResponsePage(isSuccess, message) { - const title = isSuccess ? '授权成功!' : '授权失败'; - - return ` - - - - - ${title} - - -
-

${title}

-

${message}

-
- -`; -} - -/** - * 关闭指定端口的活动服务器 - * @param {number} port - 端口号 - * @returns {Promise} - */ -async function closeActiveServer(provider, port = null) { - // 1. 关闭该提供商之前的所有服务器 - const existing = activeServers.get(provider); - if (existing) { - // 清理轮询定时器 - if (existing.pollTimer) { - clearInterval(existing.pollTimer); - existing.pollTimer = null; - } - - try { - const closePromise = new Promise((resolve, reject) => { - existing.server.close((err) => { - if (err) reject(err); - else resolve(); - }); - }); - - const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => reject(new Error('Server close timeout after 2s')), 2000); - }); - - await Promise.race([closePromise, timeoutPromise]); - logger.info(`[OAuth] 已关闭提供商 ${provider} 在端口 ${existing.port} 上的旧服务器`); - } catch (error) { - logger.warn(`[OAuth] 关闭提供商 ${provider} 服务器失败或超时: ${error.message}`); - } finally { - activeServers.delete(provider); - } - } - - // 2. 如果指定了端口,检查是否有其他提供商占用了该端口 - if (port) { - for (const [p, info] of activeServers.entries()) { - if (info.port === port) { - await closeActiveServer(p); - } - } - } -} - -/** - * 创建 OAuth 回调服务器 - * @param {Object} config - OAuth 提供商配置 - * @param {string} redirectUri - 重定向 URI - * @param {OAuth2Client} authClient - OAuth2 客户端 - * @param {string} credPath - 凭据保存路径 - * @param {string} provider - 提供商标识 - * @returns {Promise} HTTP 服务器实例 - */ -async function createOAuthCallbackServer(config, redirectUri, authClient, credPath, provider, options = {}) { - const port = parseInt(options.port) || config.port; - // 先关闭该提供商之前可能运行的所有服务器,或该端口上的旧服务器 - await closeActiveServer(provider, port); - - return new Promise((resolve, reject) => { - let pollCount = 0; - const maxPollCount = 100; // 约 5 分钟 (100 * 3s = 300s) - const pollInterval = 3000; - let pollTimer = null; - - const clearPollTimer = () => { - if (pollTimer) { - clearInterval(pollTimer); - pollTimer = null; - } - }; - - const server = http.createServer(async (req, res) => { - try { - const url = new URL(req.url, redirectUri); - const code = url.searchParams.get('code'); - const errorParam = url.searchParams.get('error'); - - if (code) { - clearPollTimer(); - logger.info(`${config.logPrefix} 收到来自 Google 的成功回调: ${req.url}`); - - try { - const { tokens } = await authClient.getToken(code); - let finalCredPath = credPath; - - // 如果指定了保存到 configs 目录 - if (options.saveToConfigs) { - const providerDir = options.providerDir; - const targetDir = path.join(process.cwd(), 'configs', providerDir); - await fs.promises.mkdir(targetDir, { recursive: true }); - const timestamp = Date.now(); - const filename = `${timestamp}_oauth_creds.json`; - finalCredPath = path.join(targetDir, filename); - } - - await fs.promises.mkdir(path.dirname(finalCredPath), { recursive: true }); - await fs.promises.writeFile(finalCredPath, JSON.stringify(tokens, null, 2)); - logger.info(`${config.logPrefix} 新令牌已接收并保存到文件: ${finalCredPath}`); - - const relativePath = path.relative(process.cwd(), finalCredPath); - - // 广播授权成功事件 - broadcastEvent('oauth_success', { - provider: provider, - credPath: finalCredPath, - relativePath: relativePath, - timestamp: new Date().toISOString() - }); - - // 自动关联新生成的凭据到 Pools - await autoLinkProviderConfigs(CONFIG, { - onlyCurrentCred: true, - credPath: relativePath - }); - - res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(generateResponsePage(true, '您可以关闭此页面')); - } catch (tokenError) { - logger.error(`${config.logPrefix} 获取令牌失败:`, tokenError); - res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(generateResponsePage(false, `获取令牌失败: ${tokenError.message}`)); - } finally { - server.close(() => { - activeServers.delete(provider); - }); - } - } else if (errorParam) { - clearPollTimer(); - const errorMessage = `授权失败。Google 返回错误: ${errorParam}`; - logger.error(`${config.logPrefix}`, errorMessage); - - res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(generateResponsePage(false, errorMessage)); - server.close(() => { - activeServers.delete(provider); - }); - } else { - logger.info(`${config.logPrefix} 忽略无关请求: ${req.url}`); - res.writeHead(204); - res.end(); - } - } catch (error) { - clearPollTimer(); - logger.error(`${config.logPrefix} 处理回调时出错:`, error); - res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(generateResponsePage(false, `服务器错误: ${error.message}`)); - - if (server.listening) { - server.close(() => { - activeServers.delete(provider); - }); - } - } - }); - - server.on('error', (err) => { - clearPollTimer(); - if (err.code === 'EADDRINUSE') { - logger.error(`${config.logPrefix} 端口 ${port} 已被占用`); - reject(new Error(`端口 ${port} 已被占用`)); - } else { - logger.error(`${config.logPrefix} 服务器错误:`, err); - reject(err); - } - }); - - const host = '0.0.0.0'; - server.listen(port, host, () => { - logger.info(`${config.logPrefix} OAuth 回调服务器已启动于 ${host}:${port}`); - - // 启动轮询日志 - pollTimer = setInterval(() => { - pollCount++; - if (pollCount <= maxPollCount) { - logger.info(`${config.logPrefix} Waiting for callback... (${pollCount}/${maxPollCount})`); - } else { - clearPollTimer(); - logger.warn(`${config.logPrefix} Polling timeout, closing server...`); - if (server.listening) { - server.close(() => { - activeServers.delete(provider); - }); - } - } - }, pollInterval); - - activeServers.set(provider, { server, port, pollTimer }); - resolve(server); - }); - }); -} - -/** - * 处理 Google OAuth 授权(通用函数) - * @param {string} providerKey - 提供商键名 - * @param {Object} currentConfig - 当前配置对象 - * @param {Object} options - 额外选项 - * @returns {Promise} 返回授权URL和相关信息 - */ -async function handleGoogleOAuth(providerKey, currentConfig, options = {}) { - const config = OAUTH_PROVIDERS[providerKey]; - if (!config) { - throw new Error(`未知的提供商: ${providerKey}`); - } - - const port = parseInt(options.port) || config.port; - const host = 'localhost'; - const redirectUri = `http://${host}:${port}`; - - // 获取代理配置 - const proxyConfig = getGoogleAuthProxyConfig(currentConfig, providerKey); - - // 构建 OAuth2Client 选项 - const oauth2Options = { - clientId: config.clientId, - clientSecret: config.clientSecret, - }; - - if (proxyConfig) { - oauth2Options.transporterOptions = proxyConfig; - logger.info(`${config.logPrefix} Using proxy for OAuth token exchange`); - } - - const authClient = new OAuth2Client(oauth2Options); - authClient.redirectUri = redirectUri; - - const authUrl = authClient.generateAuthUrl({ - access_type: 'offline', - prompt: 'select_account', - scope: config.scope - }); - - // 启动回调服务器 - const credPath = path.join(os.homedir(), config.credentialsDir, config.credentialsFile); - - try { - await createOAuthCallbackServer(config, redirectUri, authClient, credPath, providerKey, options); - } catch (error) { - throw new Error(`启动回调服务器失败: ${error.message}`); - } - - return { - authUrl, - authInfo: { - provider: providerKey, - redirectUri: redirectUri, - port: port, - ...options - } - }; -} - -/** - * 处理 Gemini CLI OAuth 授权 - * @param {Object} currentConfig - 当前配置对象 - * @param {Object} options - 额外选项 - * @returns {Promise} 返回授权URL和相关信息 - */ -export async function handleGeminiCliOAuth(currentConfig, options = {}) { - return handleGoogleOAuth('gemini-cli-oauth', currentConfig, options); -} - -/** - * 处理 Gemini Antigravity OAuth 授权 - * @param {Object} currentConfig - 当前配置对象 - * @param {Object} options - 额外选项 - * @returns {Promise} 返回授权URL和相关信息 - */ -export async function handleGeminiAntigravityOAuth(currentConfig, options = {}) { - return handleGoogleOAuth('gemini-antigravity', currentConfig, options); -} - -/** - * 检查 Gemini 凭据是否已存在(基于 refresh_token) - * @param {string} providerType - 提供商类型 - * @param {string} refreshToken - 要检查的 refreshToken - * @returns {Promise<{isDuplicate: boolean, existingPath?: string}>} 检查结果 - */ -export async function checkGeminiCredentialsDuplicate(providerType, refreshToken) { - const config = OAUTH_PROVIDERS[providerType]; - if (!config) return { isDuplicate: false }; - - const providerDir = config.credentialsDir.replace('.', ''); - const targetDir = path.join(process.cwd(), 'configs', providerDir); - - try { - if (!fs.existsSync(targetDir)) { - return { isDuplicate: false }; - } - - const files = await fs.promises.readdir(targetDir); - for (const file of files) { - if (file.endsWith('.json')) { - try { - const fullPath = path.join(targetDir, file); - const content = await fs.promises.readFile(fullPath, 'utf8'); - const credentials = JSON.parse(content); - - if (credentials.refresh_token === refreshToken) { - const relativePath = path.relative(process.cwd(), fullPath); - return { - isDuplicate: true, - existingPath: relativePath - }; - } - } catch (e) { - // 忽略解析错误 - } - } - } - return { isDuplicate: false }; - } catch (error) { - logger.warn(`[Gemini Auth] Error checking duplicates for ${providerType}:`, error.message); - return { isDuplicate: false }; - } -} - -/** - * 批量导入 Gemini Token 并生成凭据文件(流式版本,支持实时进度回调) - * @param {string} providerType - 提供商类型 ('gemini-cli-oauth' 或 'gemini-antigravity') - * @param {Object[]} tokens - Token 对象数组 - * @param {Function} onProgress - 进度回调函数 - * @param {boolean} skipDuplicateCheck - 是否跳过重复检查 (默认: false) - * @returns {Promise} 批量处理结果 - */ -export async function batchImportGeminiTokensStream(providerType, tokens, onProgress = null, skipDuplicateCheck = false) { - const config = OAUTH_PROVIDERS[providerType]; - if (!config) { - throw new Error(`未知的提供商: ${providerType}`); - } - - const results = { - total: tokens.length, - success: 0, - failed: 0, - details: [] - }; - - for (let i = 0; i < tokens.length; i++) { - const token = tokens[i]; - const progressData = { - index: i + 1, - total: tokens.length, - current: null - }; - - try { - // 验证 token 是否包含必需字段 (通常是 access_token 和 refresh_token) - if (!token.access_token || !token.refresh_token) { - throw new Error('Token 缺少必需字段 (access_token 或 refresh_token)'); - } - - // 检查重复 - if (!skipDuplicateCheck) { - const duplicateCheck = await checkGeminiCredentialsDuplicate(providerType, token.refresh_token); - if (duplicateCheck.isDuplicate) { - progressData.current = { - index: i + 1, - success: false, - error: 'duplicate', - existingPath: duplicateCheck.existingPath - }; - results.failed++; - results.details.push(progressData.current); - if (onProgress) { - onProgress({ - ...progressData, - successCount: results.success, - failedCount: results.failed - }); - } - continue; - } - } - - // 生成文件路径 - const timestamp = Date.now(); - const providerDir = config.credentialsDir.replace('.', ''); // 去掉开头的点 - const targetDir = path.join(process.cwd(), 'configs', providerDir); - await fs.promises.mkdir(targetDir, { recursive: true }); - - const filename = `${timestamp}_${i}_oauth_creds.json`; - const credPath = path.join(targetDir, filename); - - await fs.promises.writeFile(credPath, JSON.stringify(token, null, 2)); - - const relativePath = path.relative(process.cwd(), credPath); - - logger.info(`${config.logPrefix} Token ${i + 1} 已导入并保存: ${relativePath}`); - - progressData.current = { - index: i + 1, - success: true, - path: relativePath - }; - results.success++; - - // 自动关联新生成的凭据到 Pools - await autoLinkProviderConfigs(CONFIG, { - onlyCurrentCred: true, - credPath: relativePath - }); - - } catch (error) { - logger.error(`${config.logPrefix} Token ${i + 1} 导入失败:`, error.message); - - progressData.current = { - index: i + 1, - success: false, - error: error.message - }; - results.failed++; - } - - results.details.push(progressData.current); - - // 发送进度更新 - if (onProgress) { - onProgress({ - ...progressData, - successCount: results.success, - failedCount: results.failed - }); - } - } - - // 如果有成功的,广播事件 - if (results.success > 0) { - broadcastEvent('oauth_batch_success', { - provider: providerType, - count: results.success, - timestamp: new Date().toISOString() - }); - } - - return results; -} diff --git a/src/auth/iflow-oauth.js b/src/auth/iflow-oauth.js deleted file mode 100644 index c88262484ccff03dc50c9ff727ecebe511fd6595..0000000000000000000000000000000000000000 --- a/src/auth/iflow-oauth.js +++ /dev/null @@ -1,539 +0,0 @@ -import http from 'http'; -import logger from '../utils/logger.js'; -import fs from 'fs'; -import path from 'path'; -import os from 'os'; -import crypto from 'crypto'; -import { broadcastEvent } from '../services/ui-manager.js'; -import { autoLinkProviderConfigs } from '../services/service-manager.js'; -import { CONFIG } from '../core/config-manager.js'; -import { getProxyConfigForProvider } from '../utils/proxy-utils.js'; - -/** - * iFlow OAuth 配置 - */ -const IFLOW_OAUTH_CONFIG = { - // OAuth 端点 - tokenEndpoint: 'https://iflow.cn/oauth/token', - authorizeEndpoint: 'https://iflow.cn/oauth', - userInfoEndpoint: 'https://iflow.cn/api/oauth/getUserInfo', - successRedirectURL: 'https://iflow.cn/oauth/success', - - // 客户端凭据 - clientId: '10009311001', - clientSecret: '4Z3YjXycVsQvyGF1etiNlIBB4RsqSDtW', - - // 本地回调端口 - callbackPort: 8087, - - // 凭据存储 - credentialsDir: '.iflow', - credentialsFile: 'oauth_creds.json', - - // 日志前缀 - logPrefix: '[iFlow Auth]' -}; - -/** - * 活动的 iFlow 回调服务器管理 - */ -const activeIFlowServers = new Map(); - -/** - * 创建带代理支持的 fetch 请求 - * 使用 axios 替代原生 fetch,以正确支持代理配置 - * @param {string} url - 请求 URL - * @param {Object} options - fetch 选项(兼容 fetch API 格式) - * @param {string} providerType - 提供商类型,用于获取代理配置 - * @returns {Promise} 返回类似 fetch Response 的对象 - */ -async function fetchWithProxy(url, options = {}, providerType) { - const proxyConfig = getProxyConfigForProvider(CONFIG, providerType); - - // 构建 axios 配置 - const axiosConfig = { - url, - method: options.method || 'GET', - headers: options.headers || {}, - timeout: 30000, // 30 秒超时 - }; - - // 处理请求体 - if (options.body) { - axiosConfig.data = options.body; - } - - // 配置代理 - if (proxyConfig) { - axiosConfig.httpAgent = proxyConfig.httpAgent; - axiosConfig.httpsAgent = proxyConfig.httpsAgent; - axiosConfig.proxy = false; // 禁用 axios 内置代理,使用我们的 agent - logger.info(`[OAuth] Using proxy for ${providerType}: ${CONFIG.PROXY_URL}`); - } - - try { - const axios = (await import('axios')).default; - const response = await axios(axiosConfig); - - // 返回类似 fetch Response 的对象 - return { - ok: response.status >= 200 && response.status < 300, - status: response.status, - statusText: response.statusText, - headers: response.headers, - json: async () => response.data, - text: async () => typeof response.data === 'string' ? response.data : JSON.stringify(response.data), - }; - } catch (error) { - // 处理 axios 错误,转换为类似 fetch 的响应格式 - if (error.response) { - // 服务器返回了错误状态码 - return { - ok: false, - status: error.response.status, - statusText: error.response.statusText, - headers: error.response.headers, - json: async () => error.response.data, - text: async () => typeof error.response.data === 'string' ? error.response.data : JSON.stringify(error.response.data), - }; - } - // 网络错误或其他错误 - throw error; - } -} - -/** - * 生成 HTML 响应页面 - * @param {boolean} isSuccess - 是否成功 - * @param {string} message - 显示消息 - * @returns {string} HTML 内容 - */ -function generateResponsePage(isSuccess, message) { - const title = isSuccess ? '授权成功!' : '授权失败'; - - return ` - - - - - ${title} - - -
-

${title}

-

${message}

-
- -`; -} - -/** - * 生成 iFlow 授权链接 - * @param {string} state - 状态参数 - * @param {number} port - 回调端口 - * @returns {Object} 包含 authUrl 和 redirectUri - */ -function generateIFlowAuthorizationURL(state, port) { - const redirectUri = `http://localhost:${port}/oauth2callback`; - const params = new URLSearchParams({ - loginMethod: 'phone', - type: 'phone', - redirect: redirectUri, - state: state, - client_id: IFLOW_OAUTH_CONFIG.clientId - }); - const authUrl = `${IFLOW_OAUTH_CONFIG.authorizeEndpoint}?${params.toString()}`; - return { authUrl, redirectUri }; -} - -/** - * 交换授权码获取 iFlow 令牌 - * @param {string} code - 授权码 - * @param {string} redirectUri - 重定向 URI - * @returns {Promise} 令牌数据 - */ -async function exchangeIFlowCodeForTokens(code, redirectUri) { - const form = new URLSearchParams({ - grant_type: 'authorization_code', - code: code, - redirect_uri: redirectUri, - client_id: IFLOW_OAUTH_CONFIG.clientId, - client_secret: IFLOW_OAUTH_CONFIG.clientSecret - }); - - // 生成 Basic Auth 头 - const basicAuth = Buffer.from(`${IFLOW_OAUTH_CONFIG.clientId}:${IFLOW_OAUTH_CONFIG.clientSecret}`).toString('base64'); - - const response = await fetchWithProxy(IFLOW_OAUTH_CONFIG.tokenEndpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Accept': 'application/json', - 'Authorization': `Basic ${basicAuth}` - }, - body: form.toString() - }, 'openai-iflow'); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`iFlow token exchange failed: ${response.status} ${errorText}`); - } - - const tokenData = await response.json(); - - if (!tokenData.access_token) { - throw new Error('iFlow token: missing access token in response'); - } - - return { - accessToken: tokenData.access_token, - refreshToken: tokenData.refresh_token, - tokenType: tokenData.token_type, - scope: tokenData.scope, - expiresIn: tokenData.expires_in, - expiresAt: new Date(Date.now() + tokenData.expires_in * 1000).toISOString() - }; -} - -/** - * 获取 iFlow 用户信息(包含 API Key) - * @param {string} accessToken - 访问令牌 - * @returns {Promise} 用户信息 - */ -async function fetchIFlowUserInfo(accessToken) { - if (!accessToken || accessToken.trim() === '') { - throw new Error('iFlow api key: access token is empty'); - } - - const endpoint = `${IFLOW_OAUTH_CONFIG.userInfoEndpoint}?accessToken=${encodeURIComponent(accessToken)}`; - - const response = await fetchWithProxy(endpoint, { - method: 'GET', - headers: { - 'Accept': 'application/json' - } - }, 'openai-iflow'); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`iFlow user info failed: ${response.status} ${errorText}`); - } - - const result = await response.json(); - - if (!result.success) { - throw new Error('iFlow api key: request not successful'); - } - - if (!result.data || !result.data.apiKey) { - throw new Error('iFlow api key: missing api key in response'); - } - - // 获取邮箱或手机号作为账户标识 - let email = (result.data.email || '').trim(); - if (!email) { - email = (result.data.phone || '').trim(); - } - if (!email) { - throw new Error('iFlow token: missing account email/phone in user info'); - } - - return { - apiKey: result.data.apiKey, - email: email, - phone: result.data.phone || '' - }; -} - -/** - * 关闭 iFlow 服务器 - * @param {string} provider - 提供商标识 - * @param {number} port - 端口号(可选) - */ -async function closeIFlowServer(provider, port = null) { - const existing = activeIFlowServers.get(provider); - if (existing) { - try { - const closePromise = new Promise((resolve, reject) => { - existing.server.close((err) => { - if (err) reject(err); - else resolve(); - }); - }); - - const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => reject(new Error('Server close timeout after 2s')), 2000); - }); - - await Promise.race([closePromise, timeoutPromise]); - logger.info(`${IFLOW_OAUTH_CONFIG.logPrefix} 已关闭提供商 ${provider} 在端口 ${existing.port} 上的旧服务器`); - } catch (error) { - logger.warn(`${IFLOW_OAUTH_CONFIG.logPrefix} 关闭提供商 ${provider} 服务器失败或超时: ${error.message}`); - } finally { - activeIFlowServers.delete(provider); - } - } - - if (port) { - for (const [p, info] of activeIFlowServers.entries()) { - if (info.port === port) { - await closeIFlowServer(p); - } - } - } -} - -/** - * 创建 iFlow OAuth 回调服务器 - * @param {number} port - 端口号 - * @param {string} redirectUri - 重定向 URI - * @param {string} expectedState - 预期的 state 参数 - * @param {Object} options - 额外选项 - * @returns {Promise} HTTP 服务器实例 - */ -function createIFlowCallbackServer(port, redirectUri, expectedState, options = {}) { - return new Promise((resolve, reject) => { - const server = http.createServer(async (req, res) => { - try { - const url = new URL(req.url, `http://localhost:${port}`); - - if (url.pathname === '/oauth2callback') { - const code = url.searchParams.get('code'); - const state = url.searchParams.get('state'); - const errorParam = url.searchParams.get('error'); - - if (errorParam) { - logger.error(`${IFLOW_OAUTH_CONFIG.logPrefix} 授权失败: ${errorParam}`); - res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(generateResponsePage(false, `授权失败: ${errorParam}`)); - server.close(() => { - activeIFlowServers.delete('openai-iflow'); - }); - return; - } - - if (state !== expectedState) { - logger.error(`${IFLOW_OAUTH_CONFIG.logPrefix} State 验证失败`); - res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(generateResponsePage(false, 'State 验证失败')); - server.close(() => { - activeIFlowServers.delete('openai-iflow'); - }); - return; - } - - if (!code) { - logger.error(`${IFLOW_OAUTH_CONFIG.logPrefix} 缺少授权码`); - res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(generateResponsePage(false, '缺少授权码')); - server.close(() => { - activeIFlowServers.delete('openai-iflow'); - }); - return; - } - - logger.info(`${IFLOW_OAUTH_CONFIG.logPrefix} 收到授权回调,正在交换令牌...`); - - try { - // 1. 交换授权码获取令牌 - const tokenData = await exchangeIFlowCodeForTokens(code, redirectUri); - logger.info(`${IFLOW_OAUTH_CONFIG.logPrefix} 令牌交换成功`); - - // 2. 获取用户信息(包含 API Key) - const userInfo = await fetchIFlowUserInfo(tokenData.accessToken); - logger.info(`${IFLOW_OAUTH_CONFIG.logPrefix} 用户信息获取成功: ${userInfo.email}`); - - // 3. 组合完整的凭据数据 - const credentialsData = { - access_token: tokenData.accessToken, - refresh_token: tokenData.refreshToken, - expiry_date: new Date(tokenData.expiresAt).getTime(), - token_type: tokenData.tokenType, - scope: tokenData.scope, - apiKey: userInfo.apiKey - }; - - // 4. 保存凭据 - let credPath = path.join(os.homedir(), IFLOW_OAUTH_CONFIG.credentialsDir, IFLOW_OAUTH_CONFIG.credentialsFile); - - if (options.saveToConfigs) { - const providerDir = options.providerDir || 'iflow'; - const targetDir = path.join(process.cwd(), 'configs', providerDir); - await fs.promises.mkdir(targetDir, { recursive: true }); - const timestamp = Date.now(); - const filename = `${timestamp}_oauth_creds.json`; - credPath = path.join(targetDir, filename); - } - - await fs.promises.mkdir(path.dirname(credPath), { recursive: true }); - await fs.promises.writeFile(credPath, JSON.stringify(credentialsData, null, 2)); - logger.info(`${IFLOW_OAUTH_CONFIG.logPrefix} 凭据已保存: ${credPath}`); - - const relativePath = path.relative(process.cwd(), credPath); - - // 5. 广播授权成功事件 - broadcastEvent('oauth_success', { - provider: 'openai-iflow', - credPath: credPath, - relativePath: relativePath, - email: userInfo.email, - timestamp: new Date().toISOString() - }); - - // 6. 自动关联新生成的凭据到 Pools - await autoLinkProviderConfigs(CONFIG, { - onlyCurrentCred: true, - credPath: relativePath - }); - - res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(generateResponsePage(true, `授权成功!账户: ${userInfo.email},您可以关闭此页面`)); - - } catch (tokenError) { - logger.error(`${IFLOW_OAUTH_CONFIG.logPrefix} 令牌处理失败:`, tokenError); - res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(generateResponsePage(false, `令牌处理失败: ${tokenError.message}`)); - } finally { - server.close(() => { - activeIFlowServers.delete('openai-iflow'); - }); - } - } else { - // 忽略其他请求 - res.writeHead(204); - res.end(); - } - } catch (error) { - logger.error(`${IFLOW_OAUTH_CONFIG.logPrefix} 处理回调出错:`, error); - res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(generateResponsePage(false, `服务器错误: ${error.message}`)); - - if (server.listening) { - server.close(() => { - activeIFlowServers.delete('openai-iflow'); - }); - } - } - }); - - server.on('error', (err) => { - if (err.code === 'EADDRINUSE') { - logger.error(`${IFLOW_OAUTH_CONFIG.logPrefix} 端口 ${port} 已被占用`); - reject(new Error(`端口 ${port} 已被占用`)); - } else { - logger.error(`${IFLOW_OAUTH_CONFIG.logPrefix} 服务器错误:`, err); - reject(err); - } - }); - - const host = '0.0.0.0'; - server.listen(port, host, () => { - logger.info(`${IFLOW_OAUTH_CONFIG.logPrefix} OAuth 回调服务器已启动于 ${host}:${port}`); - resolve(server); - }); - - // 10 分钟超时自动关闭 - setTimeout(() => { - if (server.listening) { - logger.info(`${IFLOW_OAUTH_CONFIG.logPrefix} 回调服务器超时,自动关闭`); - server.close(() => { - activeIFlowServers.delete('openai-iflow'); - }); - } - }, 10 * 60 * 1000); - }); -} - -/** - * 处理 iFlow OAuth 授权 - * @param {Object} currentConfig - 当前配置对象 - * @param {Object} options - 额外选项 - * - port: 自定义端口号 - * - saveToConfigs: 是否保存到 configs 目录 - * - providerDir: 提供商目录名 - * @returns {Promise} 返回授权URL和相关信息 - */ -export async function handleIFlowOAuth(currentConfig, options = {}) { - const port = parseInt(options.port) || IFLOW_OAUTH_CONFIG.callbackPort; - const providerKey = 'openai-iflow'; - - // 生成 state 参数 - const state = crypto.randomBytes(16).toString('base64url'); - - // 生成授权链接 - const { authUrl, redirectUri } = generateIFlowAuthorizationURL(state, port); - - logger.info(`${IFLOW_OAUTH_CONFIG.logPrefix} 生成授权链接: ${authUrl}`); - - // 关闭之前可能存在的服务器 - await closeIFlowServer(providerKey, port); - - // 启动回调服务器 - try { - const server = await createIFlowCallbackServer(port, redirectUri, state, options); - activeIFlowServers.set(providerKey, { server, port }); - } catch (error) { - throw new Error(`启动 iFlow 回调服务器失败: ${error.message}`); - } - - return { - authUrl, - authInfo: { - provider: 'openai-iflow', - redirectUri: redirectUri, - callbackPort: port, - state: state, - ...options - } - }; -} - -/** - * 使用 refresh_token 刷新 iFlow 令牌 - * @param {string} refreshToken - 刷新令牌 - * @returns {Promise} 新的令牌数据 - */ -export async function refreshIFlowTokens(refreshToken) { - const form = new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: refreshToken, - client_id: IFLOW_OAUTH_CONFIG.clientId, - client_secret: IFLOW_OAUTH_CONFIG.clientSecret - }); - - // 生成 Basic Auth 头 - const basicAuth = Buffer.from(`${IFLOW_OAUTH_CONFIG.clientId}:${IFLOW_OAUTH_CONFIG.clientSecret}`).toString('base64'); - - const response = await fetchWithProxy(IFLOW_OAUTH_CONFIG.tokenEndpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Accept': 'application/json', - 'Authorization': `Basic ${basicAuth}` - }, - body: form.toString() - }, 'openai-iflow'); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`iFlow token refresh failed: ${response.status} ${errorText}`); - } - - const tokenData = await response.json(); - - if (!tokenData.access_token) { - throw new Error('iFlow token refresh: missing access token in response'); - } - - // 获取用户信息以更新 API Key - const userInfo = await fetchIFlowUserInfo(tokenData.access_token); - - return { - access_token: tokenData.access_token, - refresh_token: tokenData.refresh_token, - expiry_date: Date.now() + tokenData.expires_in * 1000, - token_type: tokenData.token_type, - scope: tokenData.scope, - apiKey: userInfo.apiKey - }; -} \ No newline at end of file diff --git a/src/auth/index.js b/src/auth/index.js deleted file mode 100644 index dc5cfb02254efe5c7b1428b9eb17a749fadff939..0000000000000000000000000000000000000000 --- a/src/auth/index.js +++ /dev/null @@ -1,35 +0,0 @@ -// Codex OAuth -export { - refreshCodexTokensWithRetry, - handleCodexOAuth, - handleCodexOAuthCallback, - batchImportCodexTokensStream -} from './codex-oauth.js'; - -// Gemini OAuth -export { - handleGeminiCliOAuth, - handleGeminiAntigravityOAuth, - batchImportGeminiTokensStream, - checkGeminiCredentialsDuplicate -} from './gemini-oauth.js'; - -// Qwen OAuth -export { - handleQwenOAuth -} from './qwen-oauth.js'; - -// Kiro OAuth -export { - handleKiroOAuth, - checkKiroCredentialsDuplicate, - batchImportKiroRefreshTokens, - batchImportKiroRefreshTokensStream, - importAwsCredentials -} from './kiro-oauth.js'; - -// iFlow OAuth -export { - handleIFlowOAuth, - refreshIFlowTokens -} from './iflow-oauth.js'; diff --git a/src/auth/kiro-oauth.js b/src/auth/kiro-oauth.js deleted file mode 100644 index 2e1bba62bc26397e5738d2cd20074965396c2d94..0000000000000000000000000000000000000000 --- a/src/auth/kiro-oauth.js +++ /dev/null @@ -1,1149 +0,0 @@ -import http from 'http'; -import logger from '../utils/logger.js'; -import fs from 'fs'; -import path from 'path'; -import crypto from 'crypto'; -import os from 'os'; -import { broadcastEvent } from '../services/ui-manager.js'; -import { autoLinkProviderConfigs } from '../services/service-manager.js'; -import { CONFIG } from '../core/config-manager.js'; -import { getProxyConfigForProvider } from '../utils/proxy-utils.js'; - -/** - * Kiro OAuth 配置(支持多种认证方式) - */ -const KIRO_OAUTH_CONFIG = { - // Kiro Auth Service 端点 (用于 Social Auth) - authServiceEndpoint: 'https://prod.us-east-1.auth.desktop.kiro.dev', - - // AWS SSO OIDC 端点 (用于 Builder ID) - ssoOIDCEndpoint: 'https://oidc.{{region}}.amazonaws.com', - - // AWS Builder ID 起始 URL - builderIDStartURL: 'https://view.awsapps.com/start', - - // 本地回调端口范围(用于 Social Auth HTTP 回调) - callbackPortStart: 19876, - callbackPortEnd: 19880, - - // 超时配置 - authTimeout: 10 * 60 * 1000, // 10 分钟 - pollInterval: 5000, // 5 秒 - - // CodeWhisperer Scopes - scopes: [ - 'codewhisperer:completions', - 'codewhisperer:analysis', - 'codewhisperer:conversations', - // 'codewhisperer:transformations', - // 'codewhisperer:taskassist' - ], - - // 凭据存储(符合现有规范) - credentialsDir: '.kiro', - credentialsFile: 'oauth_creds.json', - - // 日志前缀 - logPrefix: '[Kiro Auth]' -}; - -/** - * 活动的 Kiro 回调服务器管理 - */ -const activeKiroServers = new Map(); - -/** - * 活动的 Kiro 轮询任务管理(用于 Builder ID Device Code) - */ -const activeKiroPollingTasks = new Map(); - -/** - * 创建带代理支持的 fetch 请求 - * 使用 axios 替代原生 fetch,以正确支持代理配置 - * @param {string} url - 请求 URL - * @param {Object} options - fetch 选项(兼容 fetch API 格式) - * @param {string} providerType - 提供商类型,用于获取代理配置 - * @returns {Promise} 返回类似 fetch Response 的对象 - */ -async function fetchWithProxy(url, options = {}, providerType) { - const proxyConfig = getProxyConfigForProvider(CONFIG, providerType); - - // 构建 axios 配置 - const axiosConfig = { - url, - method: options.method || 'GET', - headers: options.headers || {}, - timeout: 30000, // 30 秒超时 - }; - - // 处理请求体 - if (options.body) { - axiosConfig.data = options.body; - } - - // 配置代理 - if (proxyConfig) { - axiosConfig.httpAgent = proxyConfig.httpAgent; - axiosConfig.httpsAgent = proxyConfig.httpsAgent; - axiosConfig.proxy = false; // 禁用 axios 内置代理,使用我们的 agent - logger.info(`[OAuth] Using proxy for ${providerType}: ${CONFIG.PROXY_URL}`); - } - - try { - const axios = (await import('axios')).default; - const response = await axios(axiosConfig); - - // 返回类似 fetch Response 的对象 - return { - ok: response.status >= 200 && response.status < 300, - status: response.status, - statusText: response.statusText, - headers: response.headers, - json: async () => response.data, - text: async () => typeof response.data === 'string' ? response.data : JSON.stringify(response.data), - }; - } catch (error) { - // 处理 axios 错误,转换为类似 fetch 的响应格式 - if (error.response) { - // 服务器返回了错误状态码 - return { - ok: false, - status: error.response.status, - statusText: error.response.statusText, - headers: error.response.headers, - json: async () => error.response.data, - text: async () => typeof error.response.data === 'string' ? error.response.data : JSON.stringify(error.response.data), - }; - } - // 网络错误或其他错误 - throw error; - } -} - -/** - * 生成 HTML 响应页面 - * @param {boolean} isSuccess - 是否成功 - * @param {string} message - 显示消息 - * @returns {string} HTML 内容 - */ -function generateResponsePage(isSuccess, message) { - const title = isSuccess ? '授权成功!' : '授权失败'; - - return ` - - - - - ${title} - - -
-

${title}

-

${message}

-
- -`; -} - -/** - * 生成 PKCE 代码验证器 - * @returns {string} Base64URL 编码的随机字符串 - */ -function generateCodeVerifier() { - return crypto.randomBytes(32).toString('base64url'); -} - -/** - * 生成 PKCE 代码挑战 - * @param {string} codeVerifier - 代码验证器 - * @returns {string} Base64URL 编码的 SHA256 哈希 - */ -function generateCodeChallenge(codeVerifier) { - const hash = crypto.createHash('sha256'); - hash.update(codeVerifier); - return hash.digest('base64url'); -} - -/** - * 处理 Kiro OAuth 授权(统一入口) - * @param {Object} currentConfig - 当前配置对象 - * @param {Object} options - 额外选项 - * - method: 'google' | 'github' | 'builder-id' - * - saveToConfigs: boolean - * @returns {Promise} 返回授权URL和相关信息 - */ -export async function handleKiroOAuth(currentConfig, options = {}) { - const method = options.method || options.authMethod || 'google'; // 默认使用 Google,同时支持 authMethod 参数 - - logger.info(`${KIRO_OAUTH_CONFIG.logPrefix} Starting OAuth with method: ${method}`); - - switch (method) { - case 'google': - return handleKiroSocialAuth('Google', currentConfig, options); - case 'github': - return handleKiroSocialAuth('Github', currentConfig, options); - case 'builder-id': - return handleKiroBuilderIDDeviceCode(currentConfig, options); - default: - throw new Error(`不支持的认证方式: ${method}`); - } -} - -/** - * Kiro Social Auth (Google/GitHub) - 使用 HTTP localhost 回调 - */ -async function handleKiroSocialAuth(provider, currentConfig, options = {}) { - // 生成 PKCE 参数 - const codeVerifier = generateCodeVerifier(); - const codeChallenge = generateCodeChallenge(codeVerifier); - const state = crypto.randomBytes(16).toString('base64url'); - - // 启动本地回调服务器并获取端口 - let handlerPort; - const providerKey = 'claude-kiro-oauth'; - if (options.port) { - const port = parseInt(options.port); - await closeKiroServer(providerKey, port); - const server = await createKiroHttpCallbackServer(port, codeVerifier, state, options); - activeKiroServers.set(providerKey, { server, port }); - handlerPort = port; - } else { - handlerPort = await startKiroCallbackServer(codeVerifier, state, options); - } - - // 使用 HTTP localhost 作为 redirect_uri - const redirectUri = `http://127.0.0.1:${handlerPort}/oauth/callback`; - - // 构建授权 URL - const authUrl = `${KIRO_OAUTH_CONFIG.authServiceEndpoint}/login?` + - `idp=${provider}&` + - `redirect_uri=${encodeURIComponent(redirectUri)}&` + - `code_challenge=${codeChallenge}&` + - `code_challenge_method=S256&` + - `state=${state}&` + - `prompt=select_account`; - - return { - authUrl, - authInfo: { - provider: 'claude-kiro-oauth', - authMethod: 'social', - socialProvider: provider, - port: handlerPort, - redirectUri: redirectUri, - state: state, - ...options - } - }; -} - -/** - * Kiro Builder ID - Device Code Flow(类似 Qwen OAuth 模式) - */ -async function handleKiroBuilderIDDeviceCode(currentConfig, options = {}) { - // 停止之前的轮询任务 - for (const [existingTaskId] of activeKiroPollingTasks.entries()) { - if (existingTaskId.startsWith('kiro-')) { - stopKiroPollingTask(existingTaskId); - } - } - - // 获取 Builder ID Start URL(优先使用前端传入的值,否则使用默认值) - const builderIDStartURL = options.builderIDStartURL || KIRO_OAUTH_CONFIG.builderIDStartURL; - logger.info(`${KIRO_OAUTH_CONFIG.logPrefix} Using Builder ID Start URL: ${builderIDStartURL}`); - - // 1. 注册 OIDC 客户端 - const region = options.region || 'us-east-1'; - const ssoOIDCEndpoint = KIRO_OAUTH_CONFIG.ssoOIDCEndpoint.replace('{{region}}', region); - - const regResponse = await fetchWithProxy(`${ssoOIDCEndpoint}/client/register`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'User-Agent': 'KiroIDE' - }, - body: JSON.stringify({ - clientName: 'Kiro IDE', - clientType: 'public', - scopes: KIRO_OAUTH_CONFIG.scopes, - // grantTypes: ['urn:ietf:params:oauth:grant-type:device_code', 'refresh_token'] - }) - }, 'claude-kiro-oauth'); - - if (!regResponse.ok) { - throw new Error(`Kiro OAuth 客户端注册失败: ${regResponse.status}`); - } - - const regData = await regResponse.json(); - - // 2. 启动设备授权 - const authResponse = await fetchWithProxy(`${ssoOIDCEndpoint}/device_authorization`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - clientId: regData.clientId, - clientSecret: regData.clientSecret, - startUrl: builderIDStartURL - }) - }, 'claude-kiro-oauth'); - - if (!authResponse.ok) { - throw new Error(`Kiro OAuth 设备授权失败: ${authResponse.status}`); - } - - const deviceAuth = await authResponse.json(); - - // 3. 启动后台轮询(类似 Qwen OAuth 的模式) - const taskId = `kiro-${deviceAuth.deviceCode.substring(0, 8)}-${Date.now()}`; - - - // 异步轮询 - pollKiroBuilderIDToken( - regData.clientId, - regData.clientSecret, - deviceAuth.deviceCode, - 5, - 300, - taskId, - { ...options, region } - ).catch(error => { - logger.error(`${KIRO_OAUTH_CONFIG.logPrefix} 轮询失败 [${taskId}]:`, error); - broadcastEvent('oauth_error', { - provider: 'claude-kiro-oauth', - error: error.message, - timestamp: new Date().toISOString() - }); - }); - - return { - authUrl: deviceAuth.verificationUriComplete, - authInfo: { - provider: 'claude-kiro-oauth', - authMethod: 'builder-id', - deviceCode: deviceAuth.deviceCode, - userCode: deviceAuth.userCode, - verificationUri: deviceAuth.verificationUri, - verificationUriComplete: deviceAuth.verificationUriComplete, - expiresIn: deviceAuth.expiresIn, - interval: deviceAuth.interval, - ...options - } - }; -} - -/** - * 轮询获取 Kiro Builder ID Token - */ -async function pollKiroBuilderIDToken(clientId, clientSecret, deviceCode, interval, expiresIn, taskId, options = {}) { - let credPath = path.join(os.homedir(), KIRO_OAUTH_CONFIG.credentialsDir, KIRO_OAUTH_CONFIG.credentialsFile); - const maxAttempts = Math.floor(expiresIn / interval); - let attempts = 0; - - const taskControl = { shouldStop: false }; - activeKiroPollingTasks.set(taskId, taskControl); - - logger.info(`${KIRO_OAUTH_CONFIG.logPrefix} 开始轮询令牌 [${taskId}]`); - - const poll = async () => { - if (taskControl.shouldStop) { - throw new Error('轮询任务已被取消'); - } - - if (attempts >= maxAttempts) { - activeKiroPollingTasks.delete(taskId); - throw new Error('授权超时'); - } - - attempts++; - - try { - const region = options.region || 'us-east-1'; - const ssoOIDCEndpoint = KIRO_OAUTH_CONFIG.ssoOIDCEndpoint.replace('{{region}}', region); - const response = await fetchWithProxy(`${ssoOIDCEndpoint}/token`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'User-Agent': 'KiroIDE' - }, - body: JSON.stringify({ - clientId, - clientSecret, - deviceCode, - grantType: 'urn:ietf:params:oauth:grant-type:device_code' - }) - }, 'claude-kiro-oauth'); - - const data = await response.json(); - - if (response.ok && data.accessToken) { - logger.info(`${KIRO_OAUTH_CONFIG.logPrefix} 成功获取令牌 [${taskId}]`); - - // 保存令牌(符合现有规范) - if (options.saveToConfigs) { - const timestamp = Date.now(); - const folderName = `${timestamp}_kiro-auth-token`; - const targetDir = path.join(process.cwd(), 'configs', 'kiro', folderName); - await fs.promises.mkdir(targetDir, { recursive: true }); - credPath = path.join(targetDir, `${folderName}.json`); - } - - const tokenData = { - accessToken: data.accessToken, - refreshToken: data.refreshToken, - expiresAt: new Date(Date.now() + data.expiresIn * 1000).toISOString(), - authMethod: 'builder-id', - clientId, - clientSecret, - idcRegion: options.region || 'us-east-1' - }; - - await fs.promises.mkdir(path.dirname(credPath), { recursive: true }); - await fs.promises.writeFile(credPath, JSON.stringify(tokenData, null, 2)); - - activeKiroPollingTasks.delete(taskId); - - // 广播成功事件(符合现有规范) - broadcastEvent('oauth_success', { - provider: 'claude-kiro-oauth', - credPath, - relativePath: path.relative(process.cwd(), credPath), - timestamp: new Date().toISOString() - }); - - // 自动关联新生成的凭据到 Pools - await autoLinkProviderConfigs(CONFIG, { - onlyCurrentCred: true, - credPath: path.relative(process.cwd(), credPath) - }); - - return tokenData; - } - - // 检查错误类型 - if (data.error === 'authorization_pending') { - logger.info(`${KIRO_OAUTH_CONFIG.logPrefix} 等待用户授权 [${taskId}]... (${attempts}/${maxAttempts})`); - await new Promise(resolve => setTimeout(resolve, interval * 1000)); - return poll(); - } else if (data.error === 'slow_down') { - await new Promise(resolve => setTimeout(resolve, (interval + 5) * 1000)); - return poll(); - } else { - activeKiroPollingTasks.delete(taskId); - throw new Error(`授权失败: ${data.error || '未知错误'}`); - } - } catch (error) { - if (error.message.includes('授权') || error.message.includes('取消')) { - throw error; - } - await new Promise(resolve => setTimeout(resolve, interval * 1000)); - return poll(); - } - }; - - return poll(); -} - -/** - * 停止 Kiro 轮询任务 - */ -function stopKiroPollingTask(taskId) { - const task = activeKiroPollingTasks.get(taskId); - if (task) { - task.shouldStop = true; - activeKiroPollingTasks.delete(taskId); - logger.info(`${KIRO_OAUTH_CONFIG.logPrefix} 已停止轮询任务: ${taskId}`); - } -} - -/** - * 启动 Kiro 回调服务器(用于 Social Auth HTTP 回调) - */ -async function startKiroCallbackServer(codeVerifier, expectedState, options = {}) { - const portStart = KIRO_OAUTH_CONFIG.callbackPortStart; - const portEnd = KIRO_OAUTH_CONFIG.callbackPortEnd; - - for (let port = portStart; port <= portEnd; port++) { - // 关闭已存在的服务器 - await closeKiroServer(port); - - try { - const server = await createKiroHttpCallbackServer(port, codeVerifier, expectedState, options); - activeKiroServers.set('claude-kiro-oauth', { server, port }); - logger.info(`${KIRO_OAUTH_CONFIG.logPrefix} 回调服务器已启动于端口 ${port}`); - return port; - } catch (err) { - logger.info(`${KIRO_OAUTH_CONFIG.logPrefix} 端口 ${port} 被占用,尝试下一个...`); - } - } - - throw new Error('所有端口都被占用'); -} - -/** - * 关闭 Kiro 服务器 - */ -async function closeKiroServer(provider, port = null) { - const existing = activeKiroServers.get(provider); - if (existing) { - try { - const closePromise = new Promise((resolve, reject) => { - existing.server.close((err) => { - if (err) reject(err); - else resolve(); - }); - }); - - const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => reject(new Error('Server close timeout after 2s')), 2000); - }); - - await Promise.race([closePromise, timeoutPromise]); - logger.info(`${KIRO_OAUTH_CONFIG.logPrefix} 已关闭提供商 ${provider} 在端口 ${existing.port} 上的旧服务器`); - } catch (error) { - logger.warn(`${KIRO_OAUTH_CONFIG.logPrefix} 关闭提供商 ${provider} 服务器失败或超时: ${error.message}`); - } finally { - activeKiroServers.delete(provider); - } - } - - if (port) { - for (const [p, info] of activeKiroServers.entries()) { - if (info.port === port) { - await closeKiroServer(p); - } - } - } -} - -/** - * 创建 Kiro HTTP 回调服务器 - */ -function createKiroHttpCallbackServer(port, codeVerifier, expectedState, options = {}) { - const redirectUri = `http://127.0.0.1:${port}/oauth/callback`; - - return new Promise((resolve, reject) => { - const server = http.createServer(async (req, res) => { - try { - const url = new URL(req.url, `http://127.0.0.1:${port}`); - - if (url.pathname === '/oauth/callback') { - const code = url.searchParams.get('code'); - const state = url.searchParams.get('state'); - const errorParam = url.searchParams.get('error'); - - if (errorParam) { - res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(generateResponsePage(false, `授权失败: ${errorParam}`)); - return; - } - - if (state !== expectedState) { - res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(generateResponsePage(false, 'State 验证失败')); - return; - } - - // 交换 Code 获取 Token(使用动态的 redirect_uri) - const tokenResponse = await fetchWithProxy(`${KIRO_OAUTH_CONFIG.authServiceEndpoint}/oauth/token`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'User-Agent': 'AIClient-2-API/1.0.0' - }, - body: JSON.stringify({ - code, - code_verifier: codeVerifier, - redirect_uri: redirectUri - }) - }, 'claude-kiro-oauth'); - - if (!tokenResponse.ok) { - const errorText = await tokenResponse.text(); - logger.error(`${KIRO_OAUTH_CONFIG.logPrefix} Token exchange failed:`, errorText); - res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(generateResponsePage(false, `获取令牌失败: ${tokenResponse.status}`)); - return; - } - - const tokenData = await tokenResponse.json(); - - // 保存令牌 - let credPath = path.join(os.homedir(), KIRO_OAUTH_CONFIG.credentialsDir, KIRO_OAUTH_CONFIG.credentialsFile); - - if (options.saveToConfigs) { - const timestamp = Date.now(); - const folderName = `${timestamp}_kiro-auth-token`; - const targetDir = path.join(process.cwd(), 'configs', 'kiro', folderName); - await fs.promises.mkdir(targetDir, { recursive: true }); - credPath = path.join(targetDir, `${folderName}.json`); - } - - const saveData = { - accessToken: tokenData.accessToken, - refreshToken: tokenData.refreshToken, - profileArn: tokenData.profileArn, - expiresAt: new Date(Date.now() + (tokenData.expiresIn || 3600) * 1000).toISOString(), - authMethod: 'social', - region: 'us-east-1' - }; - - await fs.promises.mkdir(path.dirname(credPath), { recursive: true }); - await fs.promises.writeFile(credPath, JSON.stringify(saveData, null, 2)); - - logger.info(`${KIRO_OAUTH_CONFIG.logPrefix} 令牌已保存: ${credPath}`); - - // 广播成功事件 - broadcastEvent('oauth_success', { - provider: 'claude-kiro-oauth', - credPath, - relativePath: path.relative(process.cwd(), credPath), - timestamp: new Date().toISOString() - }); - - // 自动关联新生成的凭据到 Pools - await autoLinkProviderConfigs(CONFIG, { - onlyCurrentCred: true, - credPath: path.relative(process.cwd(), credPath) - }); - - res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(generateResponsePage(true, '授权成功!您可以关闭此页面')); - - // 关闭服务器 - server.close(() => { - activeKiroServers.delete('claude-kiro-oauth'); - }); - - } else { - res.writeHead(204); - res.end(); - } - } catch (error) { - logger.error(`${KIRO_OAUTH_CONFIG.logPrefix} 处理回调出错:`, error); - res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' }); - res.end(generateResponsePage(false, `服务器错误: ${error.message}`)); - } - }); - - server.on('error', reject); - server.listen(port, '127.0.0.1', () => resolve(server)); - - // 超时自动关闭 - setTimeout(() => { - if (server.listening) { - server.close(() => { - activeKiroServers.delete('claude-kiro-oauth'); - }); - } - }, KIRO_OAUTH_CONFIG.authTimeout); - }); -} - -/** - * Kiro Token 刷新常量 - */ -const KIRO_REFRESH_CONSTANTS = { - REFRESH_URL: 'https://prod.{{region}}.auth.desktop.kiro.dev/refreshToken', - REFRESH_IDC_URL: 'https://oidc.{{region}}.amazonaws.com/token', - CONTENT_TYPE_JSON: 'application/json', - AUTH_METHOD_SOCIAL: 'social', - DEFAULT_PROVIDER: 'Google', - REQUEST_TIMEOUT: 30000, - DEFAULT_REGION: 'us-east-1', - IDC_REGION: 'us-east-1' // 用于 REFRESH_IDC_URL 的区域配置 -}; - -/** - * 通过 refreshToken 获取 accessToken - * @param {string} refreshToken - Kiro 的 refresh token - * @param {string} region - AWS 区域 (默认: us-east-1) - * @returns {Promise} 包含 accessToken 等信息的对象 - */ -async function refreshKiroToken(refreshToken, region = KIRO_REFRESH_CONSTANTS.DEFAULT_REGION) { - const refreshUrl = KIRO_REFRESH_CONSTANTS.REFRESH_URL.replace('{{region}}', region); - - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), KIRO_REFRESH_CONSTANTS.REQUEST_TIMEOUT); - - try { - const response = await fetchWithProxy(refreshUrl, { - method: 'POST', - headers: { - 'Content-Type': KIRO_REFRESH_CONSTANTS.CONTENT_TYPE_JSON - }, - body: JSON.stringify({ refreshToken }), - signal: controller.signal - }, 'claude-kiro-oauth'); - - clearTimeout(timeoutId); - - if (!response.ok) { - const errorText = await response.text(); - throw new Error(`HTTP ${response.status}: ${errorText}`); - } - - const data = await response.json(); - - if (!data.accessToken) { - throw new Error('Invalid refresh response: Missing accessToken'); - } - - const expiresIn = data.expiresIn || 3600; - const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString(); - - return { - accessToken: data.accessToken, - refreshToken: data.refreshToken || refreshToken, - profileArn: data.profileArn || '', - expiresAt: expiresAt, - authMethod: KIRO_REFRESH_CONSTANTS.AUTH_METHOD_SOCIAL, - provider: KIRO_REFRESH_CONSTANTS.DEFAULT_PROVIDER, - region: region - }; - } catch (error) { - clearTimeout(timeoutId); - if (error.name === 'AbortError') { - throw new Error('Request timeout'); - } - throw error; - } -} - -/** - * 检查 Kiro 凭据是否已存在(基于 refreshToken + provider 组合) - * @param {string} refreshToken - 要检查的 refreshToken - * @param {string} provider - 提供商名称 (默认: 'claude-kiro-oauth') - * @returns {Promise<{isDuplicate: boolean, existingPath?: string}>} 检查结果 - */ -export async function checkKiroCredentialsDuplicate(refreshToken, provider = 'claude-kiro-oauth') { - const kiroDir = path.join(process.cwd(), 'configs', 'kiro'); - - try { - // 检查 configs/kiro 目录是否存在 - if (!fs.existsSync(kiroDir)) { - return { isDuplicate: false }; - } - - // 递归扫描所有 JSON 文件 - const scanDirectory = async (dirPath) => { - const entries = await fs.promises.readdir(dirPath, { withFileTypes: true }); - - for (const entry of entries) { - const fullPath = path.join(dirPath, entry.name); - - if (entry.isDirectory()) { - const result = await scanDirectory(fullPath); - if (result.isDuplicate) { - return result; - } - } else if (entry.isFile() && entry.name.endsWith('.json')) { - try { - const content = await fs.promises.readFile(fullPath, 'utf8'); - const credentials = JSON.parse(content); - - // 检查 refreshToken 是否匹配 - if (credentials.refreshToken && credentials.refreshToken === refreshToken) { - const relativePath = path.relative(process.cwd(), fullPath); - logger.info(`${KIRO_OAUTH_CONFIG.logPrefix} Found duplicate refreshToken in: ${relativePath}`); - return { - isDuplicate: true, - existingPath: relativePath - }; - } - } catch (parseError) { - // 忽略解析错误的文件 - } - } - } - - return { isDuplicate: false }; - }; - - return await scanDirectory(kiroDir); - - } catch (error) { - logger.warn(`${KIRO_OAUTH_CONFIG.logPrefix} Error checking duplicates:`, error.message); - return { isDuplicate: false }; - } -} - -/** - * 批量导入 Kiro refreshToken 并生成凭据文件 - * @param {string[]} refreshTokens - refreshToken 数组 - * @param {string} region - AWS 区域 (默认: us-east-1) - * @param {boolean} skipDuplicateCheck - 是否跳过重复检查 (默认: false) - * @returns {Promise} 批量处理结果 - */ -export async function batchImportKiroRefreshTokens(refreshTokens, region = KIRO_REFRESH_CONSTANTS.DEFAULT_REGION, skipDuplicateCheck = false) { - const results = { - total: refreshTokens.length, - success: 0, - failed: 0, - details: [] - }; - - for (let i = 0; i < refreshTokens.length; i++) { - const refreshToken = refreshTokens[i].trim(); - - if (!refreshToken) { - results.details.push({ - index: i + 1, - success: false, - error: 'Empty token' - }); - results.failed++; - continue; - } - - // 检查重复 - if (!skipDuplicateCheck) { - const duplicateCheck = await checkKiroCredentialsDuplicate(refreshToken); - if (duplicateCheck.isDuplicate) { - results.details.push({ - index: i + 1, - success: false, - error: 'duplicate', - existingPath: duplicateCheck.existingPath - }); - results.failed++; - continue; - } - } - - try { - logger.info(`${KIRO_OAUTH_CONFIG.logPrefix} 正在刷新第 ${i + 1}/${refreshTokens.length} 个 token...`); - - const tokenData = await refreshKiroToken(refreshToken, region); - - // 生成文件路径: configs/kiro/{timestamp}_kiro-auth-token/{timestamp}_kiro-auth-token.json - const timestamp = Date.now(); - const folderName = `${timestamp}_kiro-auth-token`; - const targetDir = path.join(process.cwd(), 'configs', 'kiro', folderName); - await fs.promises.mkdir(targetDir, { recursive: true }); - - const credPath = path.join(targetDir, `${folderName}.json`); - await fs.promises.writeFile(credPath, JSON.stringify(tokenData, null, 2)); - - const relativePath = path.relative(process.cwd(), credPath); - - logger.info(`${KIRO_OAUTH_CONFIG.logPrefix} Token ${i + 1} 已保存: ${relativePath}`); - - results.details.push({ - index: i + 1, - success: true, - path: relativePath, - expiresAt: tokenData.expiresAt - }); - results.success++; - - } catch (error) { - logger.error(`${KIRO_OAUTH_CONFIG.logPrefix} Token ${i + 1} 刷新失败:`, error.message); - - results.details.push({ - index: i + 1, - success: false, - error: error.message - }); - results.failed++; - } - } - - // 如果有成功的,广播事件并自动关联 - if (results.success > 0) { - broadcastEvent('oauth_batch_success', { - provider: 'claude-kiro-oauth', - count: results.success, - timestamp: new Date().toISOString() - }); - - // 自动关联新生成的凭据到 Pools - for (const detail of results.details) { - if (detail.success && detail.path) { - await autoLinkProviderConfigs(CONFIG, { - onlyCurrentCred: true, - credPath: detail.path - }); - } - } - } - - return results; -} - -/** - * 批量导入 Kiro refreshToken 并生成凭据文件(流式版本,支持实时进度回调) - * @param {string[]} refreshTokens - refreshToken 数组 - * @param {string} region - AWS 区域 (默认: us-east-1) - * @param {Function} onProgress - 进度回调函数,每处理完一个 token 调用 - * @param {boolean} skipDuplicateCheck - 是否跳过重复检查 (默认: false) - * @returns {Promise} 批量处理结果 - */ -export async function batchImportKiroRefreshTokensStream(refreshTokens, region = KIRO_REFRESH_CONSTANTS.DEFAULT_REGION, onProgress = null, skipDuplicateCheck = false) { - const results = { - total: refreshTokens.length, - success: 0, - failed: 0, - details: [] - }; - - for (let i = 0; i < refreshTokens.length; i++) { - const refreshToken = refreshTokens[i].trim(); - const progressData = { - index: i + 1, - total: refreshTokens.length, - current: null - }; - - if (!refreshToken) { - progressData.current = { - index: i + 1, - success: false, - error: 'Empty token' - }; - results.details.push(progressData.current); - results.failed++; - - // 发送进度更新 - if (onProgress) { - onProgress({ - ...progressData, - successCount: results.success, - failedCount: results.failed - }); - } - continue; - } - - // 检查重复 - if (!skipDuplicateCheck) { - const duplicateCheck = await checkKiroCredentialsDuplicate(refreshToken); - if (duplicateCheck.isDuplicate) { - progressData.current = { - index: i + 1, - success: false, - error: 'duplicate', - existingPath: duplicateCheck.existingPath - }; - results.details.push(progressData.current); - results.failed++; - - // 发送进度更新 - if (onProgress) { - onProgress({ - ...progressData, - successCount: results.success, - failedCount: results.failed - }); - } - continue; - } - } - - try { - logger.info(`${KIRO_OAUTH_CONFIG.logPrefix} 正在刷新第 ${i + 1}/${refreshTokens.length} 个 token...`); - - const tokenData = await refreshKiroToken(refreshToken, region); - - // 生成文件路径: configs/kiro/{timestamp}_kiro-auth-token/{timestamp}_kiro-auth-token.json - const timestamp = Date.now(); - const folderName = `${timestamp}_kiro-auth-token`; - const targetDir = path.join(process.cwd(), 'configs', 'kiro', folderName); - await fs.promises.mkdir(targetDir, { recursive: true }); - - const credPath = path.join(targetDir, `${folderName}.json`); - await fs.promises.writeFile(credPath, JSON.stringify(tokenData, null, 2)); - - const relativePath = path.relative(process.cwd(), credPath); - - logger.info(`${KIRO_OAUTH_CONFIG.logPrefix} Token ${i + 1} 已保存: ${relativePath}`); - - progressData.current = { - index: i + 1, - success: true, - path: relativePath, - expiresAt: tokenData.expiresAt - }; - results.details.push(progressData.current); - results.success++; - - } catch (error) { - logger.error(`${KIRO_OAUTH_CONFIG.logPrefix} Token ${i + 1} 刷新失败:`, error.message); - - progressData.current = { - index: i + 1, - success: false, - error: error.message - }; - results.details.push(progressData.current); - results.failed++; - } - - // 发送进度更新 - if (onProgress) { - onProgress({ - ...progressData, - successCount: results.success, - failedCount: results.failed - }); - } - } - - // 如果有成功的,广播事件并自动关联 - if (results.success > 0) { - broadcastEvent('oauth_batch_success', { - provider: 'claude-kiro-oauth', - count: results.success, - timestamp: new Date().toISOString() - }); - - // 自动关联新生成的凭据到 Pools - for (const detail of results.details) { - if (detail.success && detail.path) { - await autoLinkProviderConfigs(CONFIG, { - onlyCurrentCred: true, - credPath: detail.path - }); - } - } - } - - return results; -} - -/** - * 导入 AWS SSO 凭据用于 Kiro (Builder ID 模式) - * 从用户上传的 AWS SSO cache 文件中导入凭据 - * @param {Object} credentials - 合并后的凭据对象,需包含 clientId 和 clientSecret - * @param {boolean} skipDuplicateCheck - 是否跳过重复检查 (默认: false) - * @returns {Promise} 导入结果 - */ -export async function importAwsCredentials(credentials, skipDuplicateCheck = false) { - try { - // 验证必需字段 - 需要四个字段都存在 - const missingFields = []; - if (!credentials.clientId) missingFields.push('clientId'); - if (!credentials.clientSecret) missingFields.push('clientSecret'); - if (!credentials.accessToken) missingFields.push('accessToken'); - if (!credentials.refreshToken) missingFields.push('refreshToken'); - - if (missingFields.length > 0) { - return { - success: false, - error: `Missing required fields: ${missingFields.join(', ')}` - }; - } - - // 检查重复凭据 - if (!skipDuplicateCheck) { - const duplicateCheck = await checkKiroCredentialsDuplicate(credentials.refreshToken); - if (duplicateCheck.isDuplicate) { - return { - success: false, - error: 'duplicate', - existingPath: duplicateCheck.existingPath - }; - } - } - - logger.info(`${KIRO_OAUTH_CONFIG.logPrefix} Importing AWS credentials...`); - - // 准备凭据数据 - 四个字段都是必需的 - const credentialsData = { - clientId: credentials.clientId, - clientSecret: credentials.clientSecret, - accessToken: credentials.accessToken, - refreshToken: credentials.refreshToken, - authMethod: credentials.authMethod || 'builder-id', - // region: credentials.region || KIRO_REFRESH_CONSTANTS.DEFAULT_REGION, - idcRegion: credentials.idcRegion || KIRO_REFRESH_CONSTANTS.IDC_REGION - }; - - // 可选字段 - if (credentials.expiresAt) { - credentialsData.expiresAt = credentials.expiresAt; - } - if (credentials.startUrl) { - credentialsData.startUrl = credentials.startUrl; - } - if (credentials.registrationExpiresAt) { - credentialsData.registrationExpiresAt = credentials.registrationExpiresAt; - } - - // 尝试刷新获取最新的 accessToken - try { - logger.info(`${KIRO_OAUTH_CONFIG.logPrefix} Attempting to refresh token with provided credentials...`); - - const refreshRegion = credentials.idcRegion || KIRO_REFRESH_CONSTANTS.IDC_REGION; - const refreshUrl = KIRO_REFRESH_CONSTANTS.REFRESH_IDC_URL.replace('{{region}}', refreshRegion); - - const refreshResponse = await fetchWithProxy(refreshUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - refreshToken: credentials.refreshToken, - clientId: credentials.clientId, - clientSecret: credentials.clientSecret, - grantType: 'refresh_token' - }) - }, 'claude-kiro-oauth'); - - if (refreshResponse.ok) { - const tokenData = await refreshResponse.json(); - credentialsData.accessToken = tokenData.accessToken; - credentialsData.refreshToken = tokenData.refreshToken; - const expiresIn = tokenData.expiresIn || 3600; - credentialsData.expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString(); - logger.info(`${KIRO_OAUTH_CONFIG.logPrefix} Token refreshed successfully`); - } else { - logger.warn(`${KIRO_OAUTH_CONFIG.logPrefix} Token refresh failed, saving original credentials`); - } - } catch (refreshError) { - logger.warn(`${KIRO_OAUTH_CONFIG.logPrefix} Token refresh error:`, refreshError.message); - // 继续保存原始凭据 - } - - // 生成文件路径: configs/kiro/{timestamp}_kiro-auth-token/{timestamp}_kiro-auth-token.json - const timestamp = Date.now(); - const folderName = `${timestamp}_kiro-auth-token`; - const targetDir = path.join(process.cwd(), 'configs', 'kiro', folderName); - await fs.promises.mkdir(targetDir, { recursive: true }); - - const credPath = path.join(targetDir, `${folderName}.json`); - await fs.promises.writeFile(credPath, JSON.stringify(credentialsData, null, 2)); - - const relativePath = path.relative(process.cwd(), credPath); - - logger.info(`${KIRO_OAUTH_CONFIG.logPrefix} AWS credentials saved to: ${relativePath}`); - - // 广播事件 - broadcastEvent('oauth_success', { - provider: 'claude-kiro-oauth', - relativePath: relativePath, - timestamp: new Date().toISOString() - }); - - // 自动关联新生成的凭据到 Pools - await autoLinkProviderConfigs(CONFIG, { - onlyCurrentCred: true, - credPath: relativePath - }); - - return { - success: true, - path: relativePath - }; - - } catch (error) { - logger.error(`${KIRO_OAUTH_CONFIG.logPrefix} AWS credentials import failed:`, error); - return { - success: false, - error: error.message - }; - } -} - - diff --git a/src/auth/oauth-handlers.js b/src/auth/oauth-handlers.js deleted file mode 100644 index 1183e4a507e882dbfe763262a6d3099f07165082..0000000000000000000000000000000000000000 --- a/src/auth/oauth-handlers.js +++ /dev/null @@ -1,27 +0,0 @@ -// OAuth 处理器统一导出文件 -// 此文件已按提供商拆分为多个独立文件,请从 index.js 导入 - -// 重新导出所有 OAuth 处理函数以保持向后兼容 -export { - // Codex OAuth - refreshCodexTokensWithRetry, - handleCodexOAuth, - handleCodexOAuthCallback, - batchImportCodexTokensStream, - // Gemini OAuth - handleGeminiCliOAuth, - handleGeminiAntigravityOAuth, - batchImportGeminiTokensStream, - checkGeminiCredentialsDuplicate, - // Qwen OAuth - handleQwenOAuth, - // Kiro OAuth - handleKiroOAuth, - checkKiroCredentialsDuplicate, - batchImportKiroRefreshTokens, - batchImportKiroRefreshTokensStream, - importAwsCredentials, - // iFlow OAuth - handleIFlowOAuth, - refreshIFlowTokens, -} from './index.js'; \ No newline at end of file diff --git a/src/auth/qwen-oauth.js b/src/auth/qwen-oauth.js deleted file mode 100644 index c37a54c5ea8e18042f923c7960cfa20fe0e50493..0000000000000000000000000000000000000000 --- a/src/auth/qwen-oauth.js +++ /dev/null @@ -1,343 +0,0 @@ -import fs from 'fs'; -import logger from '../utils/logger.js'; -import path from 'path'; -import os from 'os'; -import crypto from 'crypto'; -import { broadcastEvent } from '../services/ui-manager.js'; -import { autoLinkProviderConfigs } from '../services/service-manager.js'; -import { CONFIG } from '../core/config-manager.js'; -import { getProxyConfigForProvider } from '../utils/proxy-utils.js'; - -/** - * Qwen OAuth 配置 - */ -const QWEN_OAUTH_CONFIG = { - clientId: 'f0304373b74a44d2b584a3fb70ca9e56', - scope: 'openid profile email model.completion', - deviceCodeEndpoint: 'https://chat.qwen.ai/api/v1/oauth2/device/code', - tokenEndpoint: 'https://chat.qwen.ai/api/v1/oauth2/token', - grantType: 'urn:ietf:params:oauth:grant-type:device_code', - credentialsDir: '.qwen', - credentialsFile: 'oauth_creds.json', - logPrefix: '[Qwen Auth]' -}; - -/** - * 活动的轮询任务管理 - */ -const activePollingTasks = new Map(); - -/** - * 创建带代理支持的 fetch 请求 - * 使用 axios 替代原生 fetch,以正确支持代理配置 - * @param {string} url - 请求 URL - * @param {Object} options - fetch 选项(兼容 fetch API 格式) - * @param {string} providerType - 提供商类型,用于获取代理配置 - * @returns {Promise} 返回类似 fetch Response 的对象 - */ -async function fetchWithProxy(url, options = {}, providerType) { - const proxyConfig = getProxyConfigForProvider(CONFIG, providerType); - - // 构建 axios 配置 - const axiosConfig = { - url, - method: options.method || 'GET', - headers: options.headers || {}, - timeout: 30000, // 30 秒超时 - }; - - // 处理请求体 - if (options.body) { - axiosConfig.data = options.body; - } - - // 配置代理 - if (proxyConfig) { - axiosConfig.httpAgent = proxyConfig.httpAgent; - axiosConfig.httpsAgent = proxyConfig.httpsAgent; - axiosConfig.proxy = false; // 禁用 axios 内置代理,使用我们的 agent - logger.info(`[OAuth] Using proxy for ${providerType}: ${CONFIG.PROXY_URL}`); - } - - try { - const axios = (await import('axios')).default; - const response = await axios(axiosConfig); - - // 返回类似 fetch Response 的对象 - return { - ok: response.status >= 200 && response.status < 300, - status: response.status, - statusText: response.statusText, - headers: response.headers, - json: async () => response.data, - text: async () => typeof response.data === 'string' ? response.data : JSON.stringify(response.data), - }; - } catch (error) { - // 处理 axios 错误,转换为类似 fetch 的响应格式 - if (error.response) { - // 服务器返回了错误状态码 - return { - ok: false, - status: error.response.status, - statusText: error.response.statusText, - headers: error.response.headers, - json: async () => error.response.data, - text: async () => typeof error.response.data === 'string' ? error.response.data : JSON.stringify(error.response.data), - }; - } - // 网络错误或其他错误 - throw error; - } -} - -/** - * 生成 PKCE 代码验证器 - * @returns {string} Base64URL 编码的随机字符串 - */ -function generateCodeVerifier() { - return crypto.randomBytes(32).toString('base64url'); -} - -/** - * 生成 PKCE 代码挑战 - * @param {string} codeVerifier - 代码验证器 - * @returns {string} Base64URL 编码的 SHA256 哈希 - */ -function generateCodeChallenge(codeVerifier) { - const hash = crypto.createHash('sha256'); - hash.update(codeVerifier); - return hash.digest('base64url'); -} - -/** - * 停止活动的轮询任务 - * @param {string} taskId - 任务标识符 - */ -function stopPollingTask(taskId) { - const task = activePollingTasks.get(taskId); - if (task) { - task.shouldStop = true; - activePollingTasks.delete(taskId); - logger.info(`${QWEN_OAUTH_CONFIG.logPrefix} 已停止轮询任务: ${taskId}`); - } -} - -/** - * 轮询获取 Qwen OAuth 令牌 - * @param {string} deviceCode - 设备代码 - * @param {string} codeVerifier - PKCE 代码验证器 - * @param {number} interval - 轮询间隔(秒) - * @param {number} expiresIn - 过期时间(秒) - * @param {string} taskId - 任务标识符 - * @param {Object} options - 额外选项 - * @returns {Promise} 返回令牌信息 - */ -async function pollQwenToken(deviceCode, codeVerifier, interval = 5, expiresIn = 300, taskId = 'default', options = {}) { - let credPath = path.join(os.homedir(), QWEN_OAUTH_CONFIG.credentialsDir, QWEN_OAUTH_CONFIG.credentialsFile); - const maxAttempts = Math.floor(expiresIn / interval); - let attempts = 0; - - // 创建任务控制对象 - const taskControl = { shouldStop: false }; - activePollingTasks.set(taskId, taskControl); - - logger.info(`${QWEN_OAUTH_CONFIG.logPrefix} 开始轮询令牌 [${taskId}],间隔 ${interval} 秒,最多尝试 ${maxAttempts} 次`); - - const poll = async () => { - // 检查是否需要停止 - if (taskControl.shouldStop) { - logger.info(`${QWEN_OAUTH_CONFIG.logPrefix} 轮询任务 [${taskId}] 已被停止`); - throw new Error('轮询任务已被取消'); - } - - if (attempts >= maxAttempts) { - activePollingTasks.delete(taskId); - throw new Error('授权超时,请重新开始授权流程'); - } - - attempts++; - - const bodyData = { - client_id: QWEN_OAUTH_CONFIG.clientId, - device_code: deviceCode, - grant_type: QWEN_OAUTH_CONFIG.grantType, - code_verifier: codeVerifier - }; - - const formBody = Object.entries(bodyData) - .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) - .join('&'); - - try { - const response = await fetchWithProxy(QWEN_OAUTH_CONFIG.tokenEndpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Accept': 'application/json' - }, - body: formBody - }, 'openai-qwen-oauth'); - - const data = await response.json(); - - if (response.ok && data.access_token) { - // 成功获取令牌 - logger.info(`${QWEN_OAUTH_CONFIG.logPrefix} 成功获取令牌 [${taskId}]`); - - // 如果指定了保存到 configs 目录 - if (options.saveToConfigs) { - const targetDir = path.join(process.cwd(), 'configs', options.providerDir); - await fs.promises.mkdir(targetDir, { recursive: true }); - const timestamp = Date.now(); - const filename = `${timestamp}_oauth_creds.json`; - credPath = path.join(targetDir, filename); - } - - // 保存令牌到文件 - await fs.promises.mkdir(path.dirname(credPath), { recursive: true }); - await fs.promises.writeFile(credPath, JSON.stringify(data, null, 2)); - logger.info(`${QWEN_OAUTH_CONFIG.logPrefix} 令牌已保存到 ${credPath}`); - - const relativePath = path.relative(process.cwd(), credPath); - - // 清理任务 - activePollingTasks.delete(taskId); - - // 广播授权成功事件 - broadcastEvent('oauth_success', { - provider: 'openai-qwen-oauth', - credPath: credPath, - relativePath: relativePath, - timestamp: new Date().toISOString() - }); - - // 自动关联新生成的凭据到 Pools - await autoLinkProviderConfigs(CONFIG, { - onlyCurrentCred: true, - credPath: relativePath - }); - - return data; - } - - // 检查错误类型 - if (data.error === 'authorization_pending') { - // 用户尚未完成授权,继续轮询 - logger.info(`${QWEN_OAUTH_CONFIG.logPrefix} 等待用户授权 [${taskId}]... (第 ${attempts}/${maxAttempts} 次尝试)`); - await new Promise(resolve => setTimeout(resolve, interval * 1000)); - return poll(); - } else if (data.error === 'slow_down') { - // 需要降低轮询频率 - logger.info(`${QWEN_OAUTH_CONFIG.logPrefix} 降低轮询频率`); - await new Promise(resolve => setTimeout(resolve, (interval + 5) * 1000)); - return poll(); - } else if (data.error === 'expired_token') { - activePollingTasks.delete(taskId); - throw new Error('设备代码已过期,请重新开始授权流程'); - } else if (data.error === 'access_denied') { - activePollingTasks.delete(taskId); - throw new Error('用户拒绝了授权请求'); - } else { - activePollingTasks.delete(taskId); - throw new Error(`授权失败: ${data.error || '未知错误'}`); - } - } catch (error) { - if (error.message.includes('授权') || error.message.includes('过期') || error.message.includes('拒绝')) { - throw error; - } - logger.error(`${QWEN_OAUTH_CONFIG.logPrefix} 轮询出错:`, error); - // 网络错误,继续重试 - await new Promise(resolve => setTimeout(resolve, interval * 1000)); - return poll(); - } - }; - - return poll(); -} - -/** - * 处理 Qwen OAuth 授权(设备授权流程) - * @param {Object} currentConfig - 当前配置对象 - * @param {Object} options - 额外选项 - * @returns {Promise} 返回授权URL和相关信息 - */ -export async function handleQwenOAuth(currentConfig, options = {}) { - const codeVerifier = generateCodeVerifier(); - const codeChallenge = generateCodeChallenge(codeVerifier); - - const bodyData = { - client_id: QWEN_OAUTH_CONFIG.clientId, - scope: QWEN_OAUTH_CONFIG.scope, - code_challenge: codeChallenge, - code_challenge_method: 'S256' - }; - - const formBody = Object.entries(bodyData) - .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) - .join('&'); - - try { - const response = await fetchWithProxy(QWEN_OAUTH_CONFIG.deviceCodeEndpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Accept': 'application/json' - }, - body: formBody - }, 'openai-qwen-oauth'); - - if (!response.ok) { - throw new Error(`Qwen OAuth请求失败: ${response.status} ${response.statusText}`); - } - - const deviceAuth = await response.json(); - - if (!deviceAuth.device_code || !deviceAuth.verification_uri_complete) { - throw new Error('Qwen OAuth响应格式错误,缺少必要字段'); - } - - // 启动后台轮询获取令牌 - const interval = 5; - // const expiresIn = deviceAuth.expires_in || 1800; - const expiresIn = 300; - - // 生成唯一的任务ID - const taskId = `qwen-${deviceAuth.device_code.substring(0, 8)}-${Date.now()}`; - - // 先停止之前可能存在的所有 Qwen 轮询任务 - for (const [existingTaskId] of activePollingTasks.entries()) { - if (existingTaskId.startsWith('qwen-')) { - stopPollingTask(existingTaskId); - } - } - - // 不等待轮询完成,立即返回授权信息 - pollQwenToken(deviceAuth.device_code, codeVerifier, interval, expiresIn, taskId, options) - .catch(error => { - logger.error(`${QWEN_OAUTH_CONFIG.logPrefix} 轮询失败 [${taskId}]:`, error); - // 广播授权失败事件 - broadcastEvent('oauth_error', { - provider: 'openai-qwen-oauth', - error: error.message, - timestamp: new Date().toISOString() - }); - }); - - return { - authUrl: deviceAuth.verification_uri_complete, - authInfo: { - provider: 'openai-qwen-oauth', - deviceCode: deviceAuth.device_code, - userCode: deviceAuth.user_code, - verificationUri: deviceAuth.verification_uri, - verificationUriComplete: deviceAuth.verification_uri_complete, - expiresIn: expiresIn, - interval: interval, - codeVerifier: codeVerifier - } - }; - } catch (error) { - logger.error(`${QWEN_OAUTH_CONFIG.logPrefix} 请求失败:`, error); - throw new Error(`Qwen OAuth 授权失败: ${error.message}`); - } -} \ No newline at end of file diff --git a/src/convert/convert-old.js b/src/convert/convert-old.js deleted file mode 100644 index 5c2a0cc185b9a696be665c763c3879a15b6d6cb8..0000000000000000000000000000000000000000 --- a/src/convert/convert-old.js +++ /dev/null @@ -1,2574 +0,0 @@ -import { v4 as uuidv4 } from 'uuid'; -import logger from '../utils/logger.js'; -import { MODEL_PROTOCOL_PREFIX, getProtocolPrefix } from '../utils/common.js'; -import { - streamStateManager, - generateResponseCreated, - generateResponseInProgress, - generateOutputItemAdded, - generateContentPartAdded, - generateOutputTextDelta, - generateOutputTextDone, - generateContentPartDone, - generateOutputItemDone, - generateResponseCompleted -} from './openai/openai-responses-core.mjs'; - -// ============================================================================= -// 常量和辅助函数定义 -// ============================================================================= - -// 定义默认常量 -const DEFAULT_MAX_TOKENS = 8192; -const DEFAULT_GEMINI_MAX_TOKENS = 65535; -const DEFAULT_TEMPERATURE = 1; -const DEFAULT_TOP_P = 0.95; - -// 辅助函数:判断值是否为 undefined 或 0,并返回默认值 -function checkAndAssignOrDefault(value, defaultValue) { - if (value !== undefined && value !== 0) { - return value; - } - return defaultValue; -} - -/** - * 映射结束原因 - * @param {string} reason - 结束原因 - * @param {string} sourceFormat - 源格式 - * @param {string} targetFormat - 目标格式 - * @returns {string} 映射后的结束原因 - */ -function _mapFinishReason(reason, sourceFormat, targetFormat) { - const reasonMappings = { - openai: { - anthropic: { - stop: "end_turn", - length: "max_tokens", - content_filter: "stop_sequence", - tool_calls: "tool_use" - } - }, - gemini: { - anthropic: { - // 旧版本大写格式 - STOP: "end_turn", - MAX_TOKENS: "max_tokens", - SAFETY: "stop_sequence", - RECITATION: "stop_sequence", - // 新版本小写格式(v1beta/v1 API) - stop: "end_turn", - length: "max_tokens", - safety: "stop_sequence", - recitation: "stop_sequence", - other: "end_turn" - } - } - }; - - try { - return reasonMappings[sourceFormat][targetFormat][reason] || "end_turn"; - } catch (e) { - return "end_turn"; - } -} - -/** - * 递归清理Gemini不支持的JSON Schema属性 - * @param {Object} schema - JSON Schema - * @returns {Object} 清理后的JSON Schema - */ -function _cleanJsonSchemaProperties(schema) { - if (!schema || typeof schema !== 'object') { - return schema; - } - - // 移除所有非标准属性 - const sanitized = {}; - for (const [key, value] of Object.entries(schema)) { - if (["type", "description", "properties", "required", "enum", "items"].includes(key)) { - sanitized[key] = value; - } - } - - if (sanitized.properties && typeof sanitized.properties === 'object') { - const cleanProperties = {}; - for (const [propName, propSchema] of Object.entries(sanitized.properties)) { - cleanProperties[propName] = _cleanJsonSchemaProperties(propSchema); - } - sanitized.properties = cleanProperties; - } - - if (sanitized.items) { - sanitized.items = _cleanJsonSchemaProperties(sanitized.items); - } - - return sanitized; -} - -/** - * 根据budget_tokens智能判断OpenAI reasoning_effort等级 - * @param {number|null} budgetTokens - Anthropic thinking的budget_tokens值 - * @returns {string} OpenAI reasoning_effort等级 ("low", "medium", "high") - */ -function _determineReasoningEffortFromBudget(budgetTokens) { - // 如果没有提供budget_tokens,默认为high - if (budgetTokens === null || budgetTokens === undefined) { - logger.info("No budget_tokens provided, defaulting to reasoning_effort='high'"); - return "high"; - } - - // 使用固定阈值替代环境变量 - const LOW_THRESHOLD = 50; // 低推理努力的阈值 - const HIGH_THRESHOLD = 200; // 高推理努力的阈值 - - logger.debug(`Threshold configuration: low <= ${LOW_THRESHOLD}, medium <= ${HIGH_THRESHOLD}, high > ${HIGH_THRESHOLD}`); - - let effort; - if (budgetTokens <= LOW_THRESHOLD) { - effort = "low"; - } else if (budgetTokens <= HIGH_THRESHOLD) { - effort = "medium"; - } else { - effort = "high"; - } - - logger.info(`🎯 Budget tokens ${budgetTokens} -> reasoning_effort '${effort}' (thresholds: low<=${LOW_THRESHOLD}, high<=${HIGH_THRESHOLD})`); - return effort; -} - -// 全局工具状态管理器 -class ToolStateManager { - constructor() { - if (ToolStateManager.instance) { - return ToolStateManager.instance; - } - ToolStateManager.instance = this; - this._toolMappings = {}; - return this; - } - - // 存储工具名到ID的映射 - storeToolMapping(funcName, toolId) { - this._toolMappings[funcName] = toolId; - } - - // 根据工具名获取ID - getToolId(funcName) { - return this._toolMappings[funcName] || null; - } - - // 清除所有映射 - clearMappings() { - this._toolMappings = {}; - } -} - -// 全局工具状态管理器实例 -const toolStateManager = new ToolStateManager(); - -// ============================================================================= -// 主转换函数 -// ============================================================================= - -/** - * Generic data conversion function. - * @param {object} data - The data to convert (request body or response). - * @param {string} type - The type of conversion: 'request', 'response', 'streamChunk', 'modelList'. - * @param {string} fromProvider - The source model provider (e.g., MODEL_PROVIDER.GEMINI_CLI). - * @param {string} toProvider - The target model provider (e.g., MODEL_PROVIDER.OPENAI_CUSTOM). - * @param {string} [model] - Optional model name for response conversions. - * @returns {object} The converted data. - * @throws {Error} If no suitable conversion function is found. - */ -export function convertData(data, type, fromProvider, toProvider, model) { - // Define a map of conversion functions using protocol prefixes - const conversionMap = { - request: { - [MODEL_PROTOCOL_PREFIX.OPENAI]: { // to OpenAI protocol - [MODEL_PROTOCOL_PREFIX.GEMINI]: toOpenAIRequestFromGemini, // from Gemini protocol - [MODEL_PROTOCOL_PREFIX.CLAUDE]: toOpenAIRequestFromClaude, // from Claude protocol - }, - [MODEL_PROTOCOL_PREFIX.CLAUDE]: { // to Claude protocol - [MODEL_PROTOCOL_PREFIX.OPENAI]: toClaudeRequestFromOpenAI, // from OpenAI protocol - [MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES]: toClaudeRequestFromOpenAIResponses, // from OpenAI protocol (Responses format) - }, - [MODEL_PROTOCOL_PREFIX.GEMINI]: { // to Gemini protocol - [MODEL_PROTOCOL_PREFIX.OPENAI]: toGeminiRequestFromOpenAI, // from OpenAI protocol - [MODEL_PROTOCOL_PREFIX.CLAUDE]: toGeminiRequestFromClaude, // from Claude protocol - [MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES]: toGeminiRequestFromOpenAIResponses, // from OpenAI protocol (Responses format) - }, - }, - response: { - [MODEL_PROTOCOL_PREFIX.OPENAI]: { // to OpenAI protocol - [MODEL_PROTOCOL_PREFIX.GEMINI]: toOpenAIChatCompletionFromGemini, // from Gemini protocol - [MODEL_PROTOCOL_PREFIX.CLAUDE]: toOpenAIChatCompletionFromClaude, // from Claude protocol - }, - [MODEL_PROTOCOL_PREFIX.CLAUDE]: { // to Claude protocol - [MODEL_PROTOCOL_PREFIX.GEMINI]: toClaudeChatCompletionFromGemini, // from Gemini protocol - [MODEL_PROTOCOL_PREFIX.OPENAI]: toClaudeChatCompletionFromOpenAI, // from OpenAI protocol - }, - [MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES]: { // to OpenAI protocol (Responses format) - [MODEL_PROTOCOL_PREFIX.GEMINI]: toOpenAIResponsesFromGemini, // from Gemini protocol - [MODEL_PROTOCOL_PREFIX.CLAUDE]: toOpenAIResponsesFromClaude, // from Claude protocol - }, - }, - streamChunk: { - [MODEL_PROTOCOL_PREFIX.OPENAI]: { // to OpenAI protocol - [MODEL_PROTOCOL_PREFIX.GEMINI]: toOpenAIStreamChunkFromGemini, // from Gemini protocol - [MODEL_PROTOCOL_PREFIX.CLAUDE]: toOpenAIStreamChunkFromClaude, // from Claude protocol - }, - [MODEL_PROTOCOL_PREFIX.CLAUDE]: { // to Claude protocol - [MODEL_PROTOCOL_PREFIX.GEMINI]: toClaudeStreamChunkFromGemini, // from Gemini protocol - [MODEL_PROTOCOL_PREFIX.OPENAI]: toClaudeStreamChunkFromOpenAI, // from OpenAI protocol - }, - [MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES]: { // to OpenAI protocol (Responses format) - [MODEL_PROTOCOL_PREFIX.GEMINI]: toOpenAIResponsesStreamChunkFromGemini, // from Gemini protocol - [MODEL_PROTOCOL_PREFIX.CLAUDE]: toOpenAIResponsesStreamChunkFromClaude, // from Claude protocol - }, - }, - modelList: { - [MODEL_PROTOCOL_PREFIX.OPENAI]: { // to OpenAI protocol - [MODEL_PROTOCOL_PREFIX.GEMINI]: toOpenAIModelListFromGemini, // from Gemini protocol - [MODEL_PROTOCOL_PREFIX.CLAUDE]: toOpenAIModelListFromClaude, // from Claude protocol - }, - [MODEL_PROTOCOL_PREFIX.CLAUDE]: { // to Claude protocol - [MODEL_PROTOCOL_PREFIX.GEMINI]: toClaudeModelListFromGemini, // from Gemini protocol - [MODEL_PROTOCOL_PREFIX.OPENAI]: toClaudeModelListFromOpenAI, // from OpenAI protocol - }, - } - }; - - const targetConversions = conversionMap[type]; - if (!targetConversions) { - throw new Error(`Unsupported conversion type: ${type}`); - } - - const toConversions = targetConversions[getProtocolPrefix(toProvider)]; - if (!toConversions) { - throw new Error(`No conversions defined for target protocol: ${getProtocolPrefix(toProvider)} for type: ${type}`); - } - - const conversionFunction = toConversions[getProtocolPrefix(fromProvider)]; - if (!conversionFunction) { - throw new Error(`No conversion function found from ${getProtocolPrefix(fromProvider)} to ${toProvider} for type: ${type}`); - } - - logger.info(conversionFunction); - if (type === 'response' || type === 'streamChunk' || type === 'modelList') { - return conversionFunction(data, model); - } else { - return conversionFunction(data); - } -} - -// ============================================================================= -// OpenAI 相关转换函数 -// ============================================================================= - -/** - * Converts a Gemini API request body to an OpenAI chat completion request body. - * Handles system instructions and role mapping with multimodal support. - * @param {Object} geminiRequest - The request body from the Gemini API. - * @returns {Object} The formatted request body for the OpenAI API. - */ -export function toOpenAIRequestFromGemini(geminiRequest) { - const openaiRequest = { - messages: [], - model: geminiRequest.model, // Default model if not specified in Gemini request - max_tokens: checkAndAssignOrDefault(geminiRequest.max_tokens, DEFAULT_MAX_TOKENS), - temperature: checkAndAssignOrDefault(geminiRequest.temperature, DEFAULT_TEMPERATURE), - top_p: checkAndAssignOrDefault(geminiRequest.top_p, DEFAULT_TOP_P), - }; - - // Process system instruction - if (geminiRequest.systemInstruction && Array.isArray(geminiRequest.systemInstruction.parts)) { - const systemContent = processGeminiPartsToOpenAIContent(geminiRequest.systemInstruction.parts); - if (systemContent) { - openaiRequest.messages.push({ - role: 'system', - content: systemContent - }); - } - } - - // Process contents - if (geminiRequest.contents && Array.isArray(geminiRequest.contents)) { - geminiRequest.contents.forEach(content => { - if (content && Array.isArray(content.parts)) { - const openaiContent = processGeminiPartsToOpenAIContent(content.parts); - if (openaiContent && openaiContent.length > 0) { - const openaiRole = content.role === 'model' ? 'assistant' : content.role; - openaiRequest.messages.push({ - role: openaiRole, - content: openaiContent - }); - } - } - }); - } - - return openaiRequest; -} - - -/** - * Processes Gemini parts to OpenAI content format with multimodal support. - * @param {Array} parts - Array of Gemini parts. - * @returns {Array|string} OpenAI content format. - */ -function processGeminiPartsToOpenAIContent(parts) { - if (!parts || !Array.isArray(parts)) return ''; - - const contentArray = []; - - parts.forEach(part => { - if (!part) return; - - // Handle text content - if (typeof part.text === 'string') { - contentArray.push({ - type: 'text', - text: part.text - }); - } - - // Handle inline data (images, audio) - if (part.inlineData) { - const { mimeType, data } = part.inlineData; - if (mimeType && data) { - contentArray.push({ - type: 'image_url', - image_url: { - url: `data:${mimeType};base64,${data}` - } - }); - } - } - - // Handle file data - if (part.fileData) { - const { mimeType, fileUri } = part.fileData; - if (mimeType && fileUri) { - // For file URIs, we need to determine if it's an image or audio - if (mimeType.startsWith('image/')) { - contentArray.push({ - type: 'image_url', - image_url: { - url: fileUri - } - }); - } else if (mimeType.startsWith('audio/')) { - // For audio, we'll use a placeholder or handle as text description - contentArray.push({ - type: 'text', - text: `[Audio file: ${fileUri}]` - }); - } - } - } - }); - - // Return as array for multimodal, or string for simple text - return contentArray.length === 1 && contentArray[0].type === 'text' - ? contentArray[0].text - : contentArray; -} - -export function toOpenAIModelListFromGemini(geminiModels) { - return { - object: "list", - data: geminiModels.models.map(m => ({ - id: m.name.startsWith('models/') ? m.name.substring(7) : m.name, // 移除 'models/' 前缀作为 id - object: "model", - created: Math.floor(Date.now() / 1000), - owned_by: "google", - })), - }; -} - -export function toOpenAIChatCompletionFromGemini(geminiResponse, model) { - const content = processGeminiResponseContent(geminiResponse); - - return { - id: `chatcmpl-${uuidv4()}`, - object: "chat.completion", - created: Math.floor(Date.now() / 1000), - model: model, - choices: [{ - index: 0, - message: { - role: "assistant", - content: content - }, - finish_reason: "stop", - }], - usage: geminiResponse.usageMetadata ? { - prompt_tokens: geminiResponse.usageMetadata.promptTokenCount || 0, - completion_tokens: geminiResponse.usageMetadata.candidatesTokenCount || 0, - total_tokens: geminiResponse.usageMetadata.totalTokenCount || 0, - } : { - prompt_tokens: 0, - completion_tokens: 0, - total_tokens: 0, - }, - }; -} - -/** - * Processes Gemini response content to OpenAI format with multimodal support. - * @param {Object} geminiResponse - The Gemini API response. - * @returns {string|Array} Processed content. - */ -function processGeminiResponseContent(geminiResponse) { - if (!geminiResponse || !geminiResponse.candidates) return ''; - - const contents = []; - - geminiResponse.candidates.forEach(candidate => { - if (candidate.content && candidate.content.parts) { - candidate.content.parts.forEach(part => { - if (part.text) { - contents.push(part.text); - } - // Note: Gemini response typically doesn't include multimodal content in responses - // but we handle it for completeness - }); - } - }); - - return contents.join('\n'); -} - -export function toOpenAIStreamChunkFromGemini(geminiChunk, model) { - return { - id: `chatcmpl-${uuidv4()}`, // uuidv4 needs to be imported or handled - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model: model, - choices: [{ - index: 0, - delta: { content: geminiChunk }, - finish_reason: null, - }], - usage: geminiChunk.usageMetadata ? { - prompt_tokens: geminiChunk.usageMetadata.promptTokenCount || 0, - completion_tokens: geminiChunk.usageMetadata.candidatesTokenCount || 0, - total_tokens: geminiChunk.usageMetadata.totalTokenCount || 0, - } : { - prompt_tokens: 0, - completion_tokens: 0, - total_tokens: 0, - }, - }; -} - -/** - * Converts a Claude API messages response to an OpenAI chat completion response. - * @param {Object} claudeResponse - The Claude API messages response object. - * @param {string} model - The model name to include in the response. - * @returns {Object} The formatted OpenAI chat completion response. - */ -export function toOpenAIChatCompletionFromClaude(claudeResponse, model) { - if (!claudeResponse || !claudeResponse.content || claudeResponse.content.length === 0) { - return { - id: `chatcmpl-${uuidv4()}`, - object: "chat.completion", - created: Math.floor(Date.now() / 1000), - model: model, - choices: [{ - index: 0, - message: { - role: "assistant", - content: "", - }, - finish_reason: "stop", - }], - usage: { - prompt_tokens: claudeResponse.usage?.input_tokens || 0, - completion_tokens: claudeResponse.usage?.output_tokens || 0, - total_tokens: (claudeResponse.usage?.input_tokens || 0) + (claudeResponse.usage?.output_tokens || 0), - }, - }; - } - - const content = processClaudeResponseContent(claudeResponse.content); - const finishReason = claudeResponse.stop_reason === 'end_turn' ? 'stop' : claudeResponse.stop_reason; - - return { - id: `chatcmpl-${uuidv4()}`, - object: "chat.completion", - created: Math.floor(Date.now() / 1000), - model: model, - choices: [{ - index: 0, - message: { - role: "assistant", - content: content - }, - finish_reason: finishReason, - }], - usage: { - prompt_tokens: claudeResponse.usage?.input_tokens || 0, - completion_tokens: claudeResponse.usage?.output_tokens || 0, - total_tokens: (claudeResponse.usage?.input_tokens || 0) + (claudeResponse.usage?.output_tokens || 0), - }, - }; -} - -/** - * Processes Claude response content to OpenAI format with multimodal support. - * @param {Array} content - Array of Claude content blocks. - * @returns {string|Array} Processed content. - */ -function processClaudeResponseContent(content) { - if (!content || !Array.isArray(content)) return ''; - - const contentArray = []; - - content.forEach(block => { - if (!block) return; - - switch (block.type) { - case 'text': - contentArray.push({ - type: 'text', - text: block.text || '' - }); - break; - - case 'image': - // Handle image blocks from Claude - if (block.source && block.source.type === 'base64') { - contentArray.push({ - type: 'image_url', - image_url: { - url: `data:${block.source.media_type};base64,${block.source.data}` - } - }); - } - break; - - default: - // Handle other content types as text - if (block.text) { - contentArray.push({ - type: 'text', - text: block.text - }); - } - } - }); - - // Return as array for multimodal, or string for simple text - return contentArray.length === 1 && contentArray[0].type === 'text' - ? contentArray[0].text - : contentArray; -} - -/** - * Converts a Claude API messages stream chunk to an OpenAI chat completion stream chunk. - * Based on the official Claude Messages API stream events. - * @param {Object} claudeChunk - The Claude API messages stream chunk object. - * @param {string} [model] - Optional model name to include in the response. - * @returns {Object} The formatted OpenAI chat completion stream chunk, or an empty object for events that don't map. - */ -export function toOpenAIStreamChunkFromClaude(claudeChunk, model) { - if (!claudeChunk) { - return null; - } - return { - id: `chatcmpl-${uuidv4()}`, // uuidv4 needs to be imported or handled - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model: model, - system_fingerprint: "", - choices: [{ - index: 0, - delta: { - content: claudeChunk, - reasoning_content: "" - }, - finish_reason: !claudeChunk ? 'stop' : null, - message: { - content: claudeChunk, - reasoning_content: "" - } - }], - usage:{ - prompt_tokens: 0, - completion_tokens: 0, - total_tokens: 0, - }, - }; -} - -/** - * Converts a Claude API model list response to an OpenAI model list response. - * @param {Array} claudeModels - The array of model objects from Claude API. - * @returns {Object} The formatted OpenAI model list response. - */ -export function toOpenAIModelListFromClaude(claudeModels) { - return { - object: "list", - data: claudeModels.models.map(m => ({ - id: m.id || m.name, // Claude models might use 'name' instead of 'id' - object: "model", - created: Math.floor(Date.now() / 1000), // Claude may not provide 'created' timestamp - owned_by: "anthropic", - // You can add more properties here if they exist in Claude's model response - // and you want to map them to OpenAI's format, e.g., permissions. - })), - }; -} - -/** - * Converts an OpenAI chat completion response to a Claude API messages response. - * @param {Object} openaiResponse - The OpenAI API chat completion response object. - * @param {string} model - The model name to include in the response. - * @returns {Object} The formatted Claude API messages response. - */ -export function toClaudeChatCompletionFromOpenAI(openaiResponse, model) { - if (!openaiResponse || !openaiResponse.choices || openaiResponse.choices.length === 0) { - return { - id: `msg_${uuidv4()}`, - type: "message", - role: "assistant", - content: [], - model: model, - stop_reason: "end_turn", - stop_sequence: null, - usage: { - input_tokens: openaiResponse?.usage?.prompt_tokens || 0, - output_tokens: openaiResponse?.usage?.completion_tokens || 0 - } - }; - } - - const choice = openaiResponse.choices[0]; - const contentList = []; - - // Handle tool calls - const toolCalls = choice.message?.tool_calls || []; - for (const toolCall of toolCalls.filter(tc => tc && typeof tc === 'object')) { - if (toolCall.function) { - const func = toolCall.function; - const argStr = func.arguments || "{}"; - let argObj; - try { - argObj = typeof argStr === 'string' ? JSON.parse(argStr) : argStr; - } catch (e) { - argObj = {}; - } - contentList.push({ - type: "tool_use", - id: toolCall.id || "", - name: func.name || "", - input: argObj, - }); - } - } - - // Handle text content - const contentText = choice.message?.content || ""; - if (contentText) { - // 使用 _extractThinkingFromOpenAIText 提取 thinking 内容 - const extractedContent = _extractThinkingFromOpenAIText(contentText); - if (Array.isArray(extractedContent)) { - contentList.push(...extractedContent); - } else { - contentList.push({ type: "text", text: extractedContent }); - } - } - - // Map OpenAI finish reason to Claude stop reason - const stopReason = _mapFinishReason( - choice.finish_reason || "stop", - "openai", - "anthropic" - ); - - return { - id: `msg_${uuidv4()}`, - type: "message", - role: "assistant", - content: contentList, - model: model, - stop_reason: stopReason, - stop_sequence: null, - usage: { - input_tokens: openaiResponse.usage?.prompt_tokens || 0, - output_tokens: openaiResponse.usage?.completion_tokens || 0 - } - }; -} - -/** - * Converts a Claude API request body to an OpenAI chat completion request body. - * Handles system instructions and multimodal content. - * @param {Object} claudeRequest - The request body from the Claude API. - * @returns {Object} The formatted request body for the OpenAI API. - */ -export function toOpenAIRequestFromClaude(claudeRequest) { - const openaiMessages = []; - let systemMessageContent = ''; - - // Add system message if present - if (claudeRequest.system) { - systemMessageContent = claudeRequest.system; - } - - // Process messages - if (claudeRequest.messages && Array.isArray(claudeRequest.messages)) { - const tempOpenAIMessages = []; - for (const msg of claudeRequest.messages) { - const role = msg.role; - - // 处理用户的工具结果消息 - if (role === "user" && Array.isArray(msg.content)) { - const hasToolResult = msg.content.some( - item => item && typeof item === 'object' && item.type === "tool_result" - ); - - if (hasToolResult) { - for (const item of msg.content) { - if (item && typeof item === 'object' && item.type === "tool_result") { - const toolUseId = item.tool_use_id || item.id || ""; - const contentStr = String(item.content || ""); - tempOpenAIMessages.push({ - role: "tool", - tool_call_id: toolUseId, - content: contentStr, - }); - } - } - continue; // 已处理工具结果,跳过后续处理 - } - } - - // 处理 assistant 消息中的工具调用 - if (role === "assistant" && Array.isArray(msg.content) && msg.content.length > 0) { - const firstPart = msg.content[0]; - if (firstPart.type === "tool_use") { - const funcName = firstPart.name || ""; - const funcArgs = firstPart.input || {}; - tempOpenAIMessages.push({ - role: "assistant", - content: '', - tool_calls: [ - { - id: firstPart.id || `call_${funcName}_1`, - type: "function", - function: { - name: funcName, - arguments: JSON.stringify(funcArgs) - }, - index: firstPart.index || 0 - } - ] - }); - continue; // 已处理 - } - } - - // 普通文本消息 - const contentConverted = processClaudeContentToOpenAIContent(msg.content || ""); - // 跳过空消息,避免在历史中插入空字符串导致模型误判 - if (contentConverted && (Array.isArray(contentConverted) ? contentConverted.length > 0 : contentConverted.trim().length > 0)) { - tempOpenAIMessages.push({ - role: role, - content: contentConverted - }); - } - } - - // ---------------- OpenAI 兼容性校验 ---------------- - // 确保所有 assistant.tool_calls 均有后续 tool 响应消息;否则移除不匹配的 tool_call - const validatedMessages = []; - for (let idx = 0; idx < tempOpenAIMessages.length; idx++) { - const m = tempOpenAIMessages[idx]; - if (m.role === "assistant" && m.tool_calls) { - const callIds = m.tool_calls.map(tc => tc.id).filter(id => id); - // 统计后续是否有对应的 tool 消息 - let unmatched = new Set(callIds); - for (let laterIdx = idx + 1; laterIdx < tempOpenAIMessages.length; laterIdx++) { - const later = tempOpenAIMessages[laterIdx]; - if (later.role === "tool" && unmatched.has(later.tool_call_id)) { - unmatched.delete(later.tool_call_id); - } - if (unmatched.size === 0) { - break; - } - } - if (unmatched.size > 0) { - // 移除无匹配的 tool_call - m.tool_calls = m.tool_calls.filter(tc => !unmatched.has(tc.id)); - // 如果全部被移除,则降级为普通 assistant 文本消息 - if (m.tool_calls.length === 0) { - delete m.tool_calls; - if (m.content === null) { - m.content = ""; - } - } - } - } - validatedMessages.push(m); - } - openaiMessages.push(...validatedMessages); - } - - const openaiRequest = { - model: claudeRequest.model, // Default OpenAI model - messages: openaiMessages, - max_tokens: checkAndAssignOrDefault(claudeRequest.max_tokens, DEFAULT_MAX_TOKENS), - temperature: checkAndAssignOrDefault(claudeRequest.temperature, DEFAULT_TEMPERATURE), - top_p: checkAndAssignOrDefault(claudeRequest.top_p, DEFAULT_TOP_P), - stream: claudeRequest.stream, // Stream mode is handled by different endpoint - }; - - // Process tools - if (claudeRequest.tools) { - const openaiTools = []; - for (const tool of claudeRequest.tools) { - openaiTools.push({ - type: "function", - function: { - name: tool.name || "", - description: tool.description || "", - parameters: _cleanJsonSchemaProperties(tool.input_schema || {}) // 使用清理函数 - } - }); - } - openaiRequest.tools = openaiTools; - openaiRequest.tool_choice = "auto"; - } - - // 处理思考预算转换 (Anthropic thinking -> OpenAI reasoning_effort + max_completion_tokens) - if (claudeRequest.thinking && claudeRequest.thinking.type === "enabled") { - const budgetTokens = claudeRequest.thinking.budget_tokens; - // 根据budget_tokens智能判断reasoning_effort等级 - const reasoningEffort = _determineReasoningEffortFromBudget(budgetTokens); - openaiRequest.reasoning_effort = reasoningEffort; - - // 处理max_completion_tokens的优先级逻辑 - let maxCompletionTokens = null; - - // 优先级1:客户端传入的max_tokens - if (claudeRequest.max_tokens !== undefined) { - maxCompletionTokens = claudeRequest.max_tokens; - delete openaiRequest.max_tokens; // 移除max_tokens,使用max_completion_tokens - logger.info(`Using client max_tokens as max_completion_tokens: ${maxCompletionTokens}`); - } else { - // 优先级2:环境变量OPENAI_REASONING_MAX_TOKENS - const envMaxTokens = process.env.OPENAI_REASONING_MAX_TOKENS; - if (envMaxTokens) { - try { - maxCompletionTokens = parseInt(envMaxTokens, 10); - logger.info(`Using OPENAI_REASONING_MAX_TOKENS from environment: ${maxCompletionTokens}`); - } catch (e) { - logger.warn(`Invalid OPENAI_REASONING_MAX_TOKENS value '${envMaxTokens}', must be integer`); - } - } - - if (!envMaxTokens) { - // 优先级3:都没有则报错 - throw new Error("For OpenAI reasoning models, max_completion_tokens is required. Please specify max_tokens in the request or set OPENAI_REASONING_MAX_TOKENS environment variable."); - } - } - openaiRequest.max_completion_tokens = maxCompletionTokens; - logger.info(`Anthropic thinking enabled -> OpenAI reasoning_effort='${reasoningEffort}', max_completion_tokens=${maxCompletionTokens}`); - if (budgetTokens) { - logger.info(`Budget tokens: ${budgetTokens} -> reasoning_effort: '${reasoningEffort}'`); - } - } - - // Add system message at the beginning if present - if (systemMessageContent) { - let stringifiedSystemMessageContent = systemMessageContent; - if(Array.isArray(systemMessageContent)){ - stringifiedSystemMessageContent = systemMessageContent.map(item => - typeof item === 'string' ? item : item.text).join('\n'); - } - openaiRequest.messages.unshift({ role: 'system', content: stringifiedSystemMessageContent }); - } - - return openaiRequest; -} - - -/** - * Processes Claude content to OpenAI content format with multimodal support. - * @param {Array} content - Array of Claude content blocks. - * @returns {Array} OpenAI content format. - */ -function processClaudeContentToOpenAIContent(content) { - if (!content || !Array.isArray(content)) return []; - - const contentArray = []; - - content.forEach(block => { - if (!block) return; - - switch (block.type) { - case 'text': - if (block.text) { - contentArray.push({ - type: 'text', - text: block.text - }); - } - break; - - case 'image': - // Handle image blocks from Claude - if (block.source && block.source.type === 'base64') { - contentArray.push({ - type: 'image_url', - image_url: { - url: `data:${block.source.media_type};base64,${block.source.data}` - } - }); - } - break; - - case 'tool_use': - // Handle tool use as text - contentArray.push({ - type: 'text', - text: `[Tool use: ${block.name}]` - }); - break; - - case 'tool_result': - // Handle tool results as text - contentArray.push({ - type: 'text', - text: typeof block.content === 'string' ? block.content : JSON.stringify(block.content) - }); - break; - - default: - // Handle any other content types as text - if (block.text) { - contentArray.push({ - type: 'text', - text: block.text - }); - } - } - }); - - return contentArray; -} - -// ============================================================================= -// Gemini 相关转换函数 -// ============================================================================= - -/** - * Converts an OpenAI chat completion request body to a Gemini API request body. - * Handles system instructions and merges consecutive messages of the same role with multimodal support. - * @param {Object} openaiRequest - The request body from the OpenAI API. - * @returns {Object} The formatted request body for the Gemini API. - */ -export function toGeminiRequestFromOpenAI(openaiRequest) { - const messages = openaiRequest.messages || []; - const { systemInstruction, nonSystemMessages } = extractAndProcessSystemMessages(messages); - - // Process messages with role conversion and multimodal support - const processedMessages = []; - let lastMessage = null; - - for (const message of nonSystemMessages) { - const geminiRole = message.role === 'assistant' ? 'model' : message.role; - - // Handle tool responses - if (geminiRole === 'tool') { - if (lastMessage) processedMessages.push(lastMessage); - processedMessages.push({ - role: 'function', - parts: [{ - functionResponse: { - name: message.name, - response: { content: safeParseJSON(message.content) } - } - }] - }); - lastMessage = null; - continue; - } - - // Process multimodal content - const processedContent = processOpenAIContentToGeminiParts(message.content); - - // Merge consecutive text messages - if (lastMessage && lastMessage.role === geminiRole && !message.tool_calls && - Array.isArray(processedContent) && processedContent.every(p => p.text) && - Array.isArray(lastMessage.parts) && lastMessage.parts.every(p => p.text)) { - lastMessage.parts.push(...processedContent); - continue; - } - - if (lastMessage) processedMessages.push(lastMessage); - lastMessage = { role: geminiRole, parts: processedContent }; - } - if (lastMessage) processedMessages.push(lastMessage); - - // Build Gemini request - const geminiRequest = { - contents: processedMessages.filter(item => item.parts && item.parts.length > 0) - }; - - if (systemInstruction) geminiRequest.systemInstruction = systemInstruction; - - // Handle tools - if (openaiRequest.tools?.length) { - geminiRequest.tools = [{ - functionDeclarations: openaiRequest.tools.map(t => { - // Ensure tool is a valid object and has function property - if (!t || typeof t !== 'object' || !t.function) { - logger.warn("Skipping invalid tool declaration in openaiRequest.tools."); - return null; // Return null for invalid tools, filter out later - } - - const func = t.function; - // Clean parameters schema for Gemini compatibility - const parameters = _cleanJsonSchemaProperties(func.parameters || {}); - - return { - name: String(func.name || ''), // Ensure name is string - description: String(func.description || ''), // Ensure description is string - parameters: parameters // Use cleaned parameters - }; - }).filter(Boolean) // Filter out any nulls from invalid tool declarations - }]; - // If no valid functionDeclarations, remove the tools array - if (geminiRequest.tools[0].functionDeclarations.length === 0) { - delete geminiRequest.tools; - } - } - - if (openaiRequest.tool_choice) { - geminiRequest.toolConfig = buildToolConfig(openaiRequest.tool_choice); - } - - // Add generation config - const config = buildGenerationConfig(openaiRequest); - if (Object.keys(config).length) geminiRequest.generationConfig = config; - - // Validation - if (geminiRequest.contents[0]?.role !== 'user') { - logger.warn(`[Request Conversion] Warning: Conversation does not start with a 'user' role.`); - } - - return geminiRequest; -} - -/** - * Processes OpenAI content to Gemini parts format with multimodal support. - * @param {string|Array} content - OpenAI message content. - * @returns {Array} Array of Gemini parts. - */ -function processOpenAIContentToGeminiParts(content) { - if (!content) return []; - - // Handle string content - if (typeof content === 'string') { - return [{ text: content }]; - } - - // Handle array content (multimodal) - if (Array.isArray(content)) { - const parts = []; - - content.forEach(item => { - if (!item) return; - - switch (item.type) { - case 'text': - if (item.text) { - parts.push({ text: item.text }); - } - break; - - case 'image_url': - if (item.image_url) { - const imageUrl = typeof item.image_url === 'string' - ? item.image_url - : item.image_url.url; - - if (imageUrl.startsWith('data:')) { - // Handle base64 data URL - const [header, data] = imageUrl.split(','); - const mimeType = header.match(/data:([^;]+)/)?.[1] || 'image/jpeg'; - parts.push({ - inlineData: { - mimeType, - data - } - }); - } else { - // Handle regular URL - parts.push({ - fileData: { - mimeType: 'image/jpeg', // Default MIME type - fileUri: imageUrl - } - }); - } - } - break; - - case 'audio': - // Handle audio content - if (item.audio_url) { - const audioUrl = typeof item.audio_url === 'string' - ? item.audio_url - : item.audio_url.url; - - if (audioUrl.startsWith('data:')) { - const [header, data] = audioUrl.split(','); - const mimeType = header.match(/data:([^;]+)/)?.[1] || 'audio/wav'; - parts.push({ - inlineData: { - mimeType, - data - } - }); - } else { - parts.push({ - fileData: { - mimeType: 'audio/wav', // Default MIME type - fileUri: audioUrl - } - }); - } - } - break; - } - }); - - return parts; - } - - return []; -} - -function safeParseJSON(str) { - if (!str) { - return str; - } - let cleanedStr = str; - - // 处理可能被截断的转义序列 - if (cleanedStr.endsWith('\\') && !cleanedStr.endsWith('\\\\')) { - cleanedStr = cleanedStr.substring(0, cleanedStr.length - 1); // 移除悬挂的反斜杠 - } else if (cleanedStr.endsWith('\\u') || cleanedStr.endsWith('\\u0') || cleanedStr.endsWith('\\u00')) { - // 不完整的Unicode转义序列 - const idx = cleanedStr.lastIndexOf('\\u'); - cleanedStr = cleanedStr.substring(0, idx); - } - - try { - return JSON.parse(cleanedStr || '{}'); - } catch (e) { - // 如果清理后仍然无法解析,则返回原始字符串或进行其他错误处理 - return str; - } -} - -function buildToolConfig(toolChoice) { - if (typeof toolChoice === 'string' && ['none', 'auto'].includes(toolChoice)) { - return { functionCallingConfig: { mode: toolChoice.toUpperCase() } }; - } - if (typeof toolChoice === 'object' && toolChoice.function) { - return { functionCallingConfig: { mode: 'ANY', allowedFunctionNames: [toolChoice.function.name] } }; - } - return null; -} - -/** - * 根据 tool_result 字段构造 Gemini functionResponse - * @param {Object} item - 工具结果项 - * @returns {Object|null} functionResponse 对象 - */ -function _buildFunctionResponse(item) { - if (!item || typeof item !== 'object') { - return null; - } - - // 判定是否为工具结果 - const isResult = ( - item.type === "tool_result" || - item.tool_use_id !== undefined || - item.tool_output !== undefined || - item.result !== undefined || - item.content !== undefined - ); - if (!isResult) { - return null; - } - - // 提取函数名 - let funcName = null; - - // 方法1:从映射表中获取(Anthropic格式) - const toolUseId = item.tool_use_id || item.id; - // 这里需要注意,AnthropicConverter内部维护的_toolUseMapping是类的私有属性,在convert.js中无法直接访问 - // 因此,这里需要依赖全局的toolStateManager - // if (toolUseId && this._toolUseMapping) { // 这行代码在convert.js中将无法使用 - // funcName = this._toolUseMapping[toolUseId]; - // } - - // 方法1.5:使用全局工具状态管理器 - if (!funcName && toolUseId) { - // 先尝试从ID中提取可能的函数名 - let potentialFuncName = null; - if (String(toolUseId).startsWith("call_")) { - const nameAndHash = toolUseId.substring(4); // 去掉 "call_" 前缀 - potentialFuncName = nameAndHash.substring(0, nameAndHash.lastIndexOf("_")); - } - - // 检查全局管理器中是否有对应的映射 - if (potentialFuncName) { - const storedId = toolStateManager.getToolId(potentialFuncName); - if (storedId === toolUseId) { - funcName = potentialFuncName; - } - } - } - - // 方法2:从 tool_use_id 中提取(OpenAI格式) - if (!funcName && toolUseId && String(toolUseId).startsWith("call_")) { - // 格式: call__ ,函数名可能包含多个下划线 - const nameAndHash = toolUseId.substring(4); // 去掉 "call_" 前缀 - funcName = nameAndHash.substring(0, nameAndHash.lastIndexOf("_")); // 去掉最后一个 hash 段 - } - - // 方法3:直接从字段获取 - if (!funcName) { - funcName = ( - item.tool_name || - item.name || - item.function_name - ); - } - - if (!funcName) { - return null; - } - - // 提取结果内容 - let funcResponse = null; - - // 尝试多个可能的结果字段 - for (const key of ["content", "tool_output", "output", "response", "result"]) { - if (item[key] !== undefined) { - funcResponse = item[key]; - break; - } - } - - // 如果 content 是列表,尝试提取文本 - if (Array.isArray(funcResponse) && funcResponse.length > 0) { - const textParts = funcResponse - .filter(p => p && typeof p === 'object' && p.type === "text") - .map(p => p.text || ""); - if (textParts.length > 0) { - funcResponse = textParts.join(""); - } - } - - // 确保有响应内容 - if (funcResponse === null || funcResponse === undefined) { - funcResponse = ""; - } - - // Gemini 要求 response 为 JSON 对象,若为原始字符串则包装 - if (typeof funcResponse !== 'object') { - funcResponse = { content: String(funcResponse) }; - } - - return { - functionResponse: { - name: funcName, - response: funcResponse - } - }; -} - -/** - * Converts a Gemini API model list response to a Claude API model list response. - * @param {Object} geminiModels - The Gemini API model list response object. - * @returns {Object} The formatted Claude API model list response. - */ -export function toClaudeModelListFromGemini(geminiModels) { - return { - models: geminiModels.models.map(m => ({ - name: m.name.startsWith('models/') ? m.name.substring(7) : m.name, // 移除 'models/' 前缀作为 name - // Claude models 可能包含其他字段,这里使用默认值 - description: "", // Gemini models 不提供描述 - // Claude API 可能需要其他字段,根据实际 API 文档调整 - })), - }; -} - -/** - * Converts an OpenAI API model list response to a Claude API model list response. - * @param {Object} openaiModels - The OpenAI API model list response object. - * @returns {Object} The formatted Claude API model list response. - */ -export function toClaudeModelListFromOpenAI(openaiModels) { - return { - models: openaiModels.data.map(m => ({ - name: m.id, // OpenAI 的 id 映射为 Claude 的 name - // Claude models 可能包含其他字段,这里使用默认值 - description: "", // OpenAI models 不提供描述 - // Claude API 可能需要其他字段,根据实际 API 文档调整 - })), - }; -} - -/** - * 从OpenAI文本中提取thinking内容,返回Anthropic格式的content blocks - * @param {string} text - 文本内容 - * @returns {string|Array} 提取后的内容 - */ -function _extractThinkingFromOpenAIText(text) { - // 匹配 ... 标签 - const thinkingPattern = /\s*(.*?)\s*<\/thinking>/gs; - const matches = [...text.matchAll(thinkingPattern)]; - - const contentBlocks = []; - let lastEnd = 0; - - for (const match of matches) { - // 添加thinking标签之前的文本(如果有) - const beforeText = text.substring(lastEnd, match.index).trim(); - if (beforeText) { - contentBlocks.push({ - type: "text", - text: beforeText - }); - } - - // 添加thinking内容 - const thinkingText = match[1].trim(); - if (thinkingText) { - contentBlocks.push({ - type: "thinking", - thinking: thinkingText - }); - } - - lastEnd = match.index + match[0].length; - } - - // 添加最后一个thinking标签之后的文本(如果有) - const afterText = text.substring(lastEnd).trim(); - if (afterText) { - contentBlocks.push({ - type: "text", - text: afterText - }); - } - - // 如果没有找到thinking标签,返回原文本 - if (contentBlocks.length === 0) { - return text; - } - - // 如果只有一个文本块,返回字符串 - if (contentBlocks.length === 1 && contentBlocks[0].type === "text") { - return contentBlocks[0].text; - } - - return contentBlocks; -} - -/** - * Converts an OpenAI chat completion stream chunk to a Claude API messages stream chunk. - * @param {Object} openaiChunk - The OpenAI API chat completion stream chunk object. - * @param {string} [model] - Optional model name to include in the response. - * @returns {Object} The formatted Claude API messages stream chunk. - */ -export function toClaudeStreamChunkFromOpenAI(openaiChunk, model) { - if (!openaiChunk) { - return null; - } - - // 工具调用 - if ( Array.isArray(openaiChunk)) { - const toolCall = openaiChunk[0]; // 假设每次只处理一个工具调用 - if (toolCall) { - if (toolCall.function && toolCall.function.name) { - const toolUseBlock = { - type: "tool_use", - id: toolCall.id || `call_${toolCall.function.name}_${Date.now()}`, - name: toolCall.function.name, - input: toolCall.function.arguments ? JSON.parse(toolCall.function.arguments) : {} - }; - return { type: "content_block_start", index: 1, content_block: toolUseBlock }; - } - } - } - - // 文本内容 - if (typeof openaiChunk === 'string') { - return { - type: "content_block_delta", - index: 0, - delta: { - type: "text_delta", - text: openaiChunk - } - }; - } - return null; -} - -function buildGenerationConfig({ temperature, max_tokens, top_p, stop }) { - const config = {}; - config.temperature = checkAndAssignOrDefault(temperature, DEFAULT_TEMPERATURE); - config.maxOutputTokens = checkAndAssignOrDefault(max_tokens, DEFAULT_GEMINI_MAX_TOKENS); - config.topP = checkAndAssignOrDefault(top_p, DEFAULT_TOP_P); - if (stop !== undefined) config.stopSequences = Array.isArray(stop) ? stop : [stop]; - return config; -} - -/** - * Converts an OpenAI chat completion request body to a Claude API request body. - * Handles system instructions, tool calls, and multimodal content. - * @param {Object} openaiRequest - The request body from the OpenAI API. - * @returns {Object} The formatted request body for the Claude API. - */ -export function toClaudeRequestFromOpenAI(openaiRequest) { - const messages = openaiRequest.messages || []; - const { systemInstruction, nonSystemMessages } = extractAndProcessSystemMessages(messages); - - const claudeMessages = []; - - for (const message of nonSystemMessages) { - const role = message.role === 'assistant' ? 'assistant' : 'user'; - let content = []; - - if (message.role === 'tool') { - // Claude expects tool_result to be in a 'user' message - // The content of a tool message is a single tool_result block - content.push({ - type: 'tool_result', - tool_use_id: message.tool_call_id, // Use tool_call_id from OpenAI tool message - content: safeParseJSON(message.content) // Parse content as JSON if possible - }); - claudeMessages.push({ role: 'user', content: content }); - } else if (message.role === 'assistant' && message.tool_calls?.length) { - // Assistant message with tool calls - properly format as tool_use blocks - // Claude expects tool_use to be in an 'assistant' message - const toolUseBlocks = message.tool_calls.map(tc => ({ - type: 'tool_use', - id: tc.id, - name: tc.function.name, - input: safeParseJSON(tc.function.arguments) - })); - claudeMessages.push({ role: 'assistant', content: toolUseBlocks }); - } else { - // Regular user or assistant message (text and multimodal) - if (typeof message.content === 'string') { - if (message.content) { - content.push({ type: 'text', text: message.content }); - } - } else if (Array.isArray(message.content)) { - message.content.forEach(item => { - if (!item) return; - switch (item.type) { - case 'text': - if (item.text) { - content.push({ type: 'text', text: item.text }); - } - break; - case 'image_url': - if (item.image_url) { - const imageUrl = typeof item.image_url === 'string' - ? item.image_url - : item.image_url.url; - if (imageUrl.startsWith('data:')) { - const [header, data] = imageUrl.split(','); - const mediaType = header.match(/data:([^;]+)/)?.[1] || 'image/jpeg'; - content.push({ - type: 'image', - source: { - type: 'base64', - media_type: mediaType, - data: data - } - }); - } else { - // Claude requires base64 for images, so for URLs, we'll represent as text - content.push({ type: 'text', text: `[Image: ${imageUrl}]` }); - } - } - break; - case 'audio': - // Handle audio content as text placeholder - if (item.audio_url) { - const audioUrl = typeof item.audio_url === 'string' - ? item.audio_url - : item.audio_url.url; - content.push({ type: 'text', text: `[Audio: ${audioUrl}]` }); - } - break; - } - }); - } - // Only add message if content is not empty - if (content.length > 0) { - claudeMessages.push({ role: role, content: content }); - } - } - } - - const claudeRequest = { - model: openaiRequest.model, - messages: claudeMessages, - max_tokens: checkAndAssignOrDefault(openaiRequest.max_tokens, DEFAULT_MAX_TOKENS), - temperature: checkAndAssignOrDefault(openaiRequest.temperature, DEFAULT_TEMPERATURE), - top_p: checkAndAssignOrDefault(openaiRequest.top_p, DEFAULT_TOP_P), - }; - - if (systemInstruction) { - claudeRequest.system = extractTextFromMessageContent(systemInstruction.parts[0].text); - } - - if (openaiRequest.tools?.length) { - claudeRequest.tools = openaiRequest.tools.map(t => ({ - name: t.function.name, - description: t.function.description || '', - input_schema: t.function.parameters || { type: 'object', properties: {} } - })); - claudeRequest.tool_choice = buildClaudeToolChoice(openaiRequest.tool_choice); - } - - return claudeRequest; -} - -function buildClaudeToolChoice(toolChoice) { - if (typeof toolChoice === 'string') { - const mapping = { auto: 'auto', none: 'none', required: 'any' }; - return { type: mapping[toolChoice] }; - } - if (typeof toolChoice === 'object' && toolChoice.function) { - return { type: 'tool', name: toolChoice.function.name }; - } - return undefined; -} - -/** - * Extracts and combines all 'system' role messages into a single system instruction. - * Filters out system messages and returns the remaining non-system messages. - * @param {Array} messages - Array of message objects from OpenAI request. - * @returns {{systemInstruction: Object|null, nonSystemMessages: Array}} - * An object containing the system instruction and an array of non-system messages. - */ -export function extractAndProcessSystemMessages(messages) { - const systemContents = []; - const nonSystemMessages = []; - - for (const message of messages) { - if (message.role === 'system') { - systemContents.push(extractTextFromMessageContent(message.content)); - } else { - nonSystemMessages.push(message); - } - } - - let systemInstruction = null; - if (systemContents.length > 0) { - systemInstruction = { - parts: [{ - text: systemContents.join('\n') - }] - }; - } - return { systemInstruction, nonSystemMessages }; -} - -/** - * Extracts text from various forms of message content. - * @param {string|Array} content - The content from a message object. - * @returns {string} The extracted text. - */ -export function extractTextFromMessageContent(content) { - if (typeof content === 'string') { - return content; - } - if (Array.isArray(content)) { - return content - .filter(part => part.type === 'text' && part.text) - .map(part => part.text) - .join('\n'); - } - return ''; -} - -/** - * Converts a Claude API request body to a Gemini API request body. - * Handles system instructions and multimodal content. - * @param {Object} claudeRequest - The request body from the Claude API. - * @returns {Object} The formatted request body for the Gemini API. - */ -export function toGeminiRequestFromClaude(claudeRequest) { - // Ensure claudeRequest is a valid object - if (!claudeRequest || typeof claudeRequest !== 'object') { - logger.warn("Invalid claudeRequest provided to toGeminiRequestFromClaude."); - return { contents: [] }; - } - - const geminiRequest = { - contents: [] - }; - - // Handle system instruction - if (claudeRequest.system) { - let incomingSystemText = null; - if (typeof claudeRequest.system === 'string') { - incomingSystemText = claudeRequest.system; - } else if (typeof claudeRequest.system === 'object') { - incomingSystemText = JSON.stringify(claudeRequest.system); - } else if (claudeRequest.messages?.length > 0) { - // Fallback to first user message if no system property - const userMessage = claudeRequest.messages.find(m => m.role === 'user'); - if (userMessage) { - if (Array.isArray(userMessage.content)) { - incomingSystemText = userMessage.content.map(block => block.text).join(''); - } else { - incomingSystemText = userMessage.content; - } - } - } - geminiRequest.systemInstruction = { - parts: [{ text: incomingSystemText}] // Ensure system is string - }; - } - - // Process messages - if (Array.isArray(claudeRequest.messages)) { - claudeRequest.messages.forEach(message => { - // Ensure message is a valid object and has a role and content - if (!message || typeof message !== 'object' || !message.role || !message.content) { - logger.warn("Skipping invalid message in claudeRequest.messages."); - return; - } - - const geminiRole = message.role === 'assistant' ? 'model' : 'user'; - const processedParts = processClaudeContentToGeminiParts(message.content); - - // If the processed parts contain a function response, it should be a 'function' role message - // Claude's tool_result block does not contain the function name, only tool_use_id. - // We need to infer the function name from the previous tool_use message. - // For simplicity in this conversion, we'll assume the tool_use_id is the function name - // or that the tool_result is always preceded by a tool_use with the correct name. - // A more robust solution would involve tracking tool_use_ids to function names. - const functionResponsePart = processedParts.find(part => part.functionResponse); - if (functionResponsePart) { - geminiRequest.contents.push({ - role: 'function', - parts: [functionResponsePart] - }); - } else if (processedParts.length > 0) { // Only push if there are actual parts - geminiRequest.contents.push({ - role: geminiRole, - parts: processedParts - }); - } - }); - } - - // Add generation config - const generationConfig = {}; - generationConfig.maxOutputTokens = checkAndAssignOrDefault(claudeRequest.max_tokens, DEFAULT_GEMINI_MAX_TOKENS); - generationConfig.temperature = checkAndAssignOrDefault(claudeRequest.temperature, DEFAULT_TEMPERATURE); - generationConfig.topP = checkAndAssignOrDefault(claudeRequest.top_p, DEFAULT_TOP_P); - - if (Object.keys(generationConfig).length > 0) { - geminiRequest.generationConfig = generationConfig; - } - - // Handle tools - if (Array.isArray(claudeRequest.tools)) { - geminiRequest.tools = [{ - functionDeclarations: claudeRequest.tools.map(tool => { - // Ensure tool is a valid object and has a name - if (!tool || typeof tool !== 'object' || !tool.name) { - logger.warn("Skipping invalid tool declaration in claudeRequest.tools."); - return null; // Return null for invalid tools, filter out later - } - - // Filter out TodoWrite tool - // if (tool.name === 'TodoWrite') { - // logger.info("Filtering out TodoWrite tool"); - // return null; - // } - - delete tool.input_schema.$schema; - return { - name: String(tool.name), // Ensure name is string - description: String(tool.description || ''), // Ensure description is string - parameters: tool.input_schema && typeof tool.input_schema === 'object' ? tool.input_schema : { type: 'object', properties: {} } - }; - }).filter(Boolean) // Filter out any nulls from invalid tool declarations - }]; - // If no valid functionDeclarations, remove the tools array - if (geminiRequest.tools[0].functionDeclarations.length === 0) { - delete geminiRequest.tools; - } - } - - // Handle tool_choice - if (claudeRequest.tool_choice) { - geminiRequest.toolConfig = buildGeminiToolConfigFromClaude(claudeRequest.tool_choice); - } - - return geminiRequest; -} - -/** - * Builds Gemini toolConfig from Claude tool_choice. - * @param {Object} claudeToolChoice - The tool_choice object from Claude API. - * @returns {Object|undefined} The formatted toolConfig for Gemini API, or undefined if invalid. - */ -function buildGeminiToolConfigFromClaude(claudeToolChoice) { - if (!claudeToolChoice || typeof claudeToolChoice !== 'object' || !claudeToolChoice.type) { - logger.warn("Invalid claudeToolChoice provided to buildGeminiToolConfigFromClaude."); - return undefined; - } - - switch (claudeToolChoice.type) { - case 'auto': - return { functionCallingConfig: { mode: 'AUTO' } }; - case 'none': - return { functionCallingConfig: { mode: 'NONE' } }; - case 'tool': - if (claudeToolChoice.name && typeof claudeToolChoice.name === 'string') { - return { functionCallingConfig: { mode: 'ANY', allowedFunctionNames: [claudeToolChoice.name] } }; - } - logger.warn("Invalid tool name in claudeToolChoice of type 'tool'."); - return undefined; - default: - logger.warn(`Unsupported claudeToolChoice type: ${claudeToolChoice.type}`); - return undefined; - } -} - -/** - * Processes Claude content to Gemini parts format with multimodal support. - * @param {string|Array} content - Claude message content. - * @returns {Array} Array of Gemini parts. - */ -function processClaudeContentToGeminiParts(content) { - if (!content) return []; - - // Handle string content - if (typeof content === 'string') { - return [{ text: content }]; - } - - // Handle array content (multimodal) - if (Array.isArray(content)) { - const parts = []; - - content.forEach(block => { - // Ensure block is a valid object and has a type - if (!block || typeof block !== 'object' || !block.type) { - logger.warn("Skipping invalid content block in processClaudeContentToGeminiParts."); - return; - } - - switch (block.type) { - case 'text': - if (typeof block.text === 'string') { - parts.push({ text: block.text }); - } else { - logger.warn("Invalid text content in Claude text block."); - } - break; - - case 'image': - if (block.source && typeof block.source === 'object' && block.source.type === 'base64' && - typeof block.source.media_type === 'string' && typeof block.source.data === 'string') { - parts.push({ - inlineData: { - mimeType: block.source.media_type, - data: block.source.data - } - }); - } else { - logger.warn("Invalid image source in Claude image block."); - } - break; - - case 'tool_use': - if (typeof block.name === 'string' && block.input && typeof block.input === 'object') { - // Filter out TodoWrite tool use - // if (block.name === 'TodoWrite') { - // logger.info("Filtering out TodoWrite tool use"); - // break; // Skip adding this tool to parts - // } - parts.push({ - functionCall: { - name: block.name, - args: block.input - } - }); - } else { - logger.warn("Invalid tool_use block in Claude content."); - } - break; - - case 'tool_result': - // Claude's tool_result block does not contain the function name, only tool_use_id. - // Gemini's functionResponse requires a function name. - // For now, we'll use the tool_use_id as the name, but this is a potential point of failure - // if the tool_use_id is not the actual function name in Gemini's context. - // A more robust solution would involve tracking the function name from the tool_use block. - if (typeof block.tool_use_id === 'string') { - parts.push({ - functionResponse: { - name: block.tool_use_id, // This might need to be the actual function name - response: { content: block.content } // content can be any JSON-serializable value - } - }); - } else { - logger.warn("Invalid tool_result block in Claude content: missing tool_use_id."); - } - break; - - default: - // Handle any other content types as text if they have a text property - if (typeof block.text === 'string') { - parts.push({ text: block.text }); - } else { - logger.warn(`Unsupported Claude content block type: ${block.type}. Skipping.`); - } - } - }); - - return parts; - } - - return []; -} - -/** - * Converts a Gemini API response to a Claude API messages response. - * @param {Object} geminiResponse - The Gemini API response object. - * @param {string} model - The model name to include in the response. - * @returns {Object} The formatted Claude API messages response. - */ -export function toClaudeChatCompletionFromGemini(geminiResponse, model) { - // Handle cases where geminiResponse or candidates are missing or empty - if (!geminiResponse || !geminiResponse.candidates || geminiResponse.candidates.length === 0) { - return { - id: `msg_${uuidv4()}`, - type: "message", - role: "assistant", - content: [], // Empty content for no candidates - model: model, - stop_reason: "end_turn", // Default stop reason - stop_sequence: null, - usage: { - input_tokens: geminiResponse?.usageMetadata?.promptTokenCount || 0, - output_tokens: geminiResponse?.usageMetadata?.candidatesTokenCount || 0 - } - }; - } - - const candidate = geminiResponse.candidates[0]; - const content = processGeminiResponseToClaudeContent(geminiResponse); - const finishReason = candidate.finishReason; - let stopReason = "end_turn"; // Default stop reason - - if (finishReason) { - switch (finishReason) { - case 'STOP': - stopReason = 'end_turn'; - break; - case 'MAX_TOKENS': - stopReason = 'max_tokens'; - break; - case 'SAFETY': - stopReason = 'safety'; - break; - case 'RECITATION': - stopReason = 'recitation'; - break; - case 'OTHER': - stopReason = 'other'; - break; - default: - stopReason = 'end_turn'; - } - } - - return { - id: `msg_${uuidv4()}`, - type: "message", - role: "assistant", - content: content, - model: model, - stop_reason: stopReason, - stop_sequence: null, - usage: { - input_tokens: geminiResponse.usageMetadata?.promptTokenCount || 0, - output_tokens: geminiResponse.usageMetadata?.candidatesTokenCount || 0 - } - }; -} - -/** - * Processes Gemini response content to Claude format. - * @param {Object} geminiResponse - The Gemini API response. - * @returns {Array} Array of Claude content blocks. - */ -function processGeminiResponseToClaudeContent(geminiResponse) { - if (!geminiResponse || !geminiResponse.candidates || geminiResponse.candidates.length === 0) return []; - - const content = []; - - for (const candidate of geminiResponse.candidates) { - // 检查完成原因是否为错误类型 - if (candidate.finishReason && candidate.finishReason !== 'STOP') { - // logger.info('Gemini response finishReason:', JSON.stringify(candidate)); - // logger.warn('Gemini response contains malformed function call:', candidate.finishMessage || 'No finish message'); - - // 将错误信息作为文本内容返回 - if (candidate.finishMessage) { - content.push({ - type: 'text', - text: `Error: ${candidate.finishMessage}` - }); - } - // logger.info("Processed content:", content); - continue; // 跳过当前候选的进一步处理 - } - - if (candidate.content && candidate.content.parts) { - for (const part of candidate.content.parts) { - if (part.text) { - content.push({ - type: 'text', - text: part.text - }); - } else if (part.inlineData) { - content.push({ - type: 'image', - source: { - type: 'base64', - media_type: part.inlineData.mimeType, - data: part.inlineData.data - } - }); - } else if (part.functionCall) { - // Convert Gemini functionCall to Claude tool_use - content.push({ - type: 'tool_use', - id: uuidv4(), // Generate a new ID for the tool use - name: part.functionCall.name, - input: part.functionCall.args || {} - }); - } - } - } - } - - return content; -} - -/** - * Converts a Gemini API stream chunk to a Claude API messages stream chunk. - * @param {Object} geminiChunk - The Gemini API stream chunk object. - * @param {string} [model] - Optional model name to include in the response. - * @returns {Object} The formatted Claude API messages stream chunk. - */ -export function toClaudeStreamChunkFromGemini(geminiChunk, model) { - if (!geminiChunk) { - return null; - } - - if (typeof geminiChunk === 'string') { - return { - type: "content_block_delta", - index: 0, - delta: { - type: "text_delta", - text: geminiChunk - } - }; - } - - return null; -} - - -/** - * Converts a Claude API response to an OpenAI Responses API response. - * @param {Object} claudeResponse - The Claude API response object. - * @param {string} model - The model name to include in the response. - * @returns {Object} The formatted OpenAI Responses API response. - */ -export function toOpenAIResponsesFromClaude(claudeResponse, model) { - // 根据参考示例重构响应结构 - const content = processClaudeResponseContent(claudeResponse.content); - const textContent = typeof content === 'string' ? content : JSON.stringify(content); - - // 将claude的内容转换为OpenAI Responses输出格式 - let output = []; - - // 添加文本内容 - output.push({ - type: "message", - id: `msg_${uuidv4().replace(/-/g, '')}`, - summary: [], - type: "message", - role: "assistant", - status: "completed", - content: [{ - annotations: [], - logprobs: [], - text: textContent, - type: "output_text" - }] - }); - - return { - background: false, - created_at: Math.floor(Date.now() / 1000), - error: null, - id: `resp_${uuidv4().replace(/-/g, '')}`, - incomplete_details: null, - max_output_tokens: null, - max_tool_calls: null, - metadata: {}, - model: model || claudeResponse.model, - object: "response", - output: output, - parallel_tool_calls: true, - previous_response_id: null, - prompt_cache_key: null, - reasoning: { - // effort: "minimal", - // summary: "detailed" - }, - safety_identifier: "user-"+uuidv4().replace(/-/g, ''), // 示例值 - service_tier: "default", - status: "completed", - store: false, - temperature: 1, - text: { - format: {type: "text"}, - // verbosity: "medium" - }, - tool_choice: "auto", - tools: [], - top_logprobs: 0, - top_p: 1, - truncation: "disabled", - usage: { - input_tokens: claudeResponse.usage?.input_tokens || 0, // 示例值 - input_tokens_details: { - cached_tokens: claudeResponse.usage?.cache_creation_input_tokens || 0, // 如果有缓存相关数据则使用 - }, - output_tokens: claudeResponse.usage?.output_tokens || 0, // 示例值 - output_tokens_details: { - reasoning_tokens: 0 - }, - total_tokens: (claudeResponse.usage?.input_tokens || 0) + (claudeResponse.usage?.output_tokens || 0) // 示例值 - }, - user: null - }; -} - -/** - * Converts a Gemini API response to an OpenAI Responses API response. - * @param {Object} geminiResponse - The Gemini API response object. - * @param {string} model - The model name to include in the response. - * @returns {Object} The formatted OpenAI Responses API response. - */ -export function toOpenAIResponsesFromGemini(geminiResponse, model) { - // 根据参考示例重构响应结构 - const content = processGeminiResponseContent(geminiResponse); - const textContent = typeof content === 'string' ? content : JSON.stringify(content); - - // 将gemini的内容转换为OpenAI Responses输出格式 - let output = []; - - // 添加文本内容 - output.push({ - id: `msg_${uuidv4().replace(/-/g, '')}`, - summary: [], - type: "message", - role: "assistant", - status: "completed", - content: [{ - annotations: [], - logprobs: [], - text: textContent, - type: "output_text" - }] - }); - - return { - background: false, - created_at: Math.floor(Date.now() / 1000), - error: null, - id: `resp_${uuidv4().replace(/-/g, '')}`, - incomplete_details: null, - max_output_tokens: null, - max_tool_calls: null, - metadata: {}, - model: model, - object: "response", - output: output, - parallel_tool_calls: true, - previous_response_id: null, - prompt_cache_key: null, - reasoning: { - // effort: "minimal", - // summary: "detailed" - }, - safety_identifier: "user-"+uuidv4().replace(/-/g, ''), // 示例值 - service_tier: "default", - status: "completed", - store: false, - temperature: 1, - text: { - format: {type: "text"}, - // verbosity: "medium" - }, - tool_choice: "auto", - tools: [], - top_logprobs: 0, - top_p: 1, - truncation: "disabled", - usage: { - input_tokens: geminiResponse.usageMetadata?.promptTokenCount || 0, // 示例值 - input_tokens_details: { - cached_tokens: geminiResponse.usageMetadata?.cachedTokens || 0, // 使用正确的Gemini缓存字段 - }, - output_tokens: geminiResponse.usageMetadata?.candidatesTokenCount || 0, // 示例值 - output_tokens_details: { - reasoning_tokens: 0 - }, - total_tokens: geminiResponse.usageMetadata?.totalTokenCount || 0, // 示例值 - }, - user: null - }; -} - - -/** - * Converts an OpenAI Responses API request body to a Claude API request body. - * @param {Object} responsesRequest - The request body from the OpenAI Responses API. - * @returns {Object} The formatted request body for the Claude API. - */ -export function toClaudeRequestFromOpenAIResponses(responsesRequest) { - // The OpenAI Responses API uses input and instructions instead of messages - const claudeRequest = { - model: responsesRequest.model, - max_tokens: checkAndAssignOrDefault(responsesRequest.max_tokens, DEFAULT_MAX_TOKENS), - temperature: checkAndAssignOrDefault(responsesRequest.temperature, DEFAULT_TEMPERATURE), - top_p: checkAndAssignOrDefault(responsesRequest.top_p, DEFAULT_TOP_P), - }; - - // Process instructions as system message - if (responsesRequest.instructions) { - claudeRequest.system = []; - claudeRequest.system.push({ - text: typeof responsesRequest.instructions === 'string' ? responsesRequest.instructions : JSON.stringify(responsesRequest.instructions) - }); - - } - - const claudeMessages = []; - // Process input as user message content - if (responsesRequest.input) { - if (typeof responsesRequest.input === 'string') { - // Create user message with the string content - claudeMessages.push({ - role: 'user', - content: [{ - type: 'text', - text: responsesRequest.input - }] - }); - } else { - // Handle array of messages or items - process the entire array - for (const message of responsesRequest.input) { - const role = message.role === 'assistant' ? 'assistant' : 'user'; - let content = []; - - if (message.role === 'tool') { - // Claude expects tool_result to be in a 'user' message - // The content of a tool message is a single tool_result block - content.push({ - type: 'tool_result', - tool_use_id: message.tool_call_id, // Use tool_call_id from OpenAI tool message - content: safeParseJSON(message.content) // Parse content as JSON if possible - }); - claudeMessages.push({ role: 'user', content: content }); - } else if (message.role === 'assistant' && message.tool_calls?.length) { - // Assistant message with tool calls - properly format as tool_use blocks - // Claude expects tool_use to be in an 'assistant' message - const toolUseBlocks = message.tool_calls.map(tc => ({ - type: 'tool_use', - id: tc.id, - name: tc.function.name, - input: safeParseJSON(tc.function.arguments) - })); - claudeMessages.push({ role: 'assistant', content: toolUseBlocks }); - } else { - // Regular user or assistant message (text and multimodal) - if (typeof message.content === 'string') { - if (message.content) { - content.push({ type: 'text', text: message.content }); - } - } else if (Array.isArray(message.content)) { - message.content.forEach(item => { - if (!item) return; - switch (item.type) { - case 'input_text': - if (item.text) { - content.push({ type: 'text', text: item.text }); - } - break; - case 'output_text': - if (item.text) { - content.push({ type: 'text', text: item.text }); - } - break; - case 'image_url': - if (item.image_url) { - const imageUrl = typeof item.image_url === 'string' - ? item.image_url - : item.image_url.url; - if (imageUrl.startsWith('data:')) { - const [header, data] = imageUrl.split(','); - const mediaType = header.match(/data:([^;]+)/)?.[1] || 'image/jpeg'; - content.push({ - type: 'image', - source: { - type: 'base64', - media_type: mediaType, - data: data - } - }); - } else { - // Claude requires base64 for images, so for URLs, we'll represent as text - content.push({ type: 'text', text: `[Image: ${imageUrl}]` }); - } - } - break; - case 'audio': - // Handle audio content as text placeholder - if (item.audio_url) { - const audioUrl = typeof item.audio_url === 'string' - ? item.audio_url - : item.audio_url.url; - content.push({ type: 'text', text: `[Audio: ${audioUrl}]` }); - } - break; - } - }); - } - // Only add message if content is not empty - if (content.length > 0) { - claudeMessages.push({ role: role, content: content }); - } - } - } - } - } - - // Process tools if present - // if (responsesRequest.tools && Array.isArray(responsesRequest.tools)) { - // claudeRequest.tools = responsesRequest.tools.map(tool => ({ - // name: tool.name, - // description: tool.description || '', - // input_schema: tool.parameters || { type: 'object', properties: {} } - // })); - // claudeRequest.tool_choice = buildClaudeToolChoice(responsesRequest.tool_choice); - // } - - // Process messages - claudeRequest.messages = claudeMessages; - claudeRequest.stream = responsesRequest.stream || false; - return claudeRequest; -} - -/** - * Converts an OpenAI Responses API request body to a Gemini API request body. - * @param {Object} responsesRequest - The request body from the OpenAI Responses API. - * @returns {Object} The formatted request body for the Gemini API. - */ -export function toGeminiRequestFromOpenAIResponses(responsesRequest) { - // The OpenAI Responses API uses input and instructions instead of messages - const geminiRequest = { - contents: [] - }; - - // Process instructions as system instruction - if (responsesRequest.instructions) { - let instructionsText = ''; - if (typeof responsesRequest.instructions === 'string') { - instructionsText = responsesRequest.instructions; - } else { - instructionsText = JSON.stringify(responsesRequest.instructions); - } - geminiRequest.systemInstruction = { - parts: [{ text: instructionsText }] - }; - } - - // Process input as user content - if (responsesRequest.input) { - let inputContent = ''; - if (typeof responsesRequest.input === 'string') { - inputContent = responsesRequest.input; - } else if (Array.isArray(responsesRequest.input)) { - // Handle array of messages or items - if (responsesRequest.input.length > 0) { - // For compatibility, take the content of the last item with text content - const lastInputItem = [...responsesRequest.input].reverse().find(item => - item && ( - (item.content && typeof item.content === 'string') || - (item.content && Array.isArray(item.content) && item.content.some(c => c && c.text)) || - (item.role === 'user' && item.content) - ) - ); - - if (lastInputItem) { - if (typeof lastInputItem.content === 'string') { - inputContent = lastInputItem.content; - } else if (Array.isArray(lastInputItem.content)) { - // Process array of content blocks - inputContent = lastInputItem.content - .filter(block => block && block.text) - .map(block => block.text) - .join(' '); - } else { - // General fallback - inputContent = JSON.stringify(lastInputItem.content || lastInputItem); - } - } - } - } - - if (inputContent) { - // Add user message with the input content - geminiRequest.contents.push({ - role: 'user', - parts: [{ text: inputContent }] - }); - } - } else { - // If no input is provided, ensure we have at least one user message for Gemini - geminiRequest.contents.push({ - role: 'user', - parts: [{ text: 'Hello' }] // Default content to satisfy Gemini API requirement - }); - } - - // Add generation config - const generationConfig = {}; - generationConfig.maxOutputTokens = checkAndAssignOrDefault(responsesRequest.max_tokens, DEFAULT_GEMINI_MAX_TOKENS); - generationConfig.temperature = checkAndAssignOrDefault(responsesRequest.temperature, DEFAULT_TEMPERATURE); - generationConfig.topP = checkAndAssignOrDefault(responsesRequest.top_p, DEFAULT_TOP_P); - - if (Object.keys(generationConfig).length > 0) { - geminiRequest.generationConfig = generationConfig; - } - - // Process tools if present - if (responsesRequest.tools && Array.isArray(responsesRequest.tools)) { - geminiRequest.tools = [{ - functionDeclarations: responsesRequest.tools - .filter(tool => tool && (tool.type === 'function' || tool.function)) - .map(tool => { - const func = tool.function || tool; - return { - name: String(func.name || tool.name || ''), - description: String(func.description || tool.description || ''), - parameters: func.parameters || tool.parameters || { type: 'object', properties: {} } - }; - }).filter(Boolean) // Filter out any invalid tools - }]; - - // If no valid functionDeclarations, remove the tools array - if (geminiRequest.tools[0].functionDeclarations.length === 0) { - delete geminiRequest.tools; - } - } - - return geminiRequest; -} - -/** - * Converts a Claude API stream chunk to an OpenAI Responses API stream chunk. - * @param {Object} claudeChunk - The Claude API stream chunk object. - * @param {string} [model] - Optional model name to include in the response. - * @param {string} [requestId] - Optional request ID to maintain stream state across chunks. - * @returns {Array} The formatted OpenAI Responses API stream chunks as an array of events. - */ -export function toOpenAIResponsesStreamChunkFromClaude(claudeChunk, model, requestId = null) { - if (!claudeChunk) { - return []; - } - - // 如果没有提供requestId,则生成一个(首次调用时) - const id = requestId || Date.now().toString(); - - // 设置模型信息(仅在新请求时设置) - if (!requestId) { - streamStateManager.setModel(id, model); - } - - // Handle text content from Claude stream - let content = ''; - if (typeof claudeChunk === 'string') { - content = claudeChunk; - } else if (claudeChunk && typeof claudeChunk === 'object' && claudeChunk.delta?.text) { - content = claudeChunk.delta.text; - } else if (claudeChunk && typeof claudeChunk === 'object') { - content = claudeChunk; - } - - // 对于第一个数据块(fullText为空),生成开始事件 - const state = streamStateManager.getOrCreateState(id); - if (state.fullText === '' && !requestId) { // 只在首次调用时(未指定requestId时)生成开始事件 - // 在这种情况下,我们需要先添加内容到状态 - state.fullText = content; - return [ - // ...getOpenAIResponsesStreamChunkBegin(id, model), - generateOutputTextDelta(id, content), - // ...getOpenAIResponsesStreamChunkEnd(id) - ]; - } else if (content === '') { - // 如果是结束块,生成结束事件 - const doneEvents = getOpenAIResponsesStreamChunkEnd(id); - - // 清理状态 - streamStateManager.cleanup(id); - - return doneEvents; - } else { - // 中间数据块,只返回delta事件,但也要更新状态 - streamStateManager.updateText(id, content); - return [ - generateOutputTextDelta(id, content) - ]; - } -} - -/** - * Converts a Gemini API stream chunk to an OpenAI Responses API stream chunk. - * @param {Object} geminiChunk - The Gemini API stream chunk object. - * @param {string} [model] - Optional model name to include in the response. - * @param {string} [requestId] - Optional request ID to maintain stream state across chunks. - * @returns {Array} The formatted OpenAI Responses API stream chunks as an array of events. - */ -export function toOpenAIResponsesStreamChunkFromGemini(geminiChunk, model, requestId = null) { - if (!geminiChunk) { - return []; - } - - // 如果没有提供requestId,则生成一个(首次调用时) - const id = requestId || Date.now().toString(); - - // 设置模型信息(仅在新请求时设置) - if (!requestId) { - streamStateManager.setModel(id, model); - } - - // Handle text content in stream - let content = ''; - if (typeof geminiChunk === 'string') { - content = geminiChunk; - } else if (geminiChunk && typeof geminiChunk === 'object') { - // Extract content from Gemini chunk if it's an object - content = geminiChunk.content || geminiChunk.text || geminiChunk; - } - - // 对于第一个数据块(fullText为空),生成开始事件 - const state = streamStateManager.getOrCreateState(id); - if (state.fullText === '' && !requestId) { // 只在首次调用时(未指定requestId时)生成开始事件 - // 在这种情况下,我们需要先添加内容到状态 - state.fullText = content; - return [ - // ...getOpenAIResponsesStreamChunkBegin(id, model), - generateOutputTextDelta(id, content), - // ...getOpenAIResponsesStreamChunkEnd(id) - ]; - } else if (content === '') { - // 如果是结束块,生成结束事件 - const doneEvents = getOpenAIResponsesStreamChunkEnd(id); - - // 清理状态 - streamStateManager.cleanup(id); - - return doneEvents; - } else { - // 中间数据块,只返回delta事件,但也要更新状态 - streamStateManager.updateText(id, content); - return [ - generateOutputTextDelta(id, content) - ]; - } -} - -export function getOpenAIStreamChunkStop(model) { - return { - id: `chatcmpl-${uuidv4()}`, // uuidv4 needs to be imported or handled - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model: model, - system_fingerprint: "", - choices: [{ - index: 0, - delta: { - content: "", - reasoning_content: "" - }, - finish_reason: 'stop', - message: { - content: "", - reasoning_content: "" - } - }], - usage:{ - prompt_tokens: 0, - completion_tokens: 0, - total_tokens: 0, - }, - }; -} - -export function getOpenAIResponsesStreamChunkBegin(id, model){ - - return [ - generateResponseCreated(id, model), - generateResponseInProgress(id), - generateOutputItemAdded(id), - generateContentPartAdded(id) - ]; -} - -export function getOpenAIResponsesStreamChunkEnd(id){ - - return [ - generateOutputTextDone(id), - generateContentPartDone(id), - generateOutputItemDone(id), - generateResponseCompleted(id) - ]; -} - - diff --git a/src/convert/convert.js b/src/convert/convert.js deleted file mode 100644 index bb74633bb2c02fde231ee3d4037ce844c62d3660..0000000000000000000000000000000000000000 --- a/src/convert/convert.js +++ /dev/null @@ -1,392 +0,0 @@ -/** - * 协议转换模块 - 新架构版本 - * 使用重构后的转换器架构 - * - * 这个文件展示了如何使用新的转换器架构 - * 可以逐步替换原有的 convert.js - */ - -import { v4 as uuidv4 } from 'uuid'; -import logger from '../utils/logger.js'; -import { MODEL_PROTOCOL_PREFIX, getProtocolPrefix } from '../utils/common.js'; -import { ConverterFactory } from '../converters/ConverterFactory.js'; -import { - generateResponseCreated, - generateResponseInProgress, - generateOutputItemAdded, - generateContentPartAdded, - generateOutputTextDone, - generateContentPartDone, - generateOutputItemDone, - generateResponseCompleted -} from '../providers/openai/openai-responses-core.mjs'; - -// ============================================================================= -// 初始化:注册所有转换器 -// ============================================================================= - -// ============================================================================= -// 主转换函数 -// ============================================================================= - -/** - * 通用数据转换函数(新架构版本) - * @param {object} data - 要转换的数据(请求体或响应) - * @param {string} type - 转换类型:'request', 'response', 'streamChunk', 'modelList' - * @param {string} fromProvider - 源模型提供商 - * @param {string} toProvider - 目标模型提供商 - * @param {string} [model] - 可选的模型名称(用于响应转换) - * @returns {object} 转换后的数据 - * @throws {Error} 如果找不到合适的转换函数 - */ -export function convertData(data, type, fromProvider, toProvider, model, requestId) { - try { - // 获取协议前缀 - const fromProtocol = getProtocolPrefix(fromProvider); - const toProtocol = getProtocolPrefix(toProvider); - - // 如果目标协议为 forward,直接返回原始数据,无需转换 - if (toProtocol === MODEL_PROTOCOL_PREFIX.FORWARD || fromProtocol === MODEL_PROTOCOL_PREFIX.FORWARD) { - logger.info(`[Convert] Target protocol is forward, skipping conversion`); - return data; - } - - // 从工厂获取转换器 - const converter = ConverterFactory.getConverter(fromProtocol); - - if (!converter) { - throw new Error(`No converter found for protocol: ${fromProtocol}`); - } - - // 根据类型调用相应的转换方法 - switch (type) { - case 'request': - return converter.convertRequest(data, toProtocol); - - case 'response': - return converter.convertResponse(data, toProtocol, model); - - case 'streamChunk': - return converter.convertStreamChunk(data, toProtocol, model, requestId); - - case 'modelList': - return converter.convertModelList(data, toProtocol); - - default: - throw new Error(`Unsupported conversion type: ${type}`); - } - } catch (error) { - logger.error(`Conversion error: ${error.message}`); - throw error; - } -} - -// ============================================================================= -// 向后兼容的导出函数 -// ============================================================================= - -/** - * 以下函数保持与原有API的兼容性 - * 内部使用新的转换器架构 - */ - -// OpenAI 相关转换 -export function toOpenAIRequestFromGemini(geminiRequest) { - const converter = ConverterFactory.getConverter(MODEL_PROTOCOL_PREFIX.GEMINI); - return converter.toOpenAIRequest(geminiRequest); -} - -export function toOpenAIRequestFromClaude(claudeRequest) { - const converter = ConverterFactory.getConverter(MODEL_PROTOCOL_PREFIX.CLAUDE); - return converter.toOpenAIRequest(claudeRequest); -} - -export function toOpenAIChatCompletionFromGemini(geminiResponse, model) { - const converter = ConverterFactory.getConverter(MODEL_PROTOCOL_PREFIX.GEMINI); - return converter.toOpenAIResponse(geminiResponse, model); -} - -export function toOpenAIChatCompletionFromClaude(claudeResponse, model) { - const converter = ConverterFactory.getConverter(MODEL_PROTOCOL_PREFIX.CLAUDE); - return converter.toOpenAIResponse(claudeResponse, model); -} - -export function toOpenAIStreamChunkFromGemini(geminiChunk, model) { - const converter = ConverterFactory.getConverter(MODEL_PROTOCOL_PREFIX.GEMINI); - return converter.toOpenAIStreamChunk(geminiChunk, model); -} - -export function toOpenAIStreamChunkFromClaude(claudeChunk, model) { - const converter = ConverterFactory.getConverter(MODEL_PROTOCOL_PREFIX.CLAUDE); - return converter.toOpenAIStreamChunk(claudeChunk, model); -} - -export function toOpenAIModelListFromGemini(geminiModels) { - const converter = ConverterFactory.getConverter(MODEL_PROTOCOL_PREFIX.GEMINI); - return converter.toOpenAIModelList(geminiModels); -} - -export function toOpenAIModelListFromClaude(claudeModels) { - const converter = ConverterFactory.getConverter(MODEL_PROTOCOL_PREFIX.CLAUDE); - return converter.toOpenAIModelList(claudeModels); -} - -// Claude 相关转换 -export function toClaudeRequestFromOpenAI(openaiRequest) { - const converter = ConverterFactory.getConverter(MODEL_PROTOCOL_PREFIX.OPENAI); - return converter.toClaudeRequest(openaiRequest); -} - -export function toClaudeRequestFromOpenAIResponses(responsesRequest) { - const converter = ConverterFactory.getConverter(MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES); - return converter.toClaudeRequest(responsesRequest); -} - -export function toClaudeChatCompletionFromOpenAI(openaiResponse, model) { - const converter = ConverterFactory.getConverter(MODEL_PROTOCOL_PREFIX.OPENAI); - return converter.toClaudeResponse(openaiResponse, model); -} - -export function toClaudeChatCompletionFromGemini(geminiResponse, model) { - const converter = ConverterFactory.getConverter(MODEL_PROTOCOL_PREFIX.GEMINI); - return converter.toClaudeResponse(geminiResponse, model); -} - -export function toClaudeStreamChunkFromOpenAI(openaiChunk, model) { - const converter = ConverterFactory.getConverter(MODEL_PROTOCOL_PREFIX.OPENAI); - return converter.toClaudeStreamChunk(openaiChunk, model); -} - -export function toClaudeStreamChunkFromGemini(geminiChunk, model) { - const converter = ConverterFactory.getConverter(MODEL_PROTOCOL_PREFIX.GEMINI); - return converter.toClaudeStreamChunk(geminiChunk, model); -} - -export function toClaudeModelListFromOpenAI(openaiModels) { - const converter = ConverterFactory.getConverter(MODEL_PROTOCOL_PREFIX.OPENAI); - return converter.toClaudeModelList(openaiModels); -} - -export function toClaudeModelListFromGemini(geminiModels) { - const converter = ConverterFactory.getConverter(MODEL_PROTOCOL_PREFIX.GEMINI); - return converter.toClaudeModelList(geminiModels); -} - -// Gemini 相关转换 -export function toGeminiRequestFromOpenAI(openaiRequest) { - const converter = ConverterFactory.getConverter(MODEL_PROTOCOL_PREFIX.OPENAI); - return converter.toGeminiRequest(openaiRequest); -} - -export function toGeminiRequestFromClaude(claudeRequest) { - const converter = ConverterFactory.getConverter(MODEL_PROTOCOL_PREFIX.CLAUDE); - return converter.toGeminiRequest(claudeRequest); -} - -export function toGeminiRequestFromOpenAIResponses(responsesRequest) { - const converter = ConverterFactory.getConverter(MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES); - return converter.toGeminiRequest(responsesRequest); -} - -// OpenAI Responses 相关转换 -export function toOpenAIResponsesFromOpenAI(openaiResponse, model) { - const converter = ConverterFactory.getConverter(MODEL_PROTOCOL_PREFIX.OPENAI); - return converter.toOpenAIResponsesResponse(openaiResponse, model); -} - -export function toOpenAIResponsesFromClaude(claudeResponse, model) { - const converter = ConverterFactory.getConverter(MODEL_PROTOCOL_PREFIX.CLAUDE); - return converter.toOpenAIResponsesResponse(claudeResponse, model); -} - -export function toOpenAIResponsesFromGemini(geminiResponse, model) { - const converter = ConverterFactory.getConverter(MODEL_PROTOCOL_PREFIX.GEMINI); - return converter.toOpenAIResponsesResponse(geminiResponse, model); -} - -export function toOpenAIResponsesStreamChunkFromOpenAI(openaiChunk, model, requestId) { - const converter = ConverterFactory.getConverter(MODEL_PROTOCOL_PREFIX.OPENAI); - return converter.toOpenAIResponsesStreamChunk(openaiChunk, model, requestId); -} - -export function toOpenAIResponsesStreamChunkFromClaude(claudeChunk, model, requestId) { - const converter = ConverterFactory.getConverter(MODEL_PROTOCOL_PREFIX.CLAUDE); - return converter.toOpenAIResponsesStreamChunk(claudeChunk, model, requestId); -} - -export function toOpenAIResponsesStreamChunkFromGemini(geminiChunk, model, requestId) { - const converter = ConverterFactory.getConverter(MODEL_PROTOCOL_PREFIX.GEMINI); - return converter.toOpenAIResponsesStreamChunk(geminiChunk, model, requestId); -} - -// 从 OpenAI Responses 转换到其他格式 -export function toOpenAIRequestFromOpenAIResponses(responsesRequest) { - const converter = ConverterFactory.getConverter(MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES); - return converter.toOpenAIRequest(responsesRequest); -} - -export function toOpenAIChatCompletionFromOpenAIResponses(responsesResponse, model) { - const converter = ConverterFactory.getConverter(MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES); - return converter.toOpenAIResponse(responsesResponse, model); -} - -export function toOpenAIStreamChunkFromOpenAIResponses(responsesChunk, model) { - const converter = ConverterFactory.getConverter(MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES); - return converter.toOpenAIStreamChunk(responsesChunk, model); -} - -// 辅助函数导出 -export async function extractAndProcessSystemMessages(messages) { - const { Utils } = await import('../converters/utils.js'); - return Utils.extractSystemMessages(messages); -} - -export async function extractTextFromMessageContent(content) { - const { Utils } = await import('../converters/utils.js'); - return Utils.extractText(content); -} - -// ============================================================================= -// 工具函数 -// ============================================================================= - -/** - * 获取所有已注册的协议 - * @returns {Array} 协议前缀数组 - */ -export function getRegisteredProtocols() { - return ConverterFactory.getRegisteredProtocols(); -} - -/** - * 检查协议是否已注册 - * @param {string} protocol - 协议前缀 - * @returns {boolean} 是否已注册 - */ -export function isProtocolRegistered(protocol) { - return ConverterFactory.isProtocolRegistered(protocol); -} - -/** - * 清除所有转换器缓存 - */ -export function clearConverterCache() { - ConverterFactory.clearCache(); -} - -/** - * 获取转换器实例(用于高级用法) - * @param {string} protocol - 协议前缀 - * @returns {BaseConverter} 转换器实例 - */ -export function getConverter(protocol) { - return ConverterFactory.getConverter(protocol); -} - -// ============================================================================= -// 辅助函数 - 从原 convert.js 迁移 -// ============================================================================= - -/** - * 生成 OpenAI 流式响应的停止块 - * @param {string} model - 模型名称 - * @returns {Object} OpenAI 流式停止块 - */ -export function getOpenAIStreamChunkStop(model) { - return { - id: `chatcmpl-${uuidv4()}`, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model: model, - system_fingerprint: "", - choices: [{ - index: 0, - delta: { - content: "", - reasoning_content: "" - }, - finish_reason: 'stop', - message: { - content: "", - reasoning_content: "" - } - }], - usage:{ - prompt_tokens: 0, - completion_tokens: 0, - total_tokens: 0, - }, - }; -} - -/** - * 生成 OpenAI Responses 流式响应的开始事件 - * @param {string} id - 响应 ID - * @param {string} model - 模型名称 - * @returns {Array} 开始事件数组 - */ -export function getOpenAIResponsesStreamChunkBegin(id, model) { - return [ - generateResponseCreated(id, model), - generateResponseInProgress(id), - generateOutputItemAdded(id), - generateContentPartAdded(id) - ]; -} - -/** - * 生成 OpenAI Responses 流式响应的结束事件 - * @param {string} id - 响应 ID - * @returns {Array} 结束事件数组 - */ -export function getOpenAIResponsesStreamChunkEnd(id) { - return [ - generateOutputTextDone(id), - generateContentPartDone(id), - generateOutputItemDone(id), - generateResponseCompleted(id) - ]; -} - -// ============================================================================= -// 默认导出 -// ============================================================================= - -export default { - convertData, - getRegisteredProtocols, - isProtocolRegistered, - clearConverterCache, - getConverter, - // 向后兼容的函数 - toOpenAIRequestFromGemini, - toOpenAIRequestFromClaude, - toOpenAIChatCompletionFromGemini, - toOpenAIChatCompletionFromClaude, - toOpenAIStreamChunkFromGemini, - toOpenAIStreamChunkFromClaude, - toOpenAIModelListFromGemini, - toOpenAIModelListFromClaude, - toClaudeRequestFromOpenAI, - toClaudeChatCompletionFromOpenAI, - toClaudeChatCompletionFromGemini, - toClaudeStreamChunkFromOpenAI, - toClaudeStreamChunkFromGemini, - toClaudeModelListFromOpenAI, - toClaudeModelListFromGemini, - toGeminiRequestFromOpenAI, - toGeminiRequestFromClaude, - toOpenAIResponsesFromOpenAI, - toOpenAIResponsesFromClaude, - toOpenAIResponsesFromGemini, - toOpenAIResponsesStreamChunkFromOpenAI, - toOpenAIResponsesStreamChunkFromClaude, - toOpenAIResponsesStreamChunkFromGemini, - toOpenAIRequestFromOpenAIResponses, - toOpenAIChatCompletionFromOpenAIResponses, - toOpenAIStreamChunkFromOpenAIResponses, - toClaudeRequestFromOpenAIResponses, - toGeminiRequestFromOpenAIResponses, -}; - - diff --git a/src/converters/BaseConverter.js b/src/converters/BaseConverter.js deleted file mode 100644 index e75735bd297ca2dd2e2cfeae0a5fc51a0291f089..0000000000000000000000000000000000000000 --- a/src/converters/BaseConverter.js +++ /dev/null @@ -1,115 +0,0 @@ -/** - * 转换器基类 - * 使用策略模式定义转换器的通用接口 - */ - -/** - * 抽象转换器基类 - * 所有具体的协议转换器都应继承此类 - */ -export class BaseConverter { - constructor(protocolName) { - if (new.target === BaseConverter) { - throw new Error('BaseConverter是抽象类,不能直接实例化'); - } - this.protocolName = protocolName; - } - - /** - * 转换请求 - * @param {Object} data - 请求数据 - * @param {string} targetProtocol - 目标协议 - * @returns {Object} 转换后的请求 - */ - convertRequest(data, targetProtocol) { - throw new Error('convertRequest方法必须被子类实现'); - } - - /** - * 转换响应 - * @param {Object} data - 响应数据 - * @param {string} targetProtocol - 目标协议 - * @param {string} model - 模型名称 - * @returns {Object} 转换后的响应 - */ - convertResponse(data, targetProtocol, model) { - throw new Error('convertResponse方法必须被子类实现'); - } - - /** - * 转换流式响应块 - * @param {Object} chunk - 流式响应块 - * @param {string} targetProtocol - 目标协议 - * @param {string} model - 模型名称 - * @returns {Object} 转换后的流式响应块 - */ - convertStreamChunk(chunk, targetProtocol, model) { - throw new Error('convertStreamChunk方法必须被子类实现'); - } - - /** - * 转换模型列表 - * @param {Object} data - 模型列表数据 - * @param {string} targetProtocol - 目标协议 - * @returns {Object} 转换后的模型列表 - */ - convertModelList(data, targetProtocol) { - throw new Error('convertModelList方法必须被子类实现'); - } - - /** - * 获取协议名称 - * @returns {string} 协议名称 - */ - getProtocolName() { - return this.protocolName; - } -} - -/** - * 内容处理器接口 - * 用于处理不同类型的内容(文本、图片、音频等) - */ -export class ContentProcessor { - /** - * 处理内容 - * @param {*} content - 内容数据 - * @returns {*} 处理后的内容 - */ - process(content) { - throw new Error('process方法必须被子类实现'); - } -} - -/** - * 工具处理器接口 - * 用于处理工具调用相关的转换 - */ -export class ToolProcessor { - /** - * 处理工具定义 - * @param {Array} tools - 工具定义数组 - * @returns {Array} 处理后的工具定义 - */ - processToolDefinitions(tools) { - throw new Error('processToolDefinitions方法必须被子类实现'); - } - - /** - * 处理工具调用 - * @param {Object} toolCall - 工具调用数据 - * @returns {Object} 处理后的工具调用 - */ - processToolCall(toolCall) { - throw new Error('processToolCall方法必须被子类实现'); - } - - /** - * 处理工具结果 - * @param {Object} toolResult - 工具结果数据 - * @returns {Object} 处理后的工具结果 - */ - processToolResult(toolResult) { - throw new Error('processToolResult方法必须被子类实现'); - } -} \ No newline at end of file diff --git a/src/converters/ConverterFactory.js b/src/converters/ConverterFactory.js deleted file mode 100644 index ba97e3df6fedc6e9ada8fc48c5198caf1b754228..0000000000000000000000000000000000000000 --- a/src/converters/ConverterFactory.js +++ /dev/null @@ -1,183 +0,0 @@ -/** - * 转换器工厂类 - * 使用工厂模式管理转换器实例的创建和缓存 - */ - -import { MODEL_PROTOCOL_PREFIX } from '../utils/common.js'; -import logger from '../utils/logger.js'; - -/** - * 转换器工厂(单例模式 + 工厂模式) - */ -export class ConverterFactory { - // 私有静态属性:存储转换器实例 - static #converters = new Map(); - - // 私有静态属性:存储转换器类 - static #converterClasses = new Map(); - - /** - * 注册转换器类 - * @param {string} protocolPrefix - 协议前缀 - * @param {Class} ConverterClass - 转换器类 - */ - static registerConverter(protocolPrefix, ConverterClass) { - this.#converterClasses.set(protocolPrefix, ConverterClass); - } - - /** - * 获取转换器实例(带缓存) - * @param {string} protocolPrefix - 协议前缀 - * @returns {BaseConverter} 转换器实例 - */ - static getConverter(protocolPrefix) { - // 检查缓存 - if (this.#converters.has(protocolPrefix)) { - return this.#converters.get(protocolPrefix); - } - - // 创建新实例 - const converter = this.createConverter(protocolPrefix); - - // 缓存实例 - if (converter) { - this.#converters.set(protocolPrefix, converter); - } - - return converter; - } - - /** - * 创建转换器实例 - * @param {string} protocolPrefix - 协议前缀 - * @returns {BaseConverter} 转换器实例 - */ - static createConverter(protocolPrefix) { - const ConverterClass = this.#converterClasses.get(protocolPrefix); - - if (!ConverterClass) { - throw new Error(`No converter registered for protocol: ${protocolPrefix}`); - } - - return new ConverterClass(); - } - - /** - * 清除所有缓存的转换器 - */ - static clearCache() { - this.#converters.clear(); - } - - /** - * 清除特定协议的转换器缓存 - * @param {string} protocolPrefix - 协议前缀 - */ - static clearConverterCache(protocolPrefix) { - this.#converters.delete(protocolPrefix); - } - - /** - * 获取所有已注册的协议 - * @returns {Array} 协议前缀数组 - */ - static getRegisteredProtocols() { - return Array.from(this.#converterClasses.keys()); - } - - /** - * 检查协议是否已注册 - * @param {string} protocolPrefix - 协议前缀 - * @returns {boolean} 是否已注册 - */ - static isProtocolRegistered(protocolPrefix) { - return this.#converterClasses.has(protocolPrefix); - } -} - -/** - * 内容处理器工厂 - */ -export class ContentProcessorFactory { - static #processors = new Map(); - - /** - * 获取内容处理器 - * @param {string} sourceFormat - 源格式 - * @param {string} targetFormat - 目标格式 - * @returns {ContentProcessor} 内容处理器实例 - */ - static getProcessor(sourceFormat, targetFormat) { - const key = `${sourceFormat}_to_${targetFormat}`; - - if (!this.#processors.has(key)) { - this.#processors.set(key, this.createProcessor(sourceFormat, targetFormat)); - } - - return this.#processors.get(key); - } - - /** - * 创建内容处理器 - * @param {string} sourceFormat - 源格式 - * @param {string} targetFormat - 目标格式 - * @returns {ContentProcessor} 内容处理器实例 - */ - static createProcessor(sourceFormat, targetFormat) { - // 这里返回null,实际使用时需要导入具体的处理器类 - // 为了避免循环依赖,处理器类应该在使用时动态导入 - logger.warn(`Content processor for ${sourceFormat} to ${targetFormat} not yet implemented`); - return null; - } - - /** - * 清除所有缓存的处理器 - */ - static clearCache() { - this.#processors.clear(); - } -} - -/** - * 工具处理器工厂 - */ -export class ToolProcessorFactory { - static #processors = new Map(); - - /** - * 获取工具处理器 - * @param {string} sourceFormat - 源格式 - * @param {string} targetFormat - 目标格式 - * @returns {ToolProcessor} 工具处理器实例 - */ - static getProcessor(sourceFormat, targetFormat) { - const key = `${sourceFormat}_to_${targetFormat}`; - - if (!this.#processors.has(key)) { - this.#processors.set(key, this.createProcessor(sourceFormat, targetFormat)); - } - - return this.#processors.get(key); - } - - /** - * 创建工具处理器 - * @param {string} sourceFormat - 源格式 - * @param {string} targetFormat - 目标格式 - * @returns {ToolProcessor} 工具处理器实例 - */ - static createProcessor(sourceFormat, targetFormat) { - logger.warn(`Tool processor for ${sourceFormat} to ${targetFormat} not yet implemented`); - return null; - } - - /** - * 清除所有缓存的处理器 - */ - static clearCache() { - this.#processors.clear(); - } -} - -// 导出工厂类 -export default ConverterFactory; \ No newline at end of file diff --git a/src/converters/register-converters.js b/src/converters/register-converters.js deleted file mode 100644 index 54286410dad75a7212a2d095f9312de3f2ece0aa..0000000000000000000000000000000000000000 --- a/src/converters/register-converters.js +++ /dev/null @@ -1,29 +0,0 @@ -/** - * 转换器注册模块 - * 用于注册所有转换器到工厂,避免循环依赖问题 - */ - -import { MODEL_PROTOCOL_PREFIX } from '../utils/common.js'; -import { ConverterFactory } from './ConverterFactory.js'; -import { OpenAIConverter } from './strategies/OpenAIConverter.js'; -import { OpenAIResponsesConverter } from './strategies/OpenAIResponsesConverter.js'; -import { ClaudeConverter } from './strategies/ClaudeConverter.js'; -import { GeminiConverter } from './strategies/GeminiConverter.js'; -import { CodexConverter } from './strategies/CodexConverter.js'; -import { GrokConverter } from './strategies/GrokConverter.js'; - -/** - * 注册所有转换器到工厂 - * 此函数应在应用启动时调用一次 - */ -export function registerAllConverters() { - ConverterFactory.registerConverter(MODEL_PROTOCOL_PREFIX.OPENAI, OpenAIConverter); - ConverterFactory.registerConverter(MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES, OpenAIResponsesConverter); - ConverterFactory.registerConverter(MODEL_PROTOCOL_PREFIX.CLAUDE, ClaudeConverter); - ConverterFactory.registerConverter(MODEL_PROTOCOL_PREFIX.GEMINI, GeminiConverter); - ConverterFactory.registerConverter(MODEL_PROTOCOL_PREFIX.CODEX, CodexConverter); - ConverterFactory.registerConverter(MODEL_PROTOCOL_PREFIX.GROK, GrokConverter); -} - -// 自动注册所有转换器 -registerAllConverters(); \ No newline at end of file diff --git a/src/converters/strategies/ClaudeConverter.js b/src/converters/strategies/ClaudeConverter.js deleted file mode 100644 index b9bc5fc5c2f926df6b596087a560d90beac1637d..0000000000000000000000000000000000000000 --- a/src/converters/strategies/ClaudeConverter.js +++ /dev/null @@ -1,2234 +0,0 @@ -/** - * Claude转换器 - * 处理Claude(Anthropic)协议与其他协议之间的转换 - */ - -import { v4 as uuidv4 } from 'uuid'; -import logger from '../../utils/logger.js'; -import { BaseConverter } from '../BaseConverter.js'; -import { - checkAndAssignOrDefault, - cleanJsonSchemaProperties as cleanJsonSchema, - determineReasoningEffortFromBudget, - OPENAI_DEFAULT_MAX_TOKENS, - OPENAI_DEFAULT_TEMPERATURE, - OPENAI_DEFAULT_TOP_P, - GEMINI_DEFAULT_MAX_TOKENS, - GEMINI_DEFAULT_TEMPERATURE, - GEMINI_DEFAULT_TOP_P, - GEMINI_DEFAULT_INPUT_TOKEN_LIMIT, - GEMINI_DEFAULT_OUTPUT_TOKEN_LIMIT -} from '../utils.js'; -import { MODEL_PROTOCOL_PREFIX } from '../../utils/common.js'; -import { - generateResponseCreated, - generateResponseInProgress, - generateOutputItemAdded, - generateContentPartAdded, - generateOutputTextDone, - generateContentPartDone, - generateOutputItemDone, - generateResponseCompleted -} from '../../providers/openai/openai-responses-core.mjs'; - -/** - * Claude转换器类 - * 实现Claude协议到其他协议的转换 - */ -export class ClaudeConverter extends BaseConverter { - constructor() { - super('claude'); - } - - /** - * 转换请求 - */ - convertRequest(data, targetProtocol) { - switch (targetProtocol) { - case MODEL_PROTOCOL_PREFIX.OPENAI: - return this.toOpenAIRequest(data); - case MODEL_PROTOCOL_PREFIX.GEMINI: - return this.toGeminiRequest(data); - case MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES: - return this.toOpenAIResponsesRequest(data); - case MODEL_PROTOCOL_PREFIX.CODEX: - return this.toCodexRequest(data); - case MODEL_PROTOCOL_PREFIX.GROK: - return this.toGrokRequest(data); - default: - throw new Error(`Unsupported target protocol: ${targetProtocol}`); - } - } - - /** - * 转换响应 - */ - convertResponse(data, targetProtocol, model) { - switch (targetProtocol) { - case MODEL_PROTOCOL_PREFIX.OPENAI: - return this.toOpenAIResponse(data, model); - case MODEL_PROTOCOL_PREFIX.GEMINI: - return this.toGeminiResponse(data, model); - case MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES: - return this.toOpenAIResponsesResponse(data, model); - case MODEL_PROTOCOL_PREFIX.CODEX: - return this.toCodexResponse(data, model); - default: - throw new Error(`Unsupported target protocol: ${targetProtocol}`); - } - } - - /** - * 转换流式响应块 - */ - convertStreamChunk(chunk, targetProtocol, model) { - switch (targetProtocol) { - case MODEL_PROTOCOL_PREFIX.OPENAI: - return this.toOpenAIStreamChunk(chunk, model); - case MODEL_PROTOCOL_PREFIX.GEMINI: - return this.toGeminiStreamChunk(chunk, model); - case MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES: - return this.toOpenAIResponsesStreamChunk(chunk, model); - case MODEL_PROTOCOL_PREFIX.CODEX: - return this.toCodexStreamChunk(chunk, model); - default: - throw new Error(`Unsupported target protocol: ${targetProtocol}`); - } - } - - /** - * 转换模型列表 - */ - convertModelList(data, targetProtocol) { - switch (targetProtocol) { - case MODEL_PROTOCOL_PREFIX.OPENAI: - return this.toOpenAIModelList(data); - case MODEL_PROTOCOL_PREFIX.GEMINI: - return this.toGeminiModelList(data); - default: - return data; - } - } - - // ========================================================================= - // Claude -> OpenAI 转换 - // ========================================================================= - - /** - * Claude请求 -> OpenAI请求 - */ - toOpenAIRequest(claudeRequest) { - const openaiMessages = []; - let systemMessageContent = ''; - - // 添加系统消息 - if (claudeRequest.system) { - systemMessageContent = claudeRequest.system; - } - - // 处理消息 - if (claudeRequest.messages && Array.isArray(claudeRequest.messages)) { - const tempOpenAIMessages = []; - for (const msg of claudeRequest.messages) { - const role = msg.role; - - // 处理用户的工具结果消息 - if (role === "user" && Array.isArray(msg.content)) { - const hasToolResult = msg.content.some( - item => item && typeof item === 'object' && item.type === "tool_result" - ); - - if (hasToolResult) { - for (const item of msg.content) { - if (item && typeof item === 'object' && item.type === "tool_result") { - const toolUseId = item.tool_use_id || item.id || ""; - let contentStr = item.content || ""; - if (typeof contentStr === 'object') { - contentStr = JSON.stringify(contentStr); - } else { - contentStr = String(contentStr); - } - tempOpenAIMessages.push({ - role: "tool", - tool_call_id: toolUseId, - content: contentStr, - }); - } - } - continue; - } - } - - // 处理assistant消息中的工具调用 - if (role === "assistant" && Array.isArray(msg.content) && msg.content.length > 0) { - const firstPart = msg.content[0]; - if (firstPart.type === "tool_use") { - const funcName = firstPart.name || ""; - const funcArgs = firstPart.input || {}; - tempOpenAIMessages.push({ - role: "assistant", - content: '', - tool_calls: [ - { - id: firstPart.id || `call_${funcName}_1`, - type: "function", - function: { - name: funcName, - arguments: JSON.stringify(funcArgs) - }, - index: firstPart.index || 0 - } - ] - }); - continue; - } - } - - // 普通文本消息 - const contentConverted = this.processClaudeContentToOpenAIContent(msg.content || ""); - if (contentConverted && (Array.isArray(contentConverted) ? contentConverted.length > 0 : contentConverted.trim().length > 0)) { - tempOpenAIMessages.push({ - role: role, - content: contentConverted - }); - } - } - - // OpenAI兼容性校验 - const validatedMessages = []; - for (let idx = 0; idx < tempOpenAIMessages.length; idx++) { - const m = tempOpenAIMessages[idx]; - if (m.role === "assistant" && m.tool_calls) { - const callIds = m.tool_calls.map(tc => tc.id).filter(id => id); - let unmatched = new Set(callIds); - for (let laterIdx = idx + 1; laterIdx < tempOpenAIMessages.length; laterIdx++) { - const later = tempOpenAIMessages[laterIdx]; - if (later.role === "tool" && unmatched.has(later.tool_call_id)) { - unmatched.delete(later.tool_call_id); - } - if (unmatched.size === 0) break; - } - if (unmatched.size > 0) { - m.tool_calls = m.tool_calls.filter(tc => !unmatched.has(tc.id)); - if (m.tool_calls.length === 0) { - delete m.tool_calls; - if (m.content === null) m.content = ""; - } - } - } - validatedMessages.push(m); - } - openaiMessages.push(...validatedMessages); - } - - const openaiRequest = { - model: claudeRequest.model, - messages: openaiMessages, - max_tokens: checkAndAssignOrDefault(claudeRequest.max_tokens, OPENAI_DEFAULT_MAX_TOKENS), - temperature: checkAndAssignOrDefault(claudeRequest.temperature, OPENAI_DEFAULT_TEMPERATURE), - top_p: checkAndAssignOrDefault(claudeRequest.top_p, OPENAI_DEFAULT_TOP_P), - stream: claudeRequest.stream, - }; - - // 处理工具 - if (claudeRequest.tools) { - const openaiTools = []; - for (const tool of claudeRequest.tools) { - openaiTools.push({ - type: "function", - function: { - name: tool.name || "", - description: tool.description || "", - parameters: cleanJsonSchema(tool.input_schema || {}) - } - }); - } - openaiRequest.tools = openaiTools; - openaiRequest.tool_choice = "auto"; - } - - // 处理thinking转换 - if (claudeRequest.thinking && claudeRequest.thinking.type === "enabled") { - const budgetTokens = claudeRequest.thinking.budget_tokens; - const reasoningEffort = determineReasoningEffortFromBudget(budgetTokens); - openaiRequest.reasoning_effort = reasoningEffort; - - let maxCompletionTokens = null; - if (claudeRequest.max_tokens !== undefined) { - maxCompletionTokens = claudeRequest.max_tokens; - delete openaiRequest.max_tokens; - } else { - const envMaxTokens = process.env.OPENAI_REASONING_MAX_TOKENS; - if (envMaxTokens) { - try { - maxCompletionTokens = parseInt(envMaxTokens, 10); - } catch (e) { - logger.warn(`Invalid OPENAI_REASONING_MAX_TOKENS value '${envMaxTokens}'`); - } - } - if (!envMaxTokens) { - throw new Error("For OpenAI reasoning models, max_completion_tokens is required."); - } - } - openaiRequest.max_completion_tokens = maxCompletionTokens; - } - - // 添加系统消息 - if (systemMessageContent) { - let stringifiedSystemMessageContent = systemMessageContent; - if (Array.isArray(systemMessageContent)) { - stringifiedSystemMessageContent = systemMessageContent.map(item => - typeof item === 'string' ? item : item.text).join('\n'); - } - openaiRequest.messages.unshift({ role: 'system', content: stringifiedSystemMessageContent }); - } - - return openaiRequest; - } - - /** - * Claude响应 -> OpenAI响应 - */ - toOpenAIResponse(claudeResponse, model) { - if (!claudeResponse || !claudeResponse.content || claudeResponse.content.length === 0) { - return { - id: `chatcmpl-${uuidv4()}`, - object: "chat.completion", - created: Math.floor(Date.now() / 1000), - model: model, - choices: [{ - index: 0, - message: { - role: "assistant", - content: "", - }, - finish_reason: "stop", - }], - usage: { - prompt_tokens: claudeResponse.usage?.input_tokens || 0, - completion_tokens: claudeResponse.usage?.output_tokens || 0, - total_tokens: (claudeResponse.usage?.input_tokens || 0) + (claudeResponse.usage?.output_tokens || 0), - }, - }; - } - - // Extract thinking blocks into OpenAI-style `reasoning_content`. - let reasoningContent = ''; - if (Array.isArray(claudeResponse.content)) { - for (const block of claudeResponse.content) { - if (!block || typeof block !== 'object') continue; - if (block.type === 'thinking') { - reasoningContent += (block.thinking ?? block.text ?? ''); - } - } - } - - // 检查是否包含 tool_use - const hasToolUse = claudeResponse.content.some(block => block && block.type === 'tool_use'); - - let message = { - role: "assistant", - content: null - }; - - if (hasToolUse) { - // 处理包含工具调用的响应 - const toolCalls = []; - let textContent = ''; - - for (const block of claudeResponse.content) { - if (!block) continue; - - if (block.type === 'text') { - textContent += block.text || ''; - } else if (block.type === 'tool_use') { - toolCalls.push({ - id: block.id || `call_${block.name}_${Date.now()}`, - type: "function", - function: { - name: block.name || '', - arguments: JSON.stringify(block.input || {}) - } - }); - } - } - - message.content = textContent || null; - if (toolCalls.length > 0) { - message.tool_calls = toolCalls; - } - } else { - // 处理普通文本响应 - message.content = this.processClaudeResponseContent(claudeResponse.content); - } - - if (reasoningContent) { - message.reasoning_content = reasoningContent; - } - - // 处理 finish_reason - let finishReason = 'stop'; - if (claudeResponse.stop_reason === 'end_turn') { - finishReason = 'stop'; - } else if (claudeResponse.stop_reason === 'max_tokens') { - finishReason = 'length'; - } else if (claudeResponse.stop_reason === 'tool_use') { - finishReason = 'tool_calls'; - } else if (claudeResponse.stop_reason) { - finishReason = claudeResponse.stop_reason; - } - - return { - id: `chatcmpl-${uuidv4()}`, - object: "chat.completion", - created: Math.floor(Date.now() / 1000), - model: model, - choices: [{ - index: 0, - message: message, - finish_reason: finishReason, - }], - usage: { - prompt_tokens: claudeResponse.usage?.input_tokens || 0, - completion_tokens: claudeResponse.usage?.output_tokens || 0, - total_tokens: (claudeResponse.usage?.input_tokens || 0) + (claudeResponse.usage?.output_tokens || 0), - cached_tokens: claudeResponse.usage?.cache_read_input_tokens || 0, - prompt_tokens_details: { - cached_tokens: claudeResponse.usage?.cache_read_input_tokens || 0 - } - }, - }; - } - - /** - * Claude流式响应 -> OpenAI流式响应 - */ - toOpenAIStreamChunk(claudeChunk, model) { - if (!claudeChunk) return null; - - // 处理 Claude 流式事件 - const chunkId = `chatcmpl-${uuidv4()}`; - const timestamp = Math.floor(Date.now() / 1000); - - // message_start 事件 - if (claudeChunk.type === 'message_start') { - return { - id: chunkId, - object: "chat.completion.chunk", - created: timestamp, - model: model, - system_fingerprint: "", - choices: [{ - index: 0, - delta: { - role: "assistant", - content: "" - }, - finish_reason: null - }], - usage: { - prompt_tokens: claudeChunk.message?.usage?.input_tokens || 0, - completion_tokens: 0, - total_tokens: claudeChunk.message?.usage?.input_tokens || 0, - cached_tokens: claudeChunk.message?.usage?.cache_read_input_tokens || 0 - } - }; - } - - // content_block_start 事件 - if (claudeChunk.type === 'content_block_start') { - const contentBlock = claudeChunk.content_block; - - // 处理 tool_use 类型 - if (contentBlock && contentBlock.type === 'tool_use') { - return { - id: chunkId, - object: "chat.completion.chunk", - created: timestamp, - model: model, - system_fingerprint: "", - choices: [{ - index: 0, - delta: { - tool_calls: [{ - index: claudeChunk.index || 0, - id: contentBlock.id, - type: "function", - function: { - name: contentBlock.name, - arguments: "" - } - }] - }, - finish_reason: null - }] - }; - } - - // 处理 text 类型 - return { - id: chunkId, - object: "chat.completion.chunk", - created: timestamp, - model: model, - system_fingerprint: "", - choices: [{ - index: 0, - delta: { - content: "" - }, - finish_reason: null - }] - }; - } - - // content_block_delta 事件 - if (claudeChunk.type === 'content_block_delta') { - const delta = claudeChunk.delta; - - // 处理 text_delta - if (delta && delta.type === 'text_delta') { - return { - id: chunkId, - object: "chat.completion.chunk", - created: timestamp, - model: model, - system_fingerprint: "", - choices: [{ - index: 0, - delta: { - content: delta.text || "" - }, - finish_reason: null - }] - }; - } - - // 处理 thinking_delta (推理内容) - if (delta && delta.type === 'thinking_delta') { - return { - id: chunkId, - object: "chat.completion.chunk", - created: timestamp, - model: model, - system_fingerprint: "", - choices: [{ - index: 0, - delta: { - reasoning_content: delta.thinking || "" - }, - finish_reason: null - }] - }; - } - - // 处理 input_json_delta (tool arguments) - if (delta && delta.type === 'input_json_delta') { - return { - id: chunkId, - object: "chat.completion.chunk", - created: timestamp, - model: model, - system_fingerprint: "", - choices: [{ - index: 0, - delta: { - tool_calls: [{ - index: claudeChunk.index || 0, - function: { - arguments: delta.partial_json || "" - } - }] - }, - finish_reason: null - }] - }; - } - } - - // content_block_stop 事件 - if (claudeChunk.type === 'content_block_stop') { - return { - id: chunkId, - object: "chat.completion.chunk", - created: timestamp, - model: model, - system_fingerprint: "", - choices: [{ - index: 0, - delta: {}, - finish_reason: null - }] - }; - } - - // message_delta 事件 - if (claudeChunk.type === 'message_delta') { - const stopReason = claudeChunk.delta?.stop_reason; - const finishReason = stopReason === 'end_turn' ? 'stop' : - stopReason === 'max_tokens' ? 'length' : - stopReason === 'tool_use' ? 'tool_calls' : - stopReason || 'stop'; - - const chunk = { - id: chunkId, - object: "chat.completion.chunk", - created: timestamp, - model: model, - system_fingerprint: "", - choices: [{ - index: 0, - delta: {}, - finish_reason: finishReason - }] - }; - - if(claudeChunk.usage){ - chunk.usage = { - prompt_tokens: claudeChunk.usage.input_tokens || 0, - completion_tokens: claudeChunk.usage.output_tokens || 0, - total_tokens: (claudeChunk.usage.input_tokens || 0) + (claudeChunk.usage.output_tokens || 0), - cached_tokens: claudeChunk.usage.cache_read_input_tokens || 0, - prompt_tokens_details: { - cached_tokens: claudeChunk.usage.cache_read_input_tokens || 0 - } - }; - } - - return chunk; - } - - // message_stop 事件 - if (claudeChunk.type === 'message_stop') { - return null; - // const chunk = { - // id: chunkId, - // object: "chat.completion.chunk", - // created: timestamp, - // model: model, - // system_fingerprint: "", - // choices: [{ - // index: 0, - // delta: {}, - // finish_reason: 'stop' - // }] - // }; - // return chunk; - } - - // 兼容旧格式:如果是字符串,直接作为文本内容 - if (typeof claudeChunk === 'string') { - return { - id: chunkId, - object: "chat.completion.chunk", - created: timestamp, - model: model, - system_fingerprint: "", - choices: [{ - index: 0, - delta: { - content: claudeChunk - }, - finish_reason: null - }] - }; - } - - return null; - } - - /** - * Claude模型列表 -> OpenAI模型列表 - */ - toOpenAIModelList(claudeModels) { - return { - object: "list", - data: claudeModels.models.map(m => { - const modelId = m.id || m.name; - return { - id: modelId, - object: "model", - created: Math.floor(Date.now() / 1000), - owned_by: "anthropic", - display_name: modelId, - }; - }), - }; - } - - /** - * 将 Claude 模型列表转换为 Gemini 模型列表 - */ - toGeminiModelList(claudeModels) { - const models = claudeModels.models || []; - return { - models: models.map(m => ({ - name: `models/${m.id || m.name}`, - version: m.version || "1.0.0", - displayName: m.displayName || m.id || m.name, - description: m.description || `A generative model for text and chat generation. ID: ${m.id || m.name}`, - inputTokenLimit: m.inputTokenLimit || GEMINI_DEFAULT_INPUT_TOKEN_LIMIT, - outputTokenLimit: m.outputTokenLimit || GEMINI_DEFAULT_OUTPUT_TOKEN_LIMIT, - supportedGenerationMethods: m.supportedGenerationMethods || ["generateContent", "streamGenerateContent"] - })) - }; - } - - /** - * 处理Claude内容到OpenAI格式 - */ - processClaudeContentToOpenAIContent(content) { - if (!content) return []; - - // 如果是字符串,直接转换为 OpenAI 的文本块格式 - if (typeof content === 'string') { - return [{ - type: 'text', - text: content - }]; - } - - if (!Array.isArray(content)) return []; - - const contentArray = []; - - content.forEach(block => { - if (!block) return; - - switch (block.type) { - case 'text': - if (block.text) { - contentArray.push({ - type: 'text', - text: block.text - }); - } - break; - - case 'image': - if (block.source && block.source.type === 'base64') { - contentArray.push({ - type: 'image_url', - image_url: { - url: `data:${block.source.media_type};base64,${block.source.data}` - } - }); - } - break; - - case 'tool_use': - contentArray.push({ - type: 'text', - text: `[Tool use: ${block.name}]` - }); - break; - - case 'tool_result': - contentArray.push({ - type: 'text', - text: typeof block.content === 'string' ? block.content : JSON.stringify(block.content) - }); - break; - - default: - if (block.text) { - contentArray.push({ - type: 'text', - text: block.text - }); - } - } - }); - - return contentArray; - } - - /** - * 处理Claude响应内容 - */ - processClaudeResponseContent(content) { - if (!content) return ''; - - if (typeof content === 'string') return content; - - if (!Array.isArray(content)) return ''; - - const contentArray = []; - - content.forEach(block => { - if (!block) return; - - switch (block.type) { - case 'text': - contentArray.push({ - type: 'text', - text: block.text || '' - }); - break; - - case 'image': - if (block.source && block.source.type === 'base64') { - contentArray.push({ - type: 'image_url', - image_url: { - url: `data:${block.source.media_type};base64,${block.source.data}` - } - }); - } - break; - - default: - if (block.text) { - contentArray.push({ - type: 'text', - text: block.text - }); - } - } - }); - - return contentArray.length === 1 && contentArray[0].type === 'text' - ? contentArray[0].text - : contentArray; - } - - // ========================================================================= - // Claude -> Gemini 转换 - // ========================================================================= - - // Gemini Claude thought signature constant - static GEMINI_CLAUDE_THOUGHT_SIGNATURE = "skip_thought_signature_validator"; - - /** - * Claude请求 -> Gemini请求 - */ - toGeminiRequest(claudeRequest) { - if (!claudeRequest || typeof claudeRequest !== 'object') { - logger.warn("Invalid claudeRequest provided to toGeminiRequest."); - return { contents: [] }; - } - - const geminiRequest = { - contents: [] - }; - - // 处理系统指令 - 支持数组和字符串格式 - if (claudeRequest.system) { - if (Array.isArray(claudeRequest.system)) { - // 数组格式的系统指令 - const systemParts = []; - claudeRequest.system.forEach(systemPrompt => { - if (systemPrompt && systemPrompt.type === 'text' && typeof systemPrompt.text === 'string') { - systemParts.push({ text: systemPrompt.text }); - } - }); - if (systemParts.length > 0) { - geminiRequest.systemInstruction = { - role: 'user', - parts: systemParts - }; - } - } else if (typeof claudeRequest.system === 'string') { - // 字符串格式的系统指令 - geminiRequest.systemInstruction = { - parts: [{ text: claudeRequest.system }] - }; - } else if (typeof claudeRequest.system === 'object') { - // 对象格式的系统指令 - geminiRequest.systemInstruction = { - parts: [{ text: JSON.stringify(claudeRequest.system) }] - }; - } - } - - // 处理消息 - if (Array.isArray(claudeRequest.messages)) { - claudeRequest.messages.forEach(message => { - if (!message || typeof message !== 'object' || !message.role) { - logger.warn("Skipping invalid message in claudeRequest.messages."); - return; - } - - const geminiRole = message.role === 'assistant' ? 'model' : 'user'; - const content = message.content; - - // 处理内容 - if (Array.isArray(content)) { - const parts = []; - - content.forEach(block => { - if (!block || typeof block !== 'object') return; - - switch (block.type) { - case 'text': - if (typeof block.text === 'string') { - parts.push({ text: block.text }); - } - break; - - // 添加 thinking 块处理 - case 'thinking': - if (typeof block.thinking === 'string' && block.thinking.length > 0) { - const thinkingPart = { - text: block.thinking, - thought: true - }; - // 如果有签名,添加 thoughtSignature - if (block.signature && block.signature.length >= 50) { - thinkingPart.thoughtSignature = block.signature; - } - parts.push(thinkingPart); - } - break; - - // [FIX] 处理 redacted_thinking 块 - case 'redacted_thinking': - // 将 redacted_thinking 转换为普通文本 - if (block.data) { - parts.push({ - text: `[Redacted Thinking: ${block.data}]` - }); - } - break; - - case 'tool_use': - // 转换为 Gemini functionCall 格式 - if (block.name && block.input) { - const args = typeof block.input === 'string' - ? block.input - : JSON.stringify(block.input); - - // 验证 args 是有效的 JSON 对象 - try { - const parsedArgs = JSON.parse(args); - if (parsedArgs && typeof parsedArgs === 'object') { - parts.push({ - thoughtSignature: ClaudeConverter.GEMINI_CLAUDE_THOUGHT_SIGNATURE, - functionCall: { - name: block.name, - args: parsedArgs - } - }); - } - } catch (e) { - // 如果解析失败,尝试直接使用 input - if (block.input && typeof block.input === 'object') { - parts.push({ - thoughtSignature: ClaudeConverter.GEMINI_CLAUDE_THOUGHT_SIGNATURE, - functionCall: { - name: block.name, - args: block.input - } - }); - } - } - } - break; - - case 'tool_result': - // 转换为 Gemini functionResponse 格式 - // 的实现,正确处理 tool_use_id 到函数名的映射 - const toolCallId = block.tool_use_id; - if (toolCallId) { - // 尝试从之前的 tool_use 块中查找对应的函数名 - // 如果找不到,则从 tool_use_id 中提取 - let funcName = toolCallId; - - // 检查是否有缓存的 tool_id -> name 映射 - // 格式通常是 "funcName-uuid" 或 "toolu_xxx" - if (toolCallId.startsWith('toolu_')) { - // Claude 格式的 tool_use_id,需要从上下文中查找函数名 - // 这里我们保留原始 ID 作为 name(Gemini 会处理) - funcName = toolCallId; - } else { - const toolCallIdParts = toolCallId.split('-'); - if (toolCallIdParts.length > 1) { - // 移除最后一个部分(UUID),保留函数名 - funcName = toolCallIdParts.slice(0, -1).join('-'); - } - } - - // 获取响应数据 - let responseData = block.content; - - // 的 tool_result_compressor 逻辑 - // 处理嵌套的 content 数组(如图片等) - if (Array.isArray(responseData)) { - // 提取文本内容 - const textParts = responseData - .filter(item => item && item.type === 'text') - .map(item => item.text) - .join('\n'); - responseData = textParts || JSON.stringify(responseData); - } else if (typeof responseData !== 'string') { - responseData = JSON.stringify(responseData); - } - - parts.push({ - functionResponse: { - name: funcName, - response: { - result: responseData - } - } - }); - } - break; - - case 'image': - if (block.source && block.source.type === 'base64') { - parts.push({ - inlineData: { - mimeType: block.source.media_type, - data: block.source.data - } - }); - } - break; - } - }); - - if (parts.length > 0) { - geminiRequest.contents.push({ - role: geminiRole, - parts: parts - }); - } - } else if (typeof content === 'string') { - // 字符串内容 - geminiRequest.contents.push({ - role: geminiRole, - parts: [{ text: content }] - }); - } - }); - } - - // 添加生成配置 - const generationConfig = {}; - - if (claudeRequest.max_tokens !== undefined) { - generationConfig.maxOutputTokens = claudeRequest.max_tokens; - } - if (claudeRequest.temperature !== undefined) { - generationConfig.temperature = claudeRequest.temperature; - } - if (claudeRequest.top_p !== undefined) { - generationConfig.topP = claudeRequest.top_p; - } - if (claudeRequest.top_k !== undefined) { - generationConfig.topK = claudeRequest.top_k; - } - - // 处理 thinking 配置 - 转换为 Gemini thinkingBudget - if (claudeRequest.thinking && claudeRequest.thinking.type === 'enabled') { - if (claudeRequest.thinking.budget_tokens !== undefined) { - const budget = claudeRequest.thinking.budget_tokens; - if (!generationConfig.thinkingConfig) { - generationConfig.thinkingConfig = {}; - } - generationConfig.thinkingConfig.thinkingBudget = budget; - generationConfig.thinkingConfig.include_thoughts = true; - } - } - - if (Object.keys(generationConfig).length > 0) { - geminiRequest.generationConfig = generationConfig; - } - - // 处理工具 - 使用 parametersJsonSchema 格式 - if (Array.isArray(claudeRequest.tools) && claudeRequest.tools.length > 0) { - const functionDeclarations = []; - - claudeRequest.tools.forEach(tool => { - if (!tool || typeof tool !== 'object' || !tool.name) { - logger.warn("Skipping invalid tool declaration in claudeRequest.tools."); - return; - } - - // 清理 input_schema - let inputSchema = tool.input_schema; - if (inputSchema && typeof inputSchema === 'object') { - // 创建副本以避免修改原始对象 - inputSchema = JSON.parse(JSON.stringify(inputSchema)); - // 清理不需要的字段 - delete inputSchema.$schema; - // 清理 URL 格式(Gemini 不支持) - this.cleanUrlFormatFromSchema(inputSchema); - } - - const funcDecl = { - name: String(tool.name), - description: String(tool.description || '') - }; - - // 使用 parametersJsonSchema 而不是 parameters - if (inputSchema) { - funcDecl.parametersJsonSchema = inputSchema; - } - - functionDeclarations.push(funcDecl); - }); - - if (functionDeclarations.length > 0) { - geminiRequest.tools = [{ - functionDeclarations: functionDeclarations - }]; - } - } - - // 处理tool_choice - if (claudeRequest.tool_choice) { - geminiRequest.toolConfig = this.buildGeminiToolConfigFromClaude(claudeRequest.tool_choice); - } - - // 添加默认安全设置 - geminiRequest.safetySettings = this.getDefaultSafetySettings(); - - return geminiRequest; - } - - /** - * 清理 JSON Schema 中的 URL 格式 - * Gemini 不支持 "format": "uri" - */ - cleanUrlFormatFromSchema(schema) { - if (!schema || typeof schema !== 'object') return; - - // 如果是属性对象,检查并清理 format - if (schema.type === 'string' && schema.format === 'uri') { - delete schema.format; - } - - // 递归处理 properties - if (schema.properties && typeof schema.properties === 'object') { - Object.values(schema.properties).forEach(prop => { - this.cleanUrlFormatFromSchema(prop); - }); - } - - // 递归处理 items(数组类型) - if (schema.items) { - this.cleanUrlFormatFromSchema(schema.items); - } - - // 递归处理 additionalProperties - if (schema.additionalProperties && typeof schema.additionalProperties === 'object') { - this.cleanUrlFormatFromSchema(schema.additionalProperties); - } - } - - /** - * 获取默认的 Gemini 安全设置 - */ - getDefaultSafetySettings() { - return [ - { category: "HARM_CATEGORY_HARASSMENT", threshold: "OFF" }, - { category: "HARM_CATEGORY_HATE_SPEECH", threshold: "OFF" }, - { category: "HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold: "OFF" }, - { category: "HARM_CATEGORY_DANGEROUS_CONTENT", threshold: "OFF" }, - { category: "HARM_CATEGORY_CIVIC_INTEGRITY", threshold: "OFF" } - ]; - } - - /** - * Claude响应 -> Gemini响应 - */ - toGeminiResponse(claudeResponse, model) { - if (!claudeResponse || !claudeResponse.content || claudeResponse.content.length === 0) { - return { candidates: [], usageMetadata: {} }; - } - - const parts = []; - - // 处理内容块 - for (const block of claudeResponse.content) { - if (!block) continue; - - switch (block.type) { - case 'text': - if (block.text) { - parts.push({ text: block.text }); - } - break; - - // 添加 thinking 块处理 - case 'thinking': - if (block.thinking) { - const thinkingPart = { - text: block.thinking, - thought: true - }; - // 如果有签名,添加 thoughtSignature - if (block.signature && block.signature.length >= 50) { - thinkingPart.thoughtSignature = block.signature; - } - parts.push(thinkingPart); - } - break; - - case 'tool_use': - // [FIX] 添加 id 和 thoughtSignature 支持 - const functionCallPart = { - functionCall: { - name: block.name, - args: block.input || {} - } - }; - // 添加 id(如果存在) - if (block.id) { - functionCallPart.functionCall.id = block.id; - } - // 添加签名(如果存在) - if (block.signature && block.signature.length >= 50) { - functionCallPart.thoughtSignature = block.signature; - } - parts.push(functionCallPart); - break; - - case 'image': - if (block.source && block.source.type === 'base64') { - parts.push({ - inlineData: { - mimeType: block.source.media_type, - data: block.source.data - } - }); - } - break; - - default: - if (block.text) { - parts.push({ text: block.text }); - } - } - } - - // 映射finish_reason - const finishReasonMap = { - 'end_turn': 'STOP', - 'max_tokens': 'MAX_TOKENS', - 'tool_use': 'STOP', - 'stop_sequence': 'STOP' - }; - - return { - candidates: [{ - content: { - role: 'model', - parts: parts - }, - finishReason: finishReasonMap[claudeResponse.stop_reason] || 'STOP' - }], - usageMetadata: claudeResponse.usage ? { - promptTokenCount: claudeResponse.usage.input_tokens || 0, - candidatesTokenCount: claudeResponse.usage.output_tokens || 0, - totalTokenCount: (claudeResponse.usage.input_tokens || 0) + (claudeResponse.usage.output_tokens || 0), - cachedContentTokenCount: claudeResponse.usage.cache_read_input_tokens || 0, - promptTokensDetails: [{ - modality: "TEXT", - tokenCount: claudeResponse.usage.input_tokens || 0 - }], - candidatesTokensDetails: [{ - modality: "TEXT", - tokenCount: claudeResponse.usage.output_tokens || 0 - }] - } : {} - }; - } - - /** - * Claude流式响应 -> Gemini流式响应 - */ - toGeminiStreamChunk(claudeChunk, model) { - if (!claudeChunk) return null; - - // 处理Claude流式事件 - if (typeof claudeChunk === 'object' && !Array.isArray(claudeChunk)) { - // content_block_start 事件 - 处理 thinking 块开始 - if (claudeChunk.type === 'content_block_start') { - const contentBlock = claudeChunk.content_block; - if (contentBlock && contentBlock.type === 'thinking') { - // thinking 块开始,返回空(等待 delta) - return null; - } - if (contentBlock && contentBlock.type === 'tool_use') { - // tool_use 块开始 - return { - candidates: [{ - content: { - role: "model", - parts: [{ - functionCall: { - name: contentBlock.name, - args: {}, - id: contentBlock.id - } - }] - } - }] - }; - } - } - - // content_block_delta 事件 - if (claudeChunk.type === 'content_block_delta') { - const delta = claudeChunk.delta; - - // 处理 text_delta - if (delta && delta.type === 'text_delta') { - return { - candidates: [{ - content: { - role: "model", - parts: [{ - text: delta.text || "" - }] - } - }] - }; - } - - // [FIX] 处理 thinking_delta - 转换为 Gemini 的 thought 格式 - if (delta && delta.type === 'thinking_delta') { - return { - candidates: [{ - content: { - role: "model", - parts: [{ - text: delta.thinking || "", - thought: true - }] - } - }] - }; - } - - // [FIX] 处理 signature_delta - if (delta && delta.type === 'signature_delta') { - // 签名通常与前一个 thinking 块关联 - // 在流式场景中,我们可以忽略或记录 - return null; - } - - // [FIX] 处理 input_json_delta (tool arguments) - if (delta && delta.type === 'input_json_delta') { - // 工具参数增量,Gemini 不支持增量参数,忽略 - return null; - } - } - - // message_delta 事件 - 流结束 - if (claudeChunk.type === 'message_delta') { - const stopReason = claudeChunk.delta?.stop_reason; - const result = { - candidates: [{ - finishReason: stopReason === 'end_turn' ? 'STOP' : - stopReason === 'max_tokens' ? 'MAX_TOKENS' : - stopReason === 'tool_use' ? 'STOP' : - 'OTHER' - }] - }; - - // 添加 usage 信息 - if (claudeChunk.usage) { - result.usageMetadata = { - promptTokenCount: claudeChunk.usage.input_tokens || 0, - candidatesTokenCount: claudeChunk.usage.output_tokens || 0, - totalTokenCount: (claudeChunk.usage.input_tokens || 0) + (claudeChunk.usage.output_tokens || 0), - cachedContentTokenCount: claudeChunk.usage.cache_read_input_tokens || 0, - promptTokensDetails: [{ - modality: "TEXT", - tokenCount: claudeChunk.usage.input_tokens || 0 - }], - candidatesTokensDetails: [{ - modality: "TEXT", - tokenCount: claudeChunk.usage.output_tokens || 0 - }] - }; - } - - return result; - } - } - - // 向后兼容:处理字符串格式 - if (typeof claudeChunk === 'string') { - return { - candidates: [{ - content: { - role: "model", - parts: [{ - text: claudeChunk - }] - } - }] - }; - } - - return null; - } - - /** - * 处理Claude内容到Gemini parts - */ - processClaudeContentToGeminiParts(content) { - if (!content) return []; - - if (typeof content === 'string') { - return [{ text: content }]; - } - - if (Array.isArray(content)) { - const parts = []; - - content.forEach(block => { - if (!block || typeof block !== 'object' || !block.type) { - logger.warn("Skipping invalid content block."); - return; - } - - switch (block.type) { - case 'text': - if (typeof block.text === 'string') { - parts.push({ text: block.text }); - } - break; - - case 'image': - if (block.source && typeof block.source === 'object' && - block.source.type === 'base64' && - typeof block.source.media_type === 'string' && - typeof block.source.data === 'string') { - parts.push({ - inlineData: { - mimeType: block.source.media_type, - data: block.source.data - } - }); - } - break; - - case 'tool_use': - if (typeof block.name === 'string' && - block.input && typeof block.input === 'object') { - parts.push({ - functionCall: { - name: block.name, - args: block.input - } - }); - } - break; - - case 'tool_result': - if (typeof block.tool_use_id === 'string') { - parts.push({ - functionResponse: { - name: block.tool_use_id, - response: { content: block.content } - } - }); - } - break; - - default: - if (typeof block.text === 'string') { - parts.push({ text: block.text }); - } - } - }); - - return parts; - } - - return []; - } - - /** - * 构建Gemini工具配置 - */ - buildGeminiToolConfigFromClaude(claudeToolChoice) { - if (!claudeToolChoice || typeof claudeToolChoice !== 'object' || !claudeToolChoice.type) { - logger.warn("Invalid claudeToolChoice provided."); - return undefined; - } - - switch (claudeToolChoice.type) { - case 'auto': - return { functionCallingConfig: { mode: 'AUTO' } }; - case 'none': - return { functionCallingConfig: { mode: 'NONE' } }; - case 'tool': - if (claudeToolChoice.name && typeof claudeToolChoice.name === 'string') { - return { - functionCallingConfig: { - mode: 'ANY', - allowedFunctionNames: [claudeToolChoice.name] - } - }; - } - logger.warn("Invalid tool name in claudeToolChoice of type 'tool'."); - return undefined; - default: - logger.warn(`Unsupported claudeToolChoice type: ${claudeToolChoice.type}`); - return undefined; - } - } - - // ========================================================================= - // Claude -> OpenAI Responses 转换 - // ========================================================================= - - /** - * Claude请求 -> OpenAI Responses请求 - */ - toOpenAIResponsesRequest(claudeRequest) { - const responsesRequest = { - model: claudeRequest.model, - instructions: '', - input: [], - stream: claudeRequest.stream || false, - max_output_tokens: claudeRequest.max_tokens, - temperature: claudeRequest.temperature, - top_p: claudeRequest.top_p - }; - - // 处理系统指令 - if (claudeRequest.system) { - if (Array.isArray(claudeRequest.system)) { - responsesRequest.instructions = claudeRequest.system.map(s => typeof s === 'string' ? s : s.text).join('\n'); - } else { - responsesRequest.instructions = claudeRequest.system; - } - } - - // 处理 thinking 配置 - if (claudeRequest.thinking && claudeRequest.thinking.type === 'enabled') { - responsesRequest.reasoning = { - effort: determineReasoningEffortFromBudget(claudeRequest.thinking.budget_tokens) - }; - } - - // 处理消息 - if (claudeRequest.messages && Array.isArray(claudeRequest.messages)) { - claudeRequest.messages.forEach(msg => { - const role = msg.role; - const content = msg.content; - - if (Array.isArray(content)) { - // 检查是否包含 tool_result - const toolResult = content.find(c => c.type === 'tool_result'); - if (toolResult) { - responsesRequest.input.push({ - type: 'function_call_output', - call_id: toolResult.tool_use_id, - output: typeof toolResult.content === 'string' ? toolResult.content : JSON.stringify(toolResult.content) - }); - return; - } - - // 检查是否包含 tool_use - const toolUse = content.find(c => c.type === 'tool_use'); - if (toolUse) { - responsesRequest.input.push({ - type: 'function_call', - call_id: toolUse.id, - name: toolUse.name, - arguments: typeof toolUse.input === 'string' ? toolUse.input : JSON.stringify(toolUse.input) - }); - return; - } - - const responsesContent = content.map(c => { - if (c.type === 'text') { - return { - type: role === 'assistant' ? 'output_text' : 'input_text', - text: c.text - }; - } else if (c.type === 'image') { - return { - type: 'input_image', - image_url: { - url: `data:${c.source.media_type};base64,${c.source.data}` - } - }; - } - return null; - }).filter(Boolean); - - if (responsesContent.length > 0) { - responsesRequest.input.push({ - type: 'message', - role: role, - content: responsesContent - }); - } - } else if (typeof content === 'string') { - responsesRequest.input.push({ - type: 'message', - role: role, - content: [{ - type: role === 'assistant' ? 'output_text' : 'input_text', - text: content - }] - }); - } - }); - } - - // 处理工具 - if (claudeRequest.tools && Array.isArray(claudeRequest.tools)) { - responsesRequest.tools = claudeRequest.tools.map(tool => ({ - type: 'function', - name: tool.name, - description: tool.description, - parameters: tool.input_schema || { type: 'object', properties: {} } - })); - } - - if (claudeRequest.tool_choice) { - if (claudeRequest.tool_choice.type === 'auto') { - responsesRequest.tool_choice = 'auto'; - } else if (claudeRequest.tool_choice.type === 'any') { - responsesRequest.tool_choice = 'required'; - } else if (claudeRequest.tool_choice.type === 'tool') { - responsesRequest.tool_choice = { - type: 'function', - function: { name: claudeRequest.tool_choice.name } - }; - } - } - - return responsesRequest; - } - - /** - * Claude响应 -> OpenAI Responses响应 - */ - toOpenAIResponsesResponse(claudeResponse, model) { - const content = this.processClaudeResponseContent(claudeResponse.content); - const textContent = typeof content === 'string' ? content : JSON.stringify(content); - - let output = []; - output.push({ - type: "message", - id: `msg_${uuidv4().replace(/-/g, '')}`, - summary: [], - role: "assistant", - status: "completed", - content: [{ - annotations: [], - logprobs: [], - text: textContent, - type: "output_text" - }] - }); - - return { - background: false, - created_at: Math.floor(Date.now() / 1000), - error: null, - id: `resp_${uuidv4().replace(/-/g, '')}`, - incomplete_details: null, - max_output_tokens: null, - max_tool_calls: null, - metadata: {}, - model: model || claudeResponse.model, - object: "response", - output: output, - parallel_tool_calls: true, - previous_response_id: null, - prompt_cache_key: null, - reasoning: {}, - safety_identifier: "user-" + uuidv4().replace(/-/g, ''), - service_tier: "default", - status: "completed", - store: false, - temperature: 1, - text: { - format: { type: "text" }, - }, - tool_choice: "auto", - tools: [], - top_logprobs: 0, - top_p: 1, - truncation: "disabled", - usage: { - input_tokens: claudeResponse.usage?.input_tokens || 0, - input_tokens_details: { - cached_tokens: claudeResponse.usage?.cache_read_input_tokens || 0 - }, - output_tokens: claudeResponse.usage?.output_tokens || 0, - output_tokens_details: { - reasoning_tokens: 0 - }, - total_tokens: (claudeResponse.usage?.input_tokens || 0) + (claudeResponse.usage?.output_tokens || 0) - }, - user: null - }; - } - - /** - * Claude流式响应 -> OpenAI Responses流式响应 - */ - toOpenAIResponsesStreamChunk(claudeChunk, model, requestId = null) { - if (!claudeChunk) return []; - - const responseId = requestId || `resp_${uuidv4().replace(/-/g, '')}`; - const events = []; - - // message_start 事件 - 流开始 - if (claudeChunk.type === 'message_start') { - events.push( - generateResponseCreated(responseId, model || 'unknown'), - generateResponseInProgress(responseId), - generateOutputItemAdded(responseId), - generateContentPartAdded(responseId) - ); - } - - // content_block_start 事件 - if (claudeChunk.type === 'content_block_start') { - const contentBlock = claudeChunk.content_block; - - // 对于 tool_use 类型,添加工具调用项 - if (contentBlock && contentBlock.type === 'tool_use') { - events.push({ - item: { - id: contentBlock.id, - type: "function_call", - name: contentBlock.name, - arguments: "", - status: "in_progress" - }, - output_index: claudeChunk.index || 0, - sequence_number: 2, - type: "response.output_item.added" - }); - } - } - - // content_block_delta 事件 - if (claudeChunk.type === 'content_block_delta') { - const delta = claudeChunk.delta; - - // 处理文本增量 - if (delta && delta.type === 'text_delta') { - events.push({ - delta: delta.text || "", - item_id: `msg_${uuidv4().replace(/-/g, '')}`, - output_index: claudeChunk.index || 0, - sequence_number: 3, - type: "response.output_text.delta" - }); - } - // 处理推理内容增量 - else if (delta && delta.type === 'thinking_delta') { - events.push({ - delta: delta.thinking || "", - item_id: `thinking_${uuidv4().replace(/-/g, '')}`, - output_index: claudeChunk.index || 0, - sequence_number: 3, - type: "response.reasoning_summary_text.delta" - }); - } - // 处理工具调用参数增量 - else if (delta && delta.type === 'input_json_delta') { - events.push({ - delta: delta.partial_json || "", - item_id: `call_${uuidv4().replace(/-/g, '')}`, - output_index: claudeChunk.index || 0, - sequence_number: 3, - type: "response.custom_tool_call_input.delta" - }); - } - } - - // content_block_stop 事件 - if (claudeChunk.type === 'content_block_stop') { - events.push({ - item_id: `msg_${uuidv4().replace(/-/g, '')}`, - output_index: claudeChunk.index || 0, - sequence_number: 4, - type: "response.output_item.done" - }); - } - - // message_delta 事件 - 流结束 - if (claudeChunk.type === 'message_delta') { - // events.push( - // generateOutputTextDone(responseId), - // generateContentPartDone(responseId), - // generateOutputItemDone(responseId), - // generateResponseCompleted(responseId) - // ); - - // 如果有 usage 信息,更新最后一个事件 - if (claudeChunk.usage && events.length > 0) { - const lastEvent = events[events.length - 1]; - if (lastEvent.response) { - lastEvent.response.usage = { - input_tokens: claudeChunk.usage.input_tokens || 0, - input_tokens_details: { - cached_tokens: claudeChunk.usage.cache_read_input_tokens || 0 - }, - output_tokens: claudeChunk.usage.output_tokens || 0, - output_tokens_details: { - reasoning_tokens: 0 - }, - total_tokens: (claudeChunk.usage.input_tokens || 0) + (claudeChunk.usage.output_tokens || 0) - }; - } - } - } - - // message_stop 事件 - if (claudeChunk.type === 'message_stop') { - events.push( - generateOutputTextDone(responseId), - generateContentPartDone(responseId), - generateOutputItemDone(responseId), - generateResponseCompleted(responseId) - ); - } - - return events; - } - - // ========================================================================= - // Claude -> Codex 转换 - // ========================================================================= - - /** - * 应用简单缩短规则缩短工具名称 - */ - _shortenNameIfNeeded(name) { - const limit = 64; - if (name.length <= limit) { - return name; - } - if (name.startsWith("mcp__")) { - const idx = name.lastIndexOf("__"); - if (idx > 0) { - const cand = "mcp__" + name.substring(idx + 2); - if (cand.length > limit) { - return cand.substring(0, limit); - } - return cand; - } - } - return name.substring(0, limit); - } - - /** - * 构建短名称映射以确保请求内唯一性 - */ - _buildShortNameMap(names) { - const limit = 64; - const used = new Set(); - const m = {}; - - const baseCandidate = (n) => { - if (n.length <= limit) { - return n; - } - if (n.startsWith("mcp__")) { - const idx = n.lastIndexOf("__"); - if (idx > 0) { - let cand = "mcp__" + n.substring(idx + 2); - if (cand.length > limit) { - cand = cand.substring(0, limit); - } - return cand; - } - } - return n.substring(0, limit); - }; - - const makeUnique = (cand) => { - if (!used.has(cand)) { - return cand; - } - const base = cand; - for (let i = 1; ; i++) { - const suffix = "_" + i; - const allowed = limit - suffix.length; - let tmp = base; - if (tmp.length > (allowed < 0 ? 0 : allowed)) { - tmp = tmp.substring(0, allowed < 0 ? 0 : allowed); - } - tmp = tmp + suffix; - if (!used.has(tmp)) { - return tmp; - } - } - }; - - for (const n of names) { - const cand = baseCandidate(n); - const uniq = makeUnique(cand); - used.add(uniq); - m[n] = uniq; - } - return m; - } - - /** - * 标准化工具参数,确保对象 Schema 包含 properties - */ - _normalizeToolParameters(schema) { - if (!schema || typeof schema !== 'object') { - return { type: 'object', properties: {} }; - } - const result = { ...schema }; - if (!result.type) { - result.type = 'object'; - } - if (result.type === 'object' && !result.properties) { - result.properties = {}; - } - return result; - } - - /** - * Claude请求 -> Codex请求 - */ - toCodexRequest(claudeRequest) { - const codexRequest = { - model: claudeRequest.model, - instructions: '', - input: [], - stream: true, - store: false, - parallel_tool_calls: true, - metadata: claudeRequest.metadata || {}, - reasoning: { - effort: claudeRequest.reasoning?.effort || 'medium', - summary: 'auto' - }, - include: ['reasoning.encrypted_content'] - }; - - // 处理系统指令 - if (claudeRequest.system) { - let instructions = ''; - if (Array.isArray(claudeRequest.system)) { - instructions = claudeRequest.system.map(s => typeof s === 'string' ? s : s.text).join('\n'); - } else { - instructions = claudeRequest.system; - } - codexRequest.instructions = instructions; - - // 处理 Codex 中的系统消息(作为 developer 角色添加到 input) - const systemParts = Array.isArray(claudeRequest.system) ? claudeRequest.system : [{ type: 'text', text: claudeRequest.system }]; - const developerMessage = { - type: 'message', - role: 'developer', - content: [] - }; - - systemParts.forEach(part => { - if (part.type === 'text') { - developerMessage.content.push({ - type: 'input_text', - text: part.text - }); - } else if (typeof part === 'string') { - developerMessage.content.push({ - type: 'input_text', - text: part - }); - } - }); - - if (developerMessage.content.length > 0) { - codexRequest.input.push(developerMessage); - } - } - - // 处理工具并构建短名称映射 - let shortMap = {}; - if (claudeRequest.tools && Array.isArray(claudeRequest.tools)) { - const toolNames = claudeRequest.tools.map(t => t.name).filter(Boolean); - shortMap = this._buildShortNameMap(toolNames); - - codexRequest.tools = claudeRequest.tools.map(tool => { - // 特殊处理:将 Claude Web Search 工具映射到 Codex web_search - if (tool.type === "web_search_20250305") { - return { type: "web_search" }; - } - - let name = tool.name; - if (shortMap[name]) { - name = shortMap[name]; - } else { - name = this._shortenNameIfNeeded(name); - } - - const convertedTool = { - type: 'function', - name: name, - description: tool.description || '', - parameters: this._normalizeToolParameters(tool.input_schema), - strict: false - }; - - // 移除 parameters.$schema - if (convertedTool.parameters && convertedTool.parameters.$schema) { - delete convertedTool.parameters.$schema; - } - - return convertedTool; - }); - codexRequest.tool_choice = "auto"; - } - - // 处理消息 - if (claudeRequest.messages && Array.isArray(claudeRequest.messages)) { - for (const msg of claudeRequest.messages) { - const role = msg.role; - const content = msg.content; - - let currentMessage = { - type: 'message', - role: role, - content: [] - }; - - const flushMessage = () => { - if (currentMessage.content.length > 0) { - codexRequest.input.push({ ...currentMessage }); - currentMessage.content = []; - } - }; - - const appendTextContent = (text) => { - const partType = role === 'assistant' ? 'output_text' : 'input_text'; - currentMessage.content.push({ - type: partType, - text: text - }); - }; - - const appendImageContent = (data, mediaType) => { - currentMessage.content.push({ - type: 'input_image', - image_url: `data:${mediaType};base64,${data}` - }); - }; - - if (Array.isArray(content)) { - for (const block of content) { - switch (block.type) { - case 'text': - appendTextContent(block.text); - break; - case 'image': - if (block.source) { - const data = block.source.data || block.source.base64 || ''; - const mediaType = block.source.media_type || block.source.mime_type || 'application/octet-stream'; - if (data) { - appendImageContent(data, mediaType); - } - } - break; - case 'tool_use': - flushMessage(); - let toolName = block.name; - if (shortMap[toolName]) { - toolName = shortMap[toolName]; - } else { - toolName = this._shortenNameIfNeeded(toolName); - } - codexRequest.input.push({ - type: 'function_call', - call_id: block.id, - name: toolName, - arguments: typeof block.input === 'string' ? block.input : JSON.stringify(block.input || {}) - }); - break; - case 'tool_result': - flushMessage(); - codexRequest.input.push({ - type: 'function_call_output', - call_id: block.tool_use_id, - output: typeof block.content === 'string' ? block.content : JSON.stringify(block.content || "") - }); - break; - } - } - } else if (typeof content === 'string') { - appendTextContent(content); - } - flushMessage(); - } - } - - // 处理 thinking 转换 - if (claudeRequest.thinking && claudeRequest.thinking.type === "enabled") { - const budgetTokens = claudeRequest.thinking.budget_tokens; - codexRequest.reasoning.effort = determineReasoningEffortFromBudget(budgetTokens); - } else if (claudeRequest.thinking && claudeRequest.thinking.type === "disabled") { - codexRequest.reasoning.effort = determineReasoningEffortFromBudget(0); - } - - // 注入 Codex 指令 (对应 末尾的特殊逻辑) - // 注意:这里需要检查是否需要注入 "EXECUTE ACCORDING TO THE FOLLOWING INSTRUCTIONS!!!" - // 通过 misc.GetCodexInstructionsEnabled() 判断,这里我们参考其逻辑 - const shouldInjectInstructions = process.env.CODEX_INSTRUCTIONS_ENABLED === 'true'; // 假设环境变量控制 - if (shouldInjectInstructions && codexRequest.input.length > 0) { - const firstInput = codexRequest.input[0]; - const firstText = firstInput.content && firstInput.content[0] && firstInput.content[0].text; - const instructions = "EXECUTE ACCORDING TO THE FOLLOWING INSTRUCTIONS!!!"; - if (firstText !== instructions) { - codexRequest.input.unshift({ - type: 'message', - role: 'user', - content: [{ - type: 'input_text', - text: instructions - }] - }); - } - } - - return codexRequest; - } - - /** - * Claude请求 -> Grok请求 - */ - toGrokRequest(claudeRequest) { - // 先转换为 OpenAI 格式,因为 Grok 兼容 OpenAI 格式 - const openaiRequest = this.toOpenAIRequest(claudeRequest); - return { - ...openaiRequest, - _isConverted: true - }; - } - - /** - * Claude响应 -> Codex响应 (实际上是 Codex 转 Claude) - */ - toCodexResponse(codexResponse, model) { - const content = []; - let stopReason = "end_turn"; - - if (codexResponse.response?.output) { - codexResponse.response.output.forEach(item => { - if (item.type === 'message' && item.content) { - const textPart = item.content.find(c => c.type === 'output_text'); - if (textPart) content.push({ type: 'text', text: textPart.text }); - } else if (item.type === 'reasoning' && item.summary) { - const textPart = item.summary.find(c => c.type === 'summary_text'); - if (textPart) content.push({ type: 'thinking', thinking: textPart.text }); - } else if (item.type === 'function_call') { - stopReason = "tool_use"; - content.push({ - type: 'tool_use', - id: item.call_id, - name: item.name, - input: typeof item.arguments === 'string' ? JSON.parse(item.arguments) : item.arguments - }); - } - }); - } - - return { - id: codexResponse.response?.id || `msg_${uuidv4().replace(/-/g, '')}`, - type: "message", - role: "assistant", - model: model, - content: content, - stop_reason: stopReason, - usage: { - input_tokens: codexResponse.response?.usage?.input_tokens || 0, - output_tokens: codexResponse.response?.usage?.output_tokens || 0 - } - }; - } - - /** - * Claude流式响应 -> Codex流式响应 (实际上是 Codex 转 Claude) - */ - toCodexStreamChunk(codexChunk, model) { - const type = codexChunk.type; - const resId = codexChunk.response?.id || 'default'; - - if (type === 'response.created') { - return { - type: "message_start", - message: { - id: codexChunk.response.id, - type: "message", - role: "assistant", - content: [], - model: model, - usage: { input_tokens: 0, output_tokens: 0 } - } - }; - } - - if (type === 'response.reasoning_summary_text.delta') { - return { - type: "content_block_delta", - index: 0, - delta: { type: "thinking_delta", thinking: codexChunk.delta } - }; - } - - if (type === 'response.output_text.delta') { - return { - type: "content_block_delta", - index: 0, - delta: { type: "text_delta", text: codexChunk.delta } - }; - } - - if (type === 'response.output_item.done' && codexChunk.item?.type === 'function_call') { - return [ - { - type: "content_block_start", - index: 0, - content_block: { - type: "tool_use", - id: codexChunk.item.call_id, - name: codexChunk.item.name, - input: {} - } - }, - { - type: "content_block_delta", - index: 0, - delta: { - type: "input_json_delta", - partial_json: typeof codexChunk.item.arguments === 'string' ? codexChunk.item.arguments : JSON.stringify(codexChunk.item.arguments) - } - }, - { - type: "content_block_stop", - index: 0 - } - ]; - } - - if (type === 'response.completed') { - return [ - { - type: "message_delta", - delta: { stop_reason: "end_turn" }, - usage: { - input_tokens: codexChunk.response.usage?.input_tokens || 0, - output_tokens: codexChunk.response.usage?.output_tokens || 0 - } - }, - { type: "message_stop" } - ]; - } - - return null; - } -} - -export default ClaudeConverter; diff --git a/src/converters/strategies/CodexConverter.js b/src/converters/strategies/CodexConverter.js deleted file mode 100644 index ddc1666861f4968d05fd40c7dd961f0467321f86..0000000000000000000000000000000000000000 --- a/src/converters/strategies/CodexConverter.js +++ /dev/null @@ -1,1327 +0,0 @@ -/** - * Codex 转换器 - * 处理 OpenAI 协议与 Codex 协议之间的转换 - */ - -import { v4 as uuidv4 } from 'uuid'; -import { BaseConverter } from '../BaseConverter.js'; -import { MODEL_PROTOCOL_PREFIX } from '../../utils/common.js'; -import { - generateResponseCreated, - generateResponseInProgress, - generateOutputItemAdded, - generateContentPartAdded, - generateOutputTextDone, - generateContentPartDone, - generateOutputItemDone, - generateResponseCompleted -} from '../../providers/openai/openai-responses-core.mjs'; - -export class CodexConverter extends BaseConverter { - constructor() { - super('codex'); - this.toolNameMap = new Map(); // 工具名称缩短映射: original -> short - this.reverseToolNameMap = new Map(); // 反向映射: short -> original - this.streamParams = new Map(); // 用于存储流式状态,key 为响应 ID 或临时标识 - } - - /** - * 转换请求 - */ - convertRequest(data, targetProtocol) { - throw new Error(`Unsupported target protocol: ${targetProtocol}`); - } - - /** - * 转换响应 - */ - convertResponse(data, targetProtocol, model) { - switch (targetProtocol) { - case MODEL_PROTOCOL_PREFIX.OPENAI: - return this.toOpenAIResponse(data, model); - case MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES: - return this.toOpenAIResponsesResponse(data, model); - case MODEL_PROTOCOL_PREFIX.GEMINI: - return this.toGeminiResponse(data, model); - case MODEL_PROTOCOL_PREFIX.CLAUDE: - return this.toClaudeResponse(data, model); - case MODEL_PROTOCOL_PREFIX.CODEX: - return data; // Codex to Codex - default: - throw new Error(`Unsupported target protocol: ${targetProtocol}`); - } - } - - /** - * 转换流式响应块 - */ - convertStreamChunk(chunk, targetProtocol, model, requestId) { - switch (targetProtocol) { - case MODEL_PROTOCOL_PREFIX.OPENAI: - return this.toOpenAIStreamChunk(chunk, model); - case MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES: - return this.toOpenAIResponsesStreamChunk(chunk, model); - case MODEL_PROTOCOL_PREFIX.GEMINI: - return this.toGeminiStreamChunk(chunk, model); - case MODEL_PROTOCOL_PREFIX.CLAUDE: - return this.toClaudeStreamChunk(chunk, model, requestId); - case MODEL_PROTOCOL_PREFIX.CODEX: - return chunk; // Codex to Codex - default: - throw new Error(`Unsupported target protocol: ${targetProtocol}`); - } - } - - /** - * 转换模型列表 - */ - convertModelList(data, targetProtocol) { - return data; - } - - /** - * OpenAI Responses → Codex 请求转换 - */ - toOpenAIResponsesToCodexRequest(responsesRequest) { - let codexRequest = { ...responsesRequest }; - - // 保留监控相关字段 - if (responsesRequest._monitorRequestId) { - codexRequest._monitorRequestId = responsesRequest._monitorRequestId; - } - if (responsesRequest._requestBaseUrl) { - codexRequest._requestBaseUrl = responsesRequest._requestBaseUrl; - } - - // 处理 input 字段,如果它是字符串,则转换为消息数组 - if (codexRequest.input && typeof codexRequest.input === 'string') { - const inputText = codexRequest.input; - codexRequest.input = [{ - type: "message", - role: "user", - content: [{ - type: "input_text", - text: inputText - }] - }]; - } - - // 设置Codex特定的字段 - codexRequest.stream = true; - codexRequest.store = false; - codexRequest.parallel_tool_calls = true; - codexRequest.include = ['reasoning.encrypted_content']; - codexRequest.service_tier = responsesRequest.service_tier || 'default'; - if (codexRequest.service_tier !== 'priority') { - delete codexRequest.service_tier; - } - - // 删除Codex不支持的字段 - delete codexRequest.max_output_tokens; - delete codexRequest.max_completion_tokens; - delete codexRequest.temperature; - delete codexRequest.top_p; - delete codexRequest.user; - - // 添加 reasoning 配置 - codexRequest.reasoning = { - "effort": responsesRequest.reasoning_effort || responsesRequest.reasoning?.effort || "medium", - "summary": responsesRequest.reasoning?.summary || "auto" - }; - - - // 确保 input 数组中的每个项都有 type: "message",并将系统角色转换为开发者角色 - if (codexRequest.input && Array.isArray(codexRequest.input)) { - codexRequest.input = codexRequest.input.filter(item => { - // 如果 instructions 已存在,过滤掉 input 中的 system/developer 消息以避免重复 - if (codexRequest.instructions && (item.role === 'system' || item.role === 'developer')) { - return false; - } - return true; - }).map(item => { - // 如果没有 type 或者 type 不是 message,则添加 type: "message" - if (!item.type || item.type !== 'message') { - item = { type: "message", ...item }; - } - - // 将系统角色转换为开发者角色 - if (item.role === 'system') { - item = { ...item, role: 'developer' }; - } - - return item; - }); - } - - return codexRequest; - } - - /** - * OpenAI → Codex 请求转换 - */ - toOpenAIRequestToCodexRequest(data) { - // 构建工具名称映射 - this.buildToolNameMap(data.tools || []); - - const codexRequest = { - model: data.model, - instructions: this.buildInstructions(data), - input: this.convertMessages((data.messages || []).filter(m => m.role !== 'system' && m.role !== 'developer')), - stream: true, - store: false, - metadata: data.metadata || {}, - reasoning: { - effort: data.reasoning_effort || data.reasoning?.effort || 'medium', - summary: data.reasoning?.summary || 'auto' - }, - parallel_tool_calls: true, - include: ['reasoning.encrypted_content'] - }; - - // 保留监控相关字段 - if (data._monitorRequestId) { - codexRequest._monitorRequestId = data._monitorRequestId; - } - if (data._requestBaseUrl) { - codexRequest._requestBaseUrl = data._requestBaseUrl; - } - - codexRequest.service_tier = data.service_tier || 'default'; - if (codexRequest.service_tier !== 'priority') { - delete codexRequest.service_tier; - } - - // 处理 OpenAI Responses 特有的 instructions 和 input 字段(如果存在) - if (data.instructions && !codexRequest.instructions) { - codexRequest.instructions = data.instructions; - } - - if (data.input && Array.isArray(data.input) && codexRequest.input.length === 0) { - // 如果是 OpenAI Responses 格式的 input - for (const item of data.input) { - if (item.type === 'message' && item.role !== 'system' && item.role !== 'developer') { - codexRequest.input.push({ - type: 'message', - role: item.role === 'system' ? 'developer' : item.role, - content: Array.isArray(item.content) ? item.content.map(c => ({ - type: item.role === 'assistant' ? 'output_text' : 'input_text', - text: c.text - })) : [{ - type: item.role === 'assistant' ? 'output_text' : 'input_text', - text: item.content - }] - }); - } - } - } - - if (data.tools && data.tools.length > 0) { - codexRequest.tools = this.convertTools(data.tools); - } - - if (data.tool_choice) { - codexRequest.tool_choice = this.convertToolChoice(data.tool_choice); - } - - if (data.response_format || data.text?.verbosity) { - const textObj = {}; - if (data.response_format) { - textObj.format = this.convertResponseFormat(data.response_format); - } - if (data.text?.verbosity) { - textObj.verbosity = data.text.verbosity; - } - codexRequest.text = textObj; - } - - // 在 input 开头注入特殊指令(如果配置允许) - // 这里我们默认开启,因为这是为了确保 Codex 遵循指令 - if (codexRequest.input.length > 0 && codexRequest.instructions) { - const firstMsg = codexRequest.input[0]; - const specialInstruction = "EXECUTE ACCORDING TO THE FOLLOWING INSTRUCTIONS!!!"; - const firstText = firstMsg.content?.[0]?.text; - - if (firstMsg.role === 'user' && firstText !== specialInstruction) { - codexRequest.input.unshift({ - type: "message", - role: "user", - content: [{ - type: "input_text", - text: specialInstruction - }] - }); - } - } - - return codexRequest; - } - - /** - * 构建指令 - */ - buildInstructions(data) { - // 首先检查显式的 instructions 字段 (OpenAI Responses) - if (data.instructions) return data.instructions; - - const systemMessages = (data.messages || []).filter(m => m.role === 'system'); - if (systemMessages.length > 0) { - return systemMessages.map(m => { - if (typeof m.content === 'string') { - return m.content; - } else if (Array.isArray(m.content)) { - const textPart = m.content.find(part => part.type === 'text'); - return textPart ? textPart.text : ''; - } - return ''; - }).join('\n').trim(); - } - return ''; - } - - /** - * 转换消息 - */ - convertMessages(messages) { - const input = []; - - for (const msg of messages) { - const role = msg.role; - - if (role === 'tool' || role === 'tool_result') { - input.push({ - type: 'function_call_output', - call_id: msg.tool_call_id || msg.tool_use_id, - output: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content) - }); - } else { - const codexMsg = { - type: 'message', - role: role === 'system' ? 'developer' : (role === 'model' ? 'assistant' : role), - content: this.convertMessageContent(msg.content, role) - }; - - if (codexMsg.content.length > 0) { - input.push(codexMsg); - } - - if ((role === 'assistant' || role === 'model') && msg.tool_calls) { - for (const toolCall of msg.tool_calls) { - if (toolCall.type === 'function' || toolCall.function) { - const func = toolCall.function || toolCall; - const originalName = func.name; - const shortName = this.toolNameMap.get(originalName) || this.shortenToolName(originalName); - input.push({ - type: 'function_call', - call_id: toolCall.id, - name: shortName, - arguments: typeof func.arguments === 'string' ? func.arguments : JSON.stringify(func.arguments) - }); - } - } - } - - // 处理 Claude 格式的 tool_use - if (role === 'assistant' && Array.isArray(msg.content)) { - for (const part of msg.content) { - if (part.type === 'tool_use') { - const originalName = part.name; - const shortName = this.toolNameMap.get(originalName) || this.shortenToolName(originalName); - input.push({ - type: 'function_call', - call_id: part.id, - name: shortName, - arguments: typeof part.input === 'string' ? part.input : JSON.stringify(part.input) - }); - } - } - } - } - } - - return input; - } - - /** - * 转换消息内容 - */ - convertMessageContent(content, role) { - if (!content) return []; - - const isAssistant = role === 'assistant' || role === 'model'; - - if (typeof content === 'string') { - return [{ - type: isAssistant ? 'output_text' : 'input_text', - text: content - }]; - } - - if (Array.isArray(content)) { - return content.map(part => { - if (typeof part === 'string') { - return { - type: isAssistant ? 'output_text' : 'input_text', - text: part - }; - } - if (part.type === 'text') { - return { - type: isAssistant ? 'output_text' : 'input_text', - text: part.text - }; - } else if ((part.type === 'image_url' || part.type === 'image') && !isAssistant) { - let url = ''; - if (part.image_url) { - url = typeof part.image_url === 'string' ? part.image_url : part.image_url.url; - } else if (part.source && part.source.type === 'base64') { - url = `data:${part.source.media_type};base64,${part.source.data}`; - } - return url ? { - type: 'input_image', - image_url: url - } : null; - } - return null; - }).filter(Boolean); - } - - return []; - } - - /** - * 构建工具名称映射 - */ - buildToolNameMap(tools) { - this.toolNameMap.clear(); - this.reverseToolNameMap.clear(); - - const names = []; - for (const t of tools) { - if (t.type === 'function' && t.function?.name) { - names.push(t.function.name); - } else if (t.name) { - names.push(t.name); - } - } - - if (names.length === 0) return; - - const limit = 64; - const used = new Set(); - - const baseCandidate = (n) => { - if (n.length <= limit) return n; - if (n.startsWith('mcp__')) { - const idx = n.lastIndexOf('__'); - if (idx > 0) { - let cand = 'mcp__' + n.slice(idx + 2); - return cand.length > limit ? cand.slice(0, limit) : cand; - } - } - return n.slice(0, limit); - }; - - for (const n of names) { - let cand = baseCandidate(n); - let uniq = cand; - if (used.has(uniq)) { - for (let i = 1; ; i++) { - const suffix = '_' + i; - const allowed = limit - suffix.length; - const base = cand.slice(0, Math.max(0, allowed)); - const tmp = base + suffix; - if (!used.has(tmp)) { - uniq = tmp; - break; - } - } - } - used.add(uniq); - this.toolNameMap.set(n, uniq); - this.reverseToolNameMap.set(uniq, n); - } - } - - /** - * 转换工具 - */ - convertTools(tools) { - return tools.map(tool => { - // 处理 Claude 的 web_search - if (tool.type === "web_search_20250305") { - return { type: "web_search" }; - } - - if (tool.type !== 'function' && !tool.name) { - return tool; - } - - const func = tool.function || tool; - const originalName = func.name; - const shortName = this.toolNameMap.get(originalName) || this.shortenToolName(originalName); - - const result = { - type: 'function', - name: shortName, - description: func.description, - parameters: func.parameters || func.input_schema || { type: 'object', properties: {} }, - strict: func.strict !== undefined ? func.strict : false - }; - - // 清理参数 - if (result.parameters && result.parameters.$schema) { - delete result.parameters.$schema; - } - - return result; - }); - } - - /** - * 转换 tool_choice - */ - convertToolChoice(toolChoice) { - if (typeof toolChoice === 'string') { - return toolChoice; - } - - if (toolChoice.type === 'function') { - const name = toolChoice.function?.name; - const shortName = name ? (this.toolNameMap.get(name) || this.shortenToolName(name)) : ''; - return { - type: 'function', - name: shortName - }; - } - - return toolChoice; - } - - /** - * 缩短工具名称 - */ - shortenToolName(name) { - const limit = 64; - if (name.length <= limit) return name; - if (name.startsWith('mcp__')) { - const idx = name.lastIndexOf('__'); - if (idx > 0) { - let cand = 'mcp__' + name.slice(idx + 2); - return cand.length > limit ? cand.slice(0, limit) : cand; - } - } - return name.slice(0, limit); - } - - /** - * 获取原始工具名称 - */ - getOriginalToolName(shortName) { - return this.reverseToolNameMap.get(shortName) || shortName; - } - - /** - * 转换响应格式 - */ - convertResponseFormat(responseFormat) { - if (responseFormat.type === 'json_schema') { - return { - type: 'json_schema', - name: responseFormat.json_schema?.name || 'response', - schema: responseFormat.json_schema?.schema || {} - }; - } else if (responseFormat.type === 'json_object') { - return { - type: 'json_object' - }; - } - return responseFormat; - } - - /** - * Codex → OpenAI 响应转换(非流式) - */ - toOpenAIResponse(rawJSON, model) { - const root = typeof rawJSON === 'string' ? JSON.parse(rawJSON) : rawJSON; - if (root.type !== 'response.completed') { - return null; - } - - const response = root.response; - const unixTimestamp = response.created_at || Math.floor(Date.now() / 1000); - - const openaiResponse = { - id: response.id || `chatcmpl-${Date.now()}`, - object: 'chat.completion', - created: unixTimestamp, - model: response.model || model, - choices: [{ - index: 0, - message: { - role: 'assistant', - content: null, - reasoning_content: null, - tool_calls: null - }, - finish_reason: null, - native_finish_reason: null - }], - usage: { - prompt_tokens: response.usage?.input_tokens || 0, - completion_tokens: response.usage?.output_tokens || 0, - total_tokens: response.usage?.total_tokens || 0 - } - }; - - if (response.usage?.output_tokens_details?.reasoning_tokens) { - openaiResponse.usage.completion_tokens_details = { - reasoning_tokens: response.usage.output_tokens_details.reasoning_tokens - }; - } - - const output = response.output || []; - let contentText = ''; - let reasoningText = ''; - const toolCalls = []; - - for (const item of output) { - switch (item.type) { - case 'reasoning': - if (Array.isArray(item.summary)) { - const summaryItem = item.summary.find(s => s.type === 'summary_text'); - if (summaryItem) reasoningText = summaryItem.text; - } - break; - case 'message': - if (Array.isArray(item.content)) { - const contentItem = item.content.find(c => c.type === 'output_text'); - if (contentItem) contentText = contentItem.text; - } - break; - case 'function_call': - toolCalls.push({ - id: item.call_id || `call_${Date.now()}_${toolCalls.length}`, - type: 'function', - function: { - name: this.getOriginalToolName(item.name), - arguments: typeof item.arguments === 'string' ? item.arguments : JSON.stringify(item.arguments) - } - }); - break; - } - } - - if (contentText) openaiResponse.choices[0].message.content = contentText; - if (reasoningText) openaiResponse.choices[0].message.reasoning_content = reasoningText; - if (toolCalls.length > 0) openaiResponse.choices[0].message.tool_calls = toolCalls; - - if (response.status === 'completed') { - openaiResponse.choices[0].finish_reason = toolCalls.length > 0 ? 'tool_calls' : 'stop'; - openaiResponse.choices[0].native_finish_reason = 'stop'; - } - - return openaiResponse; - } - - /** - * Codex → OpenAI Responses 响应转换 - */ - toOpenAIResponsesResponse(rawJSON, model) { - const root = typeof rawJSON === 'string' ? JSON.parse(rawJSON) : rawJSON; - if (root.type !== 'response.completed') { - return null; - } - - const response = root.response; - const unixTimestamp = response.created_at || Math.floor(Date.now() / 1000); - - const output = []; - - if (response.output && Array.isArray(response.output)) { - for (const item of response.output) { - if (item.type === 'reasoning') { - let reasoningText = ''; - if (Array.isArray(item.summary)) { - const summaryItem = item.summary.find(s => s.type === 'summary_text'); - if (summaryItem) reasoningText = summaryItem.text; - } - if (reasoningText) { - output.push({ - id: `msg_${uuidv4().replace(/-/g, '')}`, - type: "message", - role: "assistant", - status: "completed", - content: [{ - type: "reasoning", - text: reasoningText - }] - }); - } - } else if (item.type === 'message') { - let contentText = ''; - if (Array.isArray(item.content)) { - const contentItem = item.content.find(c => c.type === 'output_text'); - if (contentItem) contentText = contentItem.text; - } - if (contentText) { - output.push({ - id: `msg_${uuidv4().replace(/-/g, '')}`, - type: "message", - role: "assistant", - status: "completed", - content: [{ - type: "output_text", - text: contentText, - annotations: [] - }] - }); - } - } else if (item.type === 'function_call') { - output.push({ - id: item.call_id || `call_${uuidv4().replace(/-/g, '')}`, - type: "function_call", - name: this.getOriginalToolName(item.name), - arguments: typeof item.arguments === 'string' ? item.arguments : JSON.stringify(item.arguments), - status: "completed" - }); - } - } - } - - return { - id: response.id || `resp_${uuidv4().replace(/-/g, '')}`, - object: "response", - created_at: unixTimestamp, - model: response.model || model, - status: "completed", - output: output, - incomplete_details: response.incomplete_details || null, - usage: { - input_tokens: response.usage?.input_tokens || 0, - output_tokens: response.usage?.output_tokens || 0, - total_tokens: response.usage?.total_tokens || 0, - output_tokens_details: { - reasoning_tokens: response.usage?.output_tokens_details?.reasoning_tokens || 0 - } - } - }; - } - - /** - * Codex → Gemini 响应转换 - */ - toGeminiResponse(rawJSON, model) { - const root = typeof rawJSON === 'string' ? JSON.parse(rawJSON) : rawJSON; - if (root.type !== 'response.completed') { - return null; - } - - const response = root.response; - const parts = []; - - if (response.output && Array.isArray(response.output)) { - for (const item of response.output) { - if (item.type === 'reasoning') { - let reasoningText = ''; - if (Array.isArray(item.summary)) { - const summaryItem = item.summary.find(s => s.type === 'summary_text'); - if (summaryItem) reasoningText = summaryItem.text; - } - if (reasoningText) { - parts.push({ text: reasoningText, thought: true }); - } - } else if (item.type === 'message') { - let contentText = ''; - if (Array.isArray(item.content)) { - const contentItem = item.content.find(c => c.type === 'output_text'); - if (contentItem) contentText = contentItem.text; - } - if (contentText) { - parts.push({ text: contentText }); - } - } else if (item.type === 'function_call') { - parts.push({ - functionCall: { - name: this.getOriginalToolName(item.name), - args: typeof item.arguments === 'string' ? JSON.parse(item.arguments) : item.arguments - } - }); - } - } - } - - return { - candidates: [{ - content: { - role: "model", - parts: parts - }, - finishReason: "STOP" - }], - usageMetadata: { - promptTokenCount: response.usage?.input_tokens || 0, - candidatesTokenCount: response.usage?.output_tokens || 0, - totalTokenCount: response.usage?.total_tokens || 0 - }, - modelVersion: response.model || model, - responseId: response.id - }; - } - - /** - * Codex → Claude 响应转换 - */ - toClaudeResponse(rawJSON, model) { - const root = typeof rawJSON === 'string' ? JSON.parse(rawJSON) : rawJSON; - if (root.type !== 'response.completed') { - return null; - } - - const response = root.response; - const content = []; - let stopReason = "end_turn"; - - if (response.output && Array.isArray(response.output)) { - for (const item of response.output) { - if (item.type === 'reasoning') { - let reasoningText = ''; - if (Array.isArray(item.summary)) { - const summaryItem = item.summary.find(s => s.type === 'summary_text'); - if (summaryItem) reasoningText = summaryItem.text; - } - if (reasoningText) { - content.push({ type: "thinking", thinking: reasoningText }); - } - } else if (item.type === 'message') { - let contentText = ''; - if (Array.isArray(item.content)) { - const contentItem = item.content.find(c => c.type === 'output_text'); - if (contentItem) contentText = contentItem.text; - } - if (contentText) { - content.push({ type: "text", text: contentText }); - } - } else if (item.type === 'function_call') { - stopReason = "tool_use"; - content.push({ - type: "tool_use", - id: item.call_id || `call_${uuidv4().replace(/-/g, '')}`, - name: this.getOriginalToolName(item.name), - input: typeof item.arguments === 'string' ? JSON.parse(item.arguments) : item.arguments - }); - } - } - } - - return { - id: response.id || `msg_${uuidv4().replace(/-/g, '')}`, - type: "message", - role: "assistant", - model: response.model || model, - content: content, - stop_reason: stopReason, - usage: { - input_tokens: response.usage?.input_tokens || 0, - output_tokens: response.usage?.output_tokens || 0 - } - }; - } - - /** - * Codex → OpenAI 流式响应块转换 - */ - toOpenAIStreamChunk(chunk, model) { - const type = chunk.type; - // 使用固定的 key 来存储当前流的状态 - const stateKey = 'openai_stream_current'; - - if (!this.streamParams.has(stateKey)) { - this.streamParams.set(stateKey, { - model: model, - createdAt: Math.floor(Date.now() / 1000), - responseID: chunk.response?.id || `chatcmpl-${Date.now()}`, - functionCallIndex: 0, // 初始值为 0,第一个 function_call 的 index 为 0 - isFirstChunk: true // 标记是否是第一个内容 chunk - }); - } - const state = this.streamParams.get(stateKey); - - // 构建模板时使用当前状态中的值 - const buildTemplate = () => ({ - id: state.responseID, - object: 'chat.completion.chunk', - created: state.createdAt, - model: state.model, - choices: [{ - index: 0, - delta: { - role: 'assistant', - content: null, - reasoning_content: null, - tool_calls: null - }, - finish_reason: null, - native_finish_reason: null - }] - }); - - if (type === 'response.created') { - // 更新状态中的 responseID - state.responseID = chunk.response.id; - state.createdAt = chunk.response.created_at || state.createdAt; - state.model = chunk.response.model || state.model; - // 重置 functionCallIndex,确保每个新请求从 0 开始 - state.functionCallIndex = 0; - state.isFirstChunk = true; - // response.created 不发送 chunk,等待第一个内容 chunk - return null; - } - - if (type === 'response.reasoning_summary_text.delta') { - const results = []; - // 如果是第一个内容 chunk,先发送带 role 的 chunk - if (state.isFirstChunk) { - const firstTemplate = buildTemplate(); - firstTemplate.choices[0].delta = { - role: 'assistant', - content: null, - reasoning_content: chunk.delta, - tool_calls: null - }; - results.push(firstTemplate); - state.isFirstChunk = false; - } else { - const template = buildTemplate(); - template.choices[0].delta = { - role: 'assistant', - content: null, - reasoning_content: chunk.delta, - tool_calls: null - }; - results.push(template); - } - return results.length === 1 ? results[0] : results; - } - - if (type === 'response.reasoning_summary_text.done') { - const template = buildTemplate(); - template.choices[0].delta = { - role: 'assistant', - content: null, - reasoning_content: '\n\n', - tool_calls: null - }; - return template; - } - - if (type === 'response.output_text.delta') { - const results = []; - // 如果是第一个内容 chunk,先发送带 role 的 chunk - if (state.isFirstChunk) { - const firstTemplate = buildTemplate(); - firstTemplate.choices[0].delta = { - role: 'assistant', - content: chunk.delta, - reasoning_content: null, - tool_calls: null - }; - results.push(firstTemplate); - state.isFirstChunk = false; - } else { - const template = buildTemplate(); - template.choices[0].delta = { - role: 'assistant', - content: chunk.delta, - reasoning_content: null, - tool_calls: null - }; - results.push(template); - } - return results.length === 1 ? results[0] : results; - } - - if (type === 'response.output_item.done' && chunk.item?.type === 'function_call') { - const currentIndex = state.functionCallIndex; - state.functionCallIndex++; // 递增,为下一个 function_call 准备 - const template = buildTemplate(); - template.choices[0].delta = { - role: 'assistant', - content: null, - reasoning_content: null, - tool_calls: [{ - index: currentIndex, - id: chunk.item.call_id, - type: 'function', - function: { - name: this.getOriginalToolName(chunk.item.name), - arguments: typeof chunk.item.arguments === 'string' ? chunk.item.arguments : JSON.stringify(chunk.item.arguments) - } - }] - }; - return template; - } - - if (type === 'response.completed') { - const template = buildTemplate(); - const finishReason = state.functionCallIndex > 0 ? 'tool_calls' : 'stop'; - template.choices[0].delta = { - role: null, - content: null, - reasoning_content: null, - tool_calls: null - }; - template.choices[0].finish_reason = finishReason; - template.choices[0].native_finish_reason = finishReason; - template.usage = { - prompt_tokens: chunk.response.usage?.input_tokens || 0, - completion_tokens: chunk.response.usage?.output_tokens || 0, - total_tokens: chunk.response.usage?.total_tokens || 0 - }; - if (chunk.response.usage?.output_tokens_details?.reasoning_tokens) { - template.usage.completion_tokens_details = { - reasoning_tokens: chunk.response.usage.output_tokens_details.reasoning_tokens - }; - } - // 完成后清理状态 - this.streamParams.delete(stateKey); - return template; - } - - return null; - } - - /** - * Codex → OpenAI Responses 流式响应转换 - */ - toOpenAIResponsesStreamChunk(chunk, model) { - if(true){ - return chunk; - } - - const type = chunk.type; - const resId = chunk.response?.id || 'default'; - - if (!this.streamParams.has(resId)) { - this.streamParams.set(resId, { - model: model, - createdAt: Math.floor(Date.now() / 1000), - responseID: resId, - functionCallIndex: -1, - eventsSent: new Set() - }); - } - const state = this.streamParams.get(resId); - const events = []; - - if (type === 'response.created') { - state.responseID = chunk.response.id; - state.model = chunk.response.model || state.model; - events.push( - generateResponseCreated(state.responseID, state.model), - generateResponseInProgress(state.responseID) - ); - return events; - } - - if (type === 'response.reasoning_summary_text.delta') { - events.push({ - type: "response.reasoning_summary_text.delta", - response_id: state.responseID, - delta: chunk.delta - }); - return events; - } - - if (type === 'response.output_text.delta') { - if (!state.eventsSent.has('output_item_added')) { - events.push(generateOutputItemAdded(state.responseID)); - state.eventsSent.add('output_item_added'); - } - if (!state.eventsSent.has('content_part_added')) { - events.push(generateContentPartAdded(state.responseID)); - state.eventsSent.add('content_part_added'); - } - events.push({ - type: "response.output_text.delta", - response_id: state.responseID, - delta: chunk.delta - }); - return events; - } - - if (type === 'response.output_item.done' && chunk.item?.type === 'function_call') { - events.push({ - type: "response.output_item.added", - response_id: state.responseID, - item: { - id: chunk.item.call_id, - type: "function_call", - name: this.getOriginalToolName(chunk.item.name), - arguments: typeof chunk.item.arguments === 'string' ? chunk.item.arguments : JSON.stringify(chunk.item.arguments), - status: "completed" - } - }); - events.push({ - type: "response.output_item.done", - response_id: state.responseID, - item_id: chunk.item.call_id - }); - return events; - } - - if (type === 'response.completed') { - events.push( - generateOutputTextDone(state.responseID), - generateContentPartDone(state.responseID), - generateOutputItemDone(state.responseID) - ); - const completedEvent = generateResponseCompleted(state.responseID); - completedEvent.response.usage = { - input_tokens: chunk.response.usage?.input_tokens || 0, - output_tokens: chunk.response.usage?.output_tokens || 0, - total_tokens: chunk.response.usage?.total_tokens || 0 - }; - events.push(completedEvent); - this.streamParams.delete(resId); - return events; - } - - return null; - } - - /** - * Codex → Gemini 流式响应转换 - */ - toGeminiStreamChunk(chunk, model) { - const type = chunk.type; - const resId = chunk.response?.id || 'default'; - - if (!this.streamParams.has(resId)) { - this.streamParams.set(resId, { - model: model, - createdAt: Math.floor(Date.now() / 1000), - responseID: resId - }); - } - const state = this.streamParams.get(resId); - - const template = { - candidates: [{ - content: { - role: "model", - parts: [] - } - }], - modelVersion: state.model, - responseId: state.responseID - }; - - if (type === 'response.reasoning_summary_text.delta') { - template.candidates[0].content.parts.push({ text: chunk.delta, thought: true }); - return template; - } - - if (type === 'response.output_text.delta') { - template.candidates[0].content.parts.push({ text: chunk.delta }); - return template; - } - - if (type === 'response.output_item.done' && chunk.item?.type === 'function_call') { - template.candidates[0].content.parts.push({ - functionCall: { - name: this.getOriginalToolName(chunk.item.name), - args: typeof chunk.item.arguments === 'string' ? JSON.parse(chunk.item.arguments) : chunk.item.arguments - } - }); - return template; - } - - if (type === 'response.completed') { - template.candidates[0].finishReason = "STOP"; - template.usageMetadata = { - promptTokenCount: chunk.response.usage?.input_tokens || 0, - candidatesTokenCount: chunk.response.usage?.output_tokens || 0, - totalTokenCount: chunk.response.usage?.total_tokens || 0 - }; - this.streamParams.delete(resId); - return template; - } - - return null; - } - - /** - * Codex → Claude 流式响应转换 - */ - toClaudeStreamChunk(chunk, model, requestId) { - const type = chunk.type; - - // 使用 requestId 作为流状态的隔离 key(并发安全)。 - // 每个请求在 handleStreamRequest 中生成唯一 requestId, - // 确保同一单例 converter 上的并发流状态完全独立。 - const stateKey = requestId || chunk.response?.id || 'default'; - - // response.created 携带 response.id,用它来初始化该请求的流状态 - if (type === 'response.created') { - const resId = chunk.response.id; - this.streamParams.set(stateKey, { - model: model, - createdAt: Math.floor(Date.now() / 1000), - responseID: resId, - blockIndex: 0, - blockStarted: false, - currentBlockType: null, - }); - const state = this.streamParams.get(stateKey); - return { - type: "message_start", - message: { - id: state.responseID, - type: "message", - role: "assistant", - content: [], - model: state.model, - usage: { input_tokens: 0, output_tokens: 0 } - } - }; - } - - if (!this.streamParams.has(stateKey)) { - // 如果还没有状态(比如没有收到 response.created 就收到了其他事件), - // 用 chunk 中能拿到的信息初始化 - this.streamParams.set(stateKey, { - model: model, - createdAt: Math.floor(Date.now() / 1000), - responseID: chunk.response?.id || stateKey, - blockIndex: 0, - blockStarted: false, - currentBlockType: null, - }); - } - const state = this.streamParams.get(stateKey); - - // response.output_item.added 不产生 Claude 输出 - if (type === 'response.output_item.added') { - return null; - } - - if (type === 'response.created') { - // 已在上方处理,不应到达此处 - return null; - } - - if (type === 'response.reasoning_summary_text.delta') { - const events = []; - // If switching from a different block type, close the previous block first - if (state.blockStarted && state.currentBlockType !== 'thinking') { - events.push({ type: "content_block_stop", index: state.blockIndex }); - state.blockIndex++; - state.blockStarted = false; - } - // Emit content_block_start on first delta for this thinking block - if (!state.blockStarted) { - events.push({ - type: "content_block_start", - index: state.blockIndex, - content_block: { type: "thinking", thinking: "" } - }); - state.blockStarted = true; - state.currentBlockType = 'thinking'; - } - events.push({ - type: "content_block_delta", - index: state.blockIndex, - delta: { type: "thinking_delta", thinking: chunk.delta } - }); - return events; - } - - if (type === 'response.output_text.delta') { - const events = []; - // If switching from a different block type, close the previous block first - if (state.blockStarted && state.currentBlockType !== 'text') { - events.push({ type: "content_block_stop", index: state.blockIndex }); - state.blockIndex++; - state.blockStarted = false; - } - // Emit content_block_start on first delta for this text block - if (!state.blockStarted) { - events.push({ - type: "content_block_start", - index: state.blockIndex, - content_block: { type: "text", text: "" } - }); - state.blockStarted = true; - state.currentBlockType = 'text'; - } - events.push({ - type: "content_block_delta", - index: state.blockIndex, - delta: { type: "text_delta", text: chunk.delta } - }); - return events; - } - - if (type === 'response.output_item.done' && chunk.item?.type === 'function_call') { - const events = []; - // Close any open text/thinking block before tool_use - if (state.blockStarted) { - events.push({ type: "content_block_stop", index: state.blockIndex }); - state.blockIndex++; - state.blockStarted = false; - state.currentBlockType = null; - } - events.push( - { - type: "content_block_start", - index: state.blockIndex, - content_block: { - type: "tool_use", - id: chunk.item.call_id, - name: this.getOriginalToolName(chunk.item.name), - input: {} - } - }, - { - type: "content_block_delta", - index: state.blockIndex, - delta: { - type: "input_json_delta", - partial_json: typeof chunk.item.arguments === 'string' ? chunk.item.arguments : JSON.stringify(chunk.item.arguments) - } - }, - { - type: "content_block_stop", - index: state.blockIndex - } - ); - state.blockIndex++; - return events; - } - - if (type === 'response.completed') { - const events = []; - // Close any open content block before ending the message - if (state.blockStarted) { - events.push({ type: "content_block_stop", index: state.blockIndex }); - } - events.push( - { - type: "message_delta", - delta: { stop_reason: "end_turn" }, - usage: { - input_tokens: chunk.response.usage?.input_tokens || 0, - output_tokens: chunk.response.usage?.output_tokens || 0 - } - }, - { type: "message_stop" } - ); - // 清理该请求的流状态 - this.streamParams.delete(stateKey); - return events; - } - - return null; - } - -} diff --git a/src/converters/strategies/GeminiConverter.js b/src/converters/strategies/GeminiConverter.js deleted file mode 100644 index af6f5d3a50e0990a6fb7a55ab3f77909d158270a..0000000000000000000000000000000000000000 --- a/src/converters/strategies/GeminiConverter.js +++ /dev/null @@ -1,1529 +0,0 @@ -/** - * Gemini转换器 - * 处理Gemini(Google)协议与其他协议之间的转换 - */ - -import { v4 as uuidv4 } from 'uuid'; -import { BaseConverter } from '../BaseConverter.js'; -import { - checkAndAssignOrDefault, - OPENAI_DEFAULT_MAX_TOKENS, - OPENAI_DEFAULT_TEMPERATURE, - OPENAI_DEFAULT_TOP_P, - CLAUDE_DEFAULT_MAX_TOKENS, - CLAUDE_DEFAULT_TEMPERATURE, - CLAUDE_DEFAULT_TOP_P -} from '../utils.js'; -import { MODEL_PROTOCOL_PREFIX } from '../../utils/common.js'; -import { - generateResponseCreated, - generateResponseInProgress, - generateOutputItemAdded, - generateContentPartAdded, - generateOutputTextDone, - generateContentPartDone, - generateOutputItemDone, - generateResponseCompleted -} from '../../providers/openai/openai-responses-core.mjs'; - -/** - * 修复 Gemini 返回的工具参数名称问题 - * Gemini 有时会使用不同的参数名称,需要映射到 Claude Code 期望的格式 - */ -function remapFunctionCallArgs(toolName, args) { - if (!args || typeof args !== 'object') return args; - - const remappedArgs = { ...args }; - const toolNameLower = toolName.toLowerCase(); - - // [IMPORTANT] Claude Code CLI 的 EnterPlanMode 工具禁止携带任何参数 - if (toolName === 'EnterPlanMode') { - return {}; - } - - switch (toolNameLower) { - case 'grep': - case 'search': - case 'search_code_definitions': - case 'search_code_snippets': - // [FIX] Gemini hallucination: maps parameter description to "description" field - if (remappedArgs.description && !remappedArgs.pattern) { - remappedArgs.pattern = remappedArgs.description; - delete remappedArgs.description; - } - - // Gemini uses "query", Claude Code expects "pattern" - if (remappedArgs.query && !remappedArgs.pattern) { - remappedArgs.pattern = remappedArgs.query; - delete remappedArgs.query; - } - - // [CRITICAL FIX] Claude Code uses "path" (string), NOT "paths" (array)! - if (!remappedArgs.path) { - if (remappedArgs.paths) { - if (Array.isArray(remappedArgs.paths)) { - remappedArgs.path = remappedArgs.paths[0] || '.'; - } else if (typeof remappedArgs.paths === 'string') { - remappedArgs.path = remappedArgs.paths; - } else { - remappedArgs.path = '.'; - } - delete remappedArgs.paths; - } else { - // Default to current directory if missing - remappedArgs.path = '.'; - } - } - // Note: We keep "-n" and "output_mode" if present as they are valid in Grep schema - break; - - case 'glob': - // [FIX] Gemini hallucination: maps parameter description to "description" field - if (remappedArgs.description && !remappedArgs.pattern) { - remappedArgs.pattern = remappedArgs.description; - delete remappedArgs.description; - } - - // Gemini uses "query", Claude Code expects "pattern" - if (remappedArgs.query && !remappedArgs.pattern) { - remappedArgs.pattern = remappedArgs.query; - delete remappedArgs.query; - } - - // [CRITICAL FIX] Claude Code uses "path" (string), NOT "paths" (array)! - // [NOTE] 与 grep 不同,glob 不添加默认 path(参考 Rust 代码) - if (!remappedArgs.path) { - if (remappedArgs.paths) { - if (Array.isArray(remappedArgs.paths)) { - remappedArgs.path = remappedArgs.paths[0] || '.'; - } else if (typeof remappedArgs.paths === 'string') { - remappedArgs.path = remappedArgs.paths; - } else { - remappedArgs.path = '.'; - } - delete remappedArgs.paths; - } - // [FIX] glob 不添加默认 path,与 Rust 代码保持一致 - } - break; - - case 'read': - // Gemini might use "path" vs "file_path" - if (remappedArgs.path && !remappedArgs.file_path) { - remappedArgs.file_path = remappedArgs.path; - delete remappedArgs.path; - } - break; - - case 'ls': - // LS tool: ensure "path" parameter exists - if (!remappedArgs.path) { - remappedArgs.path = '.'; - } - break; - - default: - // [NEW] [Issue #785] Generic Property Mapping for all tools - // If a tool has "paths" (array of 1) but no "path", convert it. - // [FIX] 与 Rust 代码保持一致:只在 paths.length === 1 时转换,不删除原始 paths - if (!remappedArgs.path && remappedArgs.paths) { - if (Array.isArray(remappedArgs.paths) && remappedArgs.paths.length === 1) { - const pathValue = remappedArgs.paths[0]; - if (typeof pathValue === 'string') { - remappedArgs.path = pathValue; - // [FIX] Rust 代码中不删除 paths,这里也不删除 - } - } - } - break; - } - - return remappedArgs; -} - -/** - * [FIX] 规范化工具名称 - * Gemini 有时会返回 "search" 而不是 "Grep" - */ -function normalizeToolName(name) { - if (!name) return name; - - const nameLower = name.toLowerCase(); - if (nameLower === 'search') { - return 'Grep'; - } - return name; -} - -/** - * Gemini转换器类 - * 实现Gemini协议到其他协议的转换 - */ -export class GeminiConverter extends BaseConverter { - constructor() { - super('gemini'); - } - - /** - * 转换请求 - */ - convertRequest(data, targetProtocol) { - switch (targetProtocol) { - case MODEL_PROTOCOL_PREFIX.OPENAI: - return this.toOpenAIRequest(data); - case MODEL_PROTOCOL_PREFIX.CLAUDE: - return this.toClaudeRequest(data); - case MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES: - return this.toOpenAIResponsesRequest(data); - case MODEL_PROTOCOL_PREFIX.CODEX: - return this.toCodexRequest(data); - case MODEL_PROTOCOL_PREFIX.GROK: - return this.toGrokRequest(data); - default: - throw new Error(`Unsupported target protocol: ${targetProtocol}`); - } - } - - /** - * 转换响应 - */ - convertResponse(data, targetProtocol, model) { - switch (targetProtocol) { - case MODEL_PROTOCOL_PREFIX.OPENAI: - return this.toOpenAIResponse(data, model); - case MODEL_PROTOCOL_PREFIX.CLAUDE: - return this.toClaudeResponse(data, model); - case MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES: - return this.toOpenAIResponsesResponse(data, model); - case MODEL_PROTOCOL_PREFIX.CODEX: - return this.toCodexResponse(data, model); - default: - throw new Error(`Unsupported target protocol: ${targetProtocol}`); - } - } - - /** - * 转换流式响应块 - */ - convertStreamChunk(chunk, targetProtocol, model) { - switch (targetProtocol) { - case MODEL_PROTOCOL_PREFIX.OPENAI: - return this.toOpenAIStreamChunk(chunk, model); - case MODEL_PROTOCOL_PREFIX.CLAUDE: - return this.toClaudeStreamChunk(chunk, model); - case MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES: - return this.toOpenAIResponsesStreamChunk(chunk, model); - case MODEL_PROTOCOL_PREFIX.CODEX: - return this.toCodexStreamChunk(chunk, model); - default: - throw new Error(`Unsupported target protocol: ${targetProtocol}`); - } - } - - /** - * 转换模型列表 - */ - convertModelList(data, targetProtocol) { - switch (targetProtocol) { - case MODEL_PROTOCOL_PREFIX.OPENAI: - return this.toOpenAIModelList(data); - case MODEL_PROTOCOL_PREFIX.CLAUDE: - return this.toClaudeModelList(data); - default: - return data; - } - } - - // ========================================================================= - // Gemini -> OpenAI 转换 - // ========================================================================= - - /** - * Gemini请求 -> OpenAI请求 - */ - toOpenAIRequest(geminiRequest) { - const openaiRequest = { - messages: [], - model: geminiRequest.model, - max_tokens: checkAndAssignOrDefault(geminiRequest.max_tokens, OPENAI_DEFAULT_MAX_TOKENS), - temperature: checkAndAssignOrDefault(geminiRequest.temperature, OPENAI_DEFAULT_TEMPERATURE), - top_p: checkAndAssignOrDefault(geminiRequest.top_p, OPENAI_DEFAULT_TOP_P), - }; - - // 处理系统指令 - if (geminiRequest.systemInstruction && Array.isArray(geminiRequest.systemInstruction.parts)) { - const systemContent = this.processGeminiPartsToOpenAIContent(geminiRequest.systemInstruction.parts); - if (systemContent) { - openaiRequest.messages.push({ - role: 'system', - content: systemContent - }); - } - } - - // 处理内容 - if (geminiRequest.contents && Array.isArray(geminiRequest.contents)) { - geminiRequest.contents.forEach(content => { - if (content && Array.isArray(content.parts)) { - const openaiContent = this.processGeminiPartsToOpenAIContent(content.parts); - if (openaiContent && openaiContent.length > 0) { - const openaiRole = content.role === 'model' ? 'assistant' : content.role; - openaiRequest.messages.push({ - role: openaiRole, - content: openaiContent - }); - } - } - }); - } - - return openaiRequest; - } - - /** - * Gemini响应 -> OpenAI响应 - */ - toOpenAIResponse(geminiResponse, model) { - const content = this.processGeminiResponseContent(geminiResponse); - - // 提取 tool_calls - const toolCalls = []; - let finishReason = "stop"; - - if (geminiResponse && geminiResponse.candidates) { - for (const candidate of geminiResponse.candidates) { - if (candidate.content && candidate.content.parts) { - for (const part of candidate.content.parts) { - if (part.functionCall) { - toolCalls.push({ - id: part.functionCall.id || `call_${uuidv4()}`, - type: 'function', - function: { - name: part.functionCall.name, - arguments: typeof part.functionCall.args === 'string' - ? part.functionCall.args - : JSON.stringify(part.functionCall.args) - } - }); - } - } - } - } - } - - // 如果有工具调用,设置 finish_reason 为 tool_calls - if (toolCalls.length > 0) { - finishReason = "tool_calls"; - } - - const message = { - role: "assistant", - content: content - }; - - // 只有在有 tool_calls 时才添加该字段 - if (toolCalls.length > 0) { - message.tool_calls = toolCalls; - } - - return { - id: `chatcmpl-${uuidv4()}`, - object: "chat.completion", - created: Math.floor(Date.now() / 1000), - model: model, - choices: [{ - index: 0, - message: message, - finish_reason: finishReason, - }], - usage: geminiResponse.usageMetadata ? { - prompt_tokens: geminiResponse.usageMetadata.promptTokenCount || 0, - completion_tokens: geminiResponse.usageMetadata.candidatesTokenCount || 0, - total_tokens: geminiResponse.usageMetadata.totalTokenCount || 0, - cached_tokens: geminiResponse.usageMetadata.cachedContentTokenCount || 0, - prompt_tokens_details: { - cached_tokens: geminiResponse.usageMetadata.cachedContentTokenCount || 0 - }, - completion_tokens_details: { - reasoning_tokens: geminiResponse.usageMetadata.thoughtsTokenCount || 0 - } - } : { - prompt_tokens: 0, - completion_tokens: 0, - total_tokens: 0, - cached_tokens: 0, - prompt_tokens_details: { - cached_tokens: 0 - }, - completion_tokens_details: { - reasoning_tokens: 0 - } - }, - }; - } - - /** - * Gemini流式响应 -> OpenAI流式响应 - */ - toOpenAIStreamChunk(geminiChunk, model) { - if (!geminiChunk) return null; - - const candidate = geminiChunk.candidates?.[0]; - if (!candidate) return null; - - let content = ''; - const toolCalls = []; - - // 从parts中提取文本和tool calls - const parts = candidate.content?.parts; - if (parts && Array.isArray(parts)) { - for (const part of parts) { - if (part.text) { - content += part.text; - } - if (part.functionCall) { - toolCalls.push({ - index: toolCalls.length, - id: part.functionCall.id || `call_${uuidv4()}`, - type: 'function', - function: { - name: part.functionCall.name, - arguments: typeof part.functionCall.args === 'string' - ? part.functionCall.args - : JSON.stringify(part.functionCall.args) - } - }); - } - // thoughtSignature is ignored (internal Gemini data) - } - } - - // 处理finishReason - let finishReason = null; - if (candidate.finishReason) { - const finishReasonMap = { - 'FINISH_REASON_UNSPECIFIED': 'stop', - 'STOP': 'stop', - 'MAX_TOKENS': 'length', - 'SAFETY': 'content_filter', - 'RECITATION': 'content_filter', - 'OTHER': 'stop', - 'BLOCKLIST': 'content_filter', - 'PROHIBITED_CONTENT': 'content_filter', - 'SPII': 'content_filter', - 'MALFORMED_FUNCTION_CALL': 'stop', - 'MODEL_ARMOR': 'content_filter', - }; - finishReason = finishReasonMap[candidate.finishReason] || 'stop'; - } - - // [FIX] 适配 Gemini 流式:Gemini 的最后一条流式消息通常不带 functionCall - // 如果当前 chunk 包含工具调用,直接将其标记为 tool_calls - if (toolCalls.length > 0) { - finishReason = 'tool_calls'; - } - - // 构建delta对象 - const delta = {}; - if (content) delta.content = content; - if (toolCalls.length > 0) delta.tool_calls = toolCalls; - - // Don't return empty delta chunks - if (Object.keys(delta).length === 0 && !finishReason) { - return null; - } - - const chunk = { - id: `chatcmpl-${uuidv4()}`, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model: model, - choices: [{ - index: 0, - delta: delta, - finish_reason: finishReason, - }], - }; - - if(geminiChunk.usageMetadata){ - chunk.usage = { - prompt_tokens: geminiChunk.usageMetadata.promptTokenCount || 0, - completion_tokens: geminiChunk.usageMetadata.candidatesTokenCount || 0, - total_tokens: geminiChunk.usageMetadata.totalTokenCount || 0, - cached_tokens: geminiChunk.usageMetadata.cachedContentTokenCount || 0, - prompt_tokens_details: { - cached_tokens: geminiChunk.usageMetadata.cachedContentTokenCount || 0 - }, - completion_tokens_details: { - reasoning_tokens: geminiChunk.usageMetadata.thoughtsTokenCount || 0 - } - }; - } - - return chunk; - } - - /** - * Gemini模型列表 -> OpenAI模型列表 - */ - toOpenAIModelList(geminiModels) { - return { - object: "list", - data: geminiModels.models.map(m => { - const modelId = m.name.startsWith('models/') ? m.name.substring(7) : m.name; - return { - id: modelId, - object: "model", - created: Math.floor(Date.now() / 1000), - owned_by: "google", - display_name: m.displayName || modelId, - }; - }), - }; - } - - /** - * 处理Gemini parts到OpenAI内容 - */ - processGeminiPartsToOpenAIContent(parts) { - if (!parts || !Array.isArray(parts)) return ''; - - const contentArray = []; - - parts.forEach(part => { - if (!part) return; - - if (typeof part.text === 'string') { - contentArray.push({ - type: 'text', - text: part.text - }); - } - - if (part.inlineData) { - const { mimeType, data } = part.inlineData; - if (mimeType && data) { - contentArray.push({ - type: 'image_url', - image_url: { - url: `data:${mimeType};base64,${data}` - } - }); - } - } - - if (part.fileData) { - const { mimeType, fileUri } = part.fileData; - if (mimeType && fileUri) { - if (mimeType.startsWith('image/')) { - contentArray.push({ - type: 'image_url', - image_url: { - url: fileUri - } - }); - } else if (mimeType.startsWith('audio/')) { - contentArray.push({ - type: 'text', - text: `[Audio file: ${fileUri}]` - }); - } - } - } - }); - - return contentArray.length === 1 && contentArray[0].type === 'text' - ? contentArray[0].text - : contentArray; - } - - /** - * 处理Gemini响应内容 - */ - processGeminiResponseContent(geminiResponse) { - if (!geminiResponse || !geminiResponse.candidates) return ''; - - const contents = []; - - geminiResponse.candidates.forEach(candidate => { - if (candidate.content && candidate.content.parts) { - candidate.content.parts.forEach(part => { - if (part.text) { - contents.push(part.text); - } - }); - } - }); - - return contents.join('\n'); - } - - // ========================================================================= - // Gemini -> Claude 转换 - // ========================================================================= - - /** - * Gemini请求 -> Claude请求 - */ - toClaudeRequest(geminiRequest) { - const claudeRequest = { - model: geminiRequest.model || 'claude-3-opus', - messages: [], - max_tokens: checkAndAssignOrDefault(geminiRequest.generationConfig?.maxOutputTokens, CLAUDE_DEFAULT_MAX_TOKENS), - temperature: checkAndAssignOrDefault(geminiRequest.generationConfig?.temperature, CLAUDE_DEFAULT_TEMPERATURE), - top_p: checkAndAssignOrDefault(geminiRequest.generationConfig?.topP, CLAUDE_DEFAULT_TOP_P), - }; - - // 处理系统指令 - if (geminiRequest.systemInstruction && geminiRequest.systemInstruction.parts) { - const systemText = geminiRequest.systemInstruction.parts - .filter(p => p.text) - .map(p => p.text) - .join('\n'); - if (systemText) { - claudeRequest.system = systemText; - } - } - - // 处理内容 - if (geminiRequest.contents && Array.isArray(geminiRequest.contents)) { - geminiRequest.contents.forEach(content => { - if (!content || !content.parts) return; - - const role = content.role === 'model' ? 'assistant' : 'user'; - const claudeContent = this.processGeminiPartsToClaudeContent(content.parts); - - if (claudeContent.length > 0) { - claudeRequest.messages.push({ - role: role, - content: claudeContent - }); - } - }); - } - - // 处理工具 - if (geminiRequest.tools && geminiRequest.tools[0]?.functionDeclarations) { - claudeRequest.tools = geminiRequest.tools[0].functionDeclarations.map(func => ({ - name: func.name, - description: func.description || '', - input_schema: func.parameters || { type: 'object', properties: {} } - })); - } - - return claudeRequest; - } - - /** - * Gemini响应 -> Claude响应 - */ - toClaudeResponse(geminiResponse, model) { - if (!geminiResponse || !geminiResponse.candidates || geminiResponse.candidates.length === 0) { - return { - id: `msg_${uuidv4()}`, - type: "message", - role: "assistant", - content: [], - model: model, - stop_reason: "end_turn", - stop_sequence: null, - usage: { - input_tokens: geminiResponse?.usageMetadata?.promptTokenCount || 0, - output_tokens: geminiResponse?.usageMetadata?.candidatesTokenCount || 0 - } - }; - } - - const candidate = geminiResponse.candidates[0]; - const { content, hasToolUse } = this.processGeminiResponseToClaudeContent(geminiResponse); - const finishReason = candidate.finishReason; - let stopReason = "end_turn"; - - // - 如果有工具调用,stop_reason 应该是 "tool_use" - if (hasToolUse) { - stopReason = 'tool_use'; - } else if (finishReason) { - switch (finishReason) { - case 'STOP': - stopReason = 'end_turn'; - break; - case 'MAX_TOKENS': - stopReason = 'max_tokens'; - break; - case 'SAFETY': - stopReason = 'safety'; - break; - case 'RECITATION': - stopReason = 'recitation'; - break; - case 'OTHER': - stopReason = 'other'; - break; - default: - stopReason = 'end_turn'; - } - } - - return { - id: `msg_${uuidv4()}`, - type: "message", - role: "assistant", - content: content, - model: model, - stop_reason: stopReason, - stop_sequence: null, - usage: { - input_tokens: geminiResponse.usageMetadata?.promptTokenCount || 0, - cache_creation_input_tokens: 0, - cache_read_input_tokens: geminiResponse.usageMetadata?.cachedContentTokenCount || 0, - output_tokens: geminiResponse.usageMetadata?.candidatesTokenCount || 0 - } - }; - } - - /** - * Gemini流式响应 -> Claude流式响应 - */ - toClaudeStreamChunk(geminiChunk, model) { - if (!geminiChunk) return null; - - // 处理完整的Gemini chunk对象 - if (typeof geminiChunk === 'object' && !Array.isArray(geminiChunk)) { - const candidate = geminiChunk.candidates?.[0]; - - if (candidate) { - const parts = candidate.content?.parts; - - // thinking 和 text 块 - if (parts && Array.isArray(parts)) { - const results = []; - let hasToolUse = false; - - for (const part of parts) { - if (!part) continue; - - if (typeof part.text === 'string') { - if (part.thought === true) { - // [FIX] 这是一个 thinking 块 - const thinkingResult = { - type: "content_block_delta", - index: 0, - delta: { - type: "thinking_delta", - thinking: part.text - } - }; - results.push(thinkingResult); - - // 如果有签名,发送 signature_delta - // [FIX] 同时检查 thoughtSignature 和 thought_signature - const rawSignature = part.thoughtSignature || part.thought_signature; - if (rawSignature) { - let signature = rawSignature; - try { - const decoded = Buffer.from(signature, 'base64').toString('utf-8'); - if (decoded && decoded.length > 0 && !decoded.includes('\ufffd')) { - signature = decoded; - } - } catch (e) { - // 解码失败,保持原样 - } - results.push({ - type: "content_block_delta", - index: 0, - delta: { - type: "signature_delta", - signature: signature - } - }); - } - } else { - // 普通文本 - results.push({ - type: "content_block_delta", - index: 0, - delta: { - type: "text_delta", - text: part.text - } - }); - } - } - - // [FIX] 处理 functionCall - if (part.functionCall) { - hasToolUse = true; - // [FIX] 规范化工具名称和参数映射 - const toolName = normalizeToolName(part.functionCall.name); - const remappedArgs = remapFunctionCallArgs(toolName, part.functionCall.args || {}); - - // 发送 tool_use 开始 - const toolId = part.functionCall.id || `${toolName}-${uuidv4().split('-')[0]}`; - results.push({ - type: "content_block_start", - index: 0, - content_block: { - type: "tool_use", - id: toolId, - name: toolName, - input: {} - } - }); - // 发送参数 - results.push({ - type: "content_block_delta", - index: 0, - delta: { - type: "input_json_delta", - partial_json: JSON.stringify(remappedArgs) - } - }); - } - } - - // [FIX] 如果有工具调用,添加 message_delta 事件设置 stop_reason 为 tool_use - if (hasToolUse && candidate.finishReason) { - const messageDelta = { - type: "message_delta", - delta: { - stop_reason: 'tool_use' - } - }; - if (geminiChunk.usageMetadata) { - messageDelta.usage = { - input_tokens: geminiChunk.usageMetadata.promptTokenCount || 0, - cache_creation_input_tokens: 0, - cache_read_input_tokens: geminiChunk.usageMetadata.cachedContentTokenCount || 0, - output_tokens: geminiChunk.usageMetadata.candidatesTokenCount || 0 - }; - } - results.push(messageDelta); - } - - // 如果有多个结果,返回数组;否则返回单个或 null - if (results.length > 1) { - return results; - } else if (results.length === 1) { - return results[0]; - } - } - - // 处理finishReason - if (candidate.finishReason) { - const result = { - type: "message_delta", - delta: { - stop_reason: candidate.finishReason === 'STOP' ? 'end_turn' : - candidate.finishReason === 'MAX_TOKENS' ? 'max_tokens' : - candidate.finishReason.toLowerCase() - } - }; - - // 添加 usage 信息 - if (geminiChunk.usageMetadata) { - result.usage = { - input_tokens: geminiChunk.usageMetadata.promptTokenCount || 0, - cache_creation_input_tokens: 0, - cache_read_input_tokens: geminiChunk.usageMetadata.cachedContentTokenCount || 0, - output_tokens: geminiChunk.usageMetadata.candidatesTokenCount || 0, - prompt_tokens: geminiChunk.usageMetadata.promptTokenCount || 0, - completion_tokens: geminiChunk.usageMetadata.candidatesTokenCount || 0, - total_tokens: geminiChunk.usageMetadata.totalTokenCount || 0, - cached_tokens: geminiChunk.usageMetadata.cachedContentTokenCount || 0 - }; - } - - return result; - } - } - } - - // 向后兼容:处理字符串格式 - if (typeof geminiChunk === 'string') { - return { - type: "content_block_delta", - index: 0, - delta: { - type: "text_delta", - text: geminiChunk - } - }; - } - - return null; - } - - /** - * Gemini模型列表 -> Claude模型列表 - */ - toClaudeModelList(geminiModels) { - return { - models: geminiModels.models.map(m => ({ - name: m.name.startsWith('models/') ? m.name.substring(7) : m.name, - description: "", - })), - }; - } - - /** - * 处理Gemini parts到Claude内容 - */ - processGeminiPartsToClaudeContent(parts) { - if (!parts || !Array.isArray(parts)) return []; - - const content = []; - - parts.forEach(part => { - if (!part) return; - - // 处理 thinking 块 - // Gemini 使用 thought: true 和 thoughtSignature 表示思考内容 - // [FIX] 同时支持 thoughtSignature 和 thought_signature(Gemini CLI 可能使用下划线格式) - if (part.text) { - if (part.thought === true) { - // 这是一个 thinking 块 - const thinkingBlock = { - type: 'thinking', - thinking: part.text - }; - // 处理签名 - 可能是 Base64 编码的 - // [FIX] 同时检查 thoughtSignature 和 thought_signature - const rawSignature = part.thoughtSignature || part.thought_signature; - if (rawSignature) { - let signature = rawSignature; - // 尝试 Base64 解码 - try { - const decoded = Buffer.from(signature, 'base64').toString('utf-8'); - // 检查解码后是否是有效的 UTF-8 字符串 - if (decoded && decoded.length > 0 && !decoded.includes('\ufffd')) { - signature = decoded; - } - } catch (e) { - // 解码失败,保持原样 - } - thinkingBlock.signature = signature; - } - content.push(thinkingBlock); - } else { - // 普通文本 - content.push({ - type: 'text', - text: part.text - }); - } - } - - if (part.inlineData) { - content.push({ - type: 'image', - source: { - type: 'base64', - media_type: part.inlineData.mimeType, - data: part.inlineData.data - } - }); - } - - if (part.functionCall) { - // [FIX] 规范化工具名称和参数映射 - const toolName = normalizeToolName(part.functionCall.name); - const remappedArgs = remapFunctionCallArgs(toolName, part.functionCall.args || {}); - - // [FIX] 使用 Gemini 提供的 id,如果没有则生成 - const toolUseBlock = { - type: 'tool_use', - id: part.functionCall.id || `${toolName}-${uuidv4().split('-')[0]}`, - name: toolName, - input: remappedArgs - }; - // [FIX] 如果有签名,添加到 tool_use 块 - // [FIX] 同时检查 thoughtSignature 和 thought_signature - const rawSignature = part.thoughtSignature || part.thought_signature; - if (rawSignature) { - let signature = rawSignature; - try { - const decoded = Buffer.from(signature, 'base64').toString('utf-8'); - if (decoded && decoded.length > 0 && !decoded.includes('\ufffd')) { - signature = decoded; - } - } catch (e) { - // 解码失败,保持原样 - } - toolUseBlock.signature = signature; - } - content.push(toolUseBlock); - } - - if (part.functionResponse) { - // [FIX] 正确处理 functionResponse - let responseContent = part.functionResponse.response; - // 如果 response 是对象且有 result 字段,提取它 - if (responseContent && typeof responseContent === 'object' && responseContent.result !== undefined) { - responseContent = responseContent.result; - } - content.push({ - type: 'tool_result', - tool_use_id: part.functionResponse.name, - content: typeof responseContent === 'string' ? responseContent : JSON.stringify(responseContent) - }); - } - }); - - return content; - } - - /** - * 处理Gemini响应到Claude内容 - * @returns {{ content: Array, hasToolUse: boolean }} - */ - processGeminiResponseToClaudeContent(geminiResponse) { - if (!geminiResponse || !geminiResponse.candidates || geminiResponse.candidates.length === 0) { - return { content: [], hasToolUse: false }; - } - - const content = []; - let hasToolUse = false; - - for (const candidate of geminiResponse.candidates) { - if (candidate.finishReason && candidate.finishReason !== 'STOP') { - if (candidate.finishMessage) { - content.push({ - type: 'text', - text: `Error: ${candidate.finishMessage}` - }); - } - continue; - } - - if (candidate.content && candidate.content.parts) { - for (const part of candidate.content.parts) { - // 处理 thinking 块 - if (part.text) { - if (part.thought === true) { - // 这是一个 thinking 块 - const thinkingBlock = { - type: 'thinking', - thinking: part.text - }; - // 处理签名 - // [FIX] 同时检查 thoughtSignature 和 thought_signature - const rawSignature = part.thoughtSignature || part.thought_signature; - if (rawSignature) { - let signature = rawSignature; - try { - const decoded = Buffer.from(signature, 'base64').toString('utf-8'); - if (decoded && decoded.length > 0 && !decoded.includes('\ufffd')) { - signature = decoded; - } - } catch (e) { - // 解码失败,保持原样 - } - thinkingBlock.signature = signature; - } - content.push(thinkingBlock); - } else { - // 普通文本 - content.push({ - type: 'text', - text: part.text - }); - } - } else if (part.inlineData) { - content.push({ - type: 'image', - source: { - type: 'base64', - media_type: part.inlineData.mimeType, - data: part.inlineData.data - } - }); - } else if (part.functionCall) { - hasToolUse = true; - // [FIX] 规范化工具名称和参数映射 - const toolName = normalizeToolName(part.functionCall.name); - const remappedArgs = remapFunctionCallArgs(toolName, part.functionCall.args || {}); - - // [FIX] 使用 Gemini 提供的 id - const toolUseBlock = { - type: 'tool_use', - id: part.functionCall.id || `${toolName}-${uuidv4().split('-')[0]}`, - name: toolName, - input: remappedArgs - }; - // 添加签名(如果存在) - // [FIX] 同时检查 thoughtSignature 和 thought_signature - const rawSignature = part.thoughtSignature || part.thought_signature; - if (rawSignature) { - let signature = rawSignature; - try { - const decoded = Buffer.from(signature, 'base64').toString('utf-8'); - if (decoded && decoded.length > 0 && !decoded.includes('\ufffd')) { - signature = decoded; - } - } catch (e) { - // 解码失败,保持原样 - } - toolUseBlock.signature = signature; - } - content.push(toolUseBlock); - } - } - } - } - - return { content, hasToolUse }; - } - - // ========================================================================= - // Gemini -> OpenAI Responses 转换 - // ========================================================================= - - /** - * Gemini请求 -> OpenAI Responses请求 - */ - toOpenAIResponsesRequest(geminiRequest) { - const responsesRequest = { - model: geminiRequest.model, - instructions: '', - input: [], - stream: geminiRequest.stream || false, - max_output_tokens: geminiRequest.generationConfig?.maxOutputTokens, - temperature: geminiRequest.generationConfig?.temperature, - top_p: geminiRequest.generationConfig?.topP - }; - - // 处理系统指令 - if (geminiRequest.systemInstruction && geminiRequest.systemInstruction.parts) { - responsesRequest.instructions = geminiRequest.systemInstruction.parts - .filter(p => p.text) - .map(p => p.text) - .join('\n'); - } - - // 处理内容 - if (geminiRequest.contents && Array.isArray(geminiRequest.contents)) { - geminiRequest.contents.forEach(content => { - const role = content.role === 'model' ? 'assistant' : 'user'; - const parts = content.parts || []; - - parts.forEach(part => { - if (part.text) { - responsesRequest.input.push({ - type: 'message', - role: role, - content: [{ - type: role === 'assistant' ? 'output_text' : 'input_text', - text: part.text - }] - }); - } - - if (part.functionCall) { - responsesRequest.input.push({ - type: 'function_call', - call_id: part.functionCall.id || `call_${uuidv4().replace(/-/g, '').slice(0, 24)}`, - name: part.functionCall.name, - arguments: typeof part.functionCall.args === 'string' - ? part.functionCall.args - : JSON.stringify(part.functionCall.args) - }); - } - - if (part.functionResponse) { - responsesRequest.input.push({ - type: 'function_call_output', - call_id: part.functionResponse.name, // Gemini 通常使用 name 作为关联 - output: typeof part.functionResponse.response?.result === 'string' - ? part.functionResponse.response.result - : JSON.stringify(part.functionResponse.response || {}) - }); - } - - if (part.inlineData) { - responsesRequest.input.push({ - type: 'message', - role: role, - content: [{ - type: 'input_image', - image_url: { - url: `data:${part.inlineData.mimeType};base64,${part.inlineData.data}` - } - }] - }); - } - }); - }); - } - - // 处理工具 - if (geminiRequest.tools && geminiRequest.tools[0]?.functionDeclarations) { - responsesRequest.tools = geminiRequest.tools[0].functionDeclarations.map(fn => ({ - type: 'function', - name: fn.name, - description: fn.description, - parameters: fn.parameters || fn.parametersJsonSchema || { type: 'object', properties: {} } - })); - } - - return responsesRequest; - } - - /** - * Gemini响应 -> OpenAI Responses响应 - */ - toOpenAIResponsesResponse(geminiResponse, model) { - const content = this.processGeminiResponseContent(geminiResponse); - const textContent = typeof content === 'string' ? content : JSON.stringify(content); - - let output = []; - output.push({ - id: `msg_${uuidv4().replace(/-/g, '')}`, - summary: [], - type: "message", - role: "assistant", - status: "completed", - content: [{ - annotations: [], - logprobs: [], - text: textContent, - type: "output_text" - }] - }); - - return { - background: false, - created_at: Math.floor(Date.now() / 1000), - error: null, - id: `resp_${uuidv4().replace(/-/g, '')}`, - incomplete_details: null, - max_output_tokens: null, - max_tool_calls: null, - metadata: {}, - model: model, - object: "response", - output: output, - parallel_tool_calls: true, - previous_response_id: null, - prompt_cache_key: null, - reasoning: {}, - safety_identifier: "user-" + uuidv4().replace(/-/g, ''), - service_tier: "default", - status: "completed", - store: false, - temperature: 1, - text: { - format: { type: "text" }, - }, - tool_choice: "auto", - tools: [], - top_logprobs: 0, - top_p: 1, - truncation: "disabled", - usage: { - input_tokens: geminiResponse.usageMetadata?.promptTokenCount || 0, - input_tokens_details: { - cached_tokens: geminiResponse.usageMetadata?.cachedContentTokenCount || 0 - }, - output_tokens: geminiResponse.usageMetadata?.candidatesTokenCount || 0, - output_tokens_details: { - reasoning_tokens: geminiResponse.usageMetadata?.thoughtsTokenCount || 0 - }, - total_tokens: geminiResponse.usageMetadata?.totalTokenCount || 0 - }, - user: null - }; - } - - /** - * Gemini流式响应 -> OpenAI Responses流式响应 - */ - toOpenAIResponsesStreamChunk(geminiChunk, model, requestId = null) { - if (!geminiChunk) return []; - - const responseId = requestId || `resp_${uuidv4().replace(/-/g, '')}`; - const events = []; - - // 处理完整的Gemini chunk对象 - if (typeof geminiChunk === 'object' && !Array.isArray(geminiChunk)) { - const candidate = geminiChunk.candidates?.[0]; - - if (candidate) { - const parts = candidate.content?.parts; - - // 第一个chunk - 检测是否是开始(有role) - if (candidate.content?.role === 'model' && parts && parts.length > 0) { - // 只在第一次有内容时发送开始事件 - const hasContent = parts.some(part => part && typeof part.text === 'string' && part.text.length > 0); - if (hasContent) { - events.push( - generateResponseCreated(responseId, model || 'unknown'), - generateResponseInProgress(responseId), - generateOutputItemAdded(responseId), - generateContentPartAdded(responseId) - ); - } - } - - // 提取文本内容 - if (parts && Array.isArray(parts)) { - const textParts = parts.filter(part => part && typeof part.text === 'string'); - if (textParts.length > 0) { - const text = textParts.map(part => part.text).join(''); - events.push({ - delta: text, - item_id: `msg_${uuidv4().replace(/-/g, '')}`, - output_index: 0, - sequence_number: 3, - type: "response.output_text.delta" - }); - } - } - - // 处理finishReason - if (candidate.finishReason) { - events.push( - generateOutputTextDone(responseId), - generateContentPartDone(responseId), - generateOutputItemDone(responseId), - generateResponseCompleted(responseId) - ); - - // 如果有 usage 信息,更新最后一个事件 - if (geminiChunk.usageMetadata && events.length > 0) { - const lastEvent = events[events.length - 1]; - if (lastEvent.response) { - lastEvent.response.usage = { - input_tokens: geminiChunk.usageMetadata.promptTokenCount || 0, - input_tokens_details: { - cached_tokens: geminiChunk.usageMetadata.cachedContentTokenCount || 0 - }, - output_tokens: geminiChunk.usageMetadata.candidatesTokenCount || 0, - output_tokens_details: { - reasoning_tokens: geminiChunk.usageMetadata.thoughtsTokenCount || 0 - }, - total_tokens: geminiChunk.usageMetadata.totalTokenCount || 0 - }; - } - } - } - } - } - - // 向后兼容:处理字符串格式 - if (typeof geminiChunk === 'string') { - events.push({ - delta: geminiChunk, - item_id: `msg_${uuidv4().replace(/-/g, '')}`, - output_index: 0, - sequence_number: 3, - type: "response.output_text.delta" - }); - } - - return events; - } - - // ========================================================================= - // Gemini -> Codex 转换 - // ========================================================================= - - /** - * Gemini请求 -> Codex请求 - */ - toCodexRequest(geminiRequest) { - // 使用 CodexConverter 进行转换,因为 CodexConverter.js 中已经实现了 OpenAI -> Codex 的逻辑 - // 我们需要先将 Gemini 转为 OpenAI 格式,再转为 Codex 格式 - const openaiRequest = this.toOpenAIRequest(geminiRequest); - - // 注意:这里我们直接在 GeminiConverter 中实现逻辑,避免循环依赖 - const codexRequest = { - model: openaiRequest.model, - instructions: '', - input: [], - stream: geminiRequest.stream || false, - store: false, - reasoning: { - effort: 'medium', - summary: 'auto' - }, - parallel_tool_calls: true, - include: ['reasoning.encrypted_content'] - }; - - // 处理系统指令 - if (geminiRequest.systemInstruction && geminiRequest.systemInstruction.parts) { - codexRequest.instructions = geminiRequest.systemInstruction.parts - .filter(p => p.text) - .map(p => p.text) - .join('\n'); - } - - // 处理内容 - if (geminiRequest.contents && Array.isArray(geminiRequest.contents)) { - const pendingCallIDs = []; - - geminiRequest.contents.forEach(content => { - const role = content.role === 'model' ? 'assistant' : 'user'; - const parts = content.parts || []; - - parts.forEach(part => { - if (part.text) { - codexRequest.input.push({ - type: 'message', - role: role, - content: [{ - type: role === 'assistant' ? 'output_text' : 'input_text', - text: part.text - }] - }); - } - - if (part.functionCall) { - const callId = `call_${uuidv4().replace(/-/g, '').slice(0, 24)}`; - pendingCallIDs.push(callId); - codexRequest.input.push({ - type: 'function_call', - call_id: callId, - name: part.functionCall.name, - arguments: typeof part.functionCall.args === 'string' - ? part.functionCall.args - : JSON.stringify(part.functionCall.args) - }); - } - - if (part.functionResponse) { - const callId = pendingCallIDs.shift() || `call_${uuidv4().replace(/-/g, '').slice(0, 24)}`; - codexRequest.input.push({ - type: 'function_call_output', - call_id: callId, - output: typeof part.functionResponse.response?.result === 'string' - ? part.functionResponse.response.result - : JSON.stringify(part.functionResponse.response || {}) - }); - } - }); - }); - } - - // 处理工具 - if (geminiRequest.tools && geminiRequest.tools[0]?.functionDeclarations) { - codexRequest.tools = geminiRequest.tools[0].functionDeclarations.map(fn => ({ - type: 'function', - name: fn.name, - description: fn.description, - parameters: fn.parameters || { type: 'object', properties: {} } - })); - } - - return codexRequest; - } - - /** - * Gemini请求 -> Grok请求 - */ - toGrokRequest(geminiRequest) { - // 先转换为 OpenAI 格式 - const openaiRequest = this.toOpenAIRequest(geminiRequest); - return { - ...openaiRequest, - _isConverted: true - }; - } - - /** - * Gemini响应 -> Codex响应 (实际上是 Codex 转 Gemini) - */ - toCodexResponse(geminiResponse, model) { - // 这里实际上是实现 Codex -> Gemini 的非流式转换 - // 为了保持接口一致,我们按照其他 Converter 的命名习惯 - const parts = []; - if (geminiResponse.response?.output) { - geminiResponse.response.output.forEach(item => { - if (item.type === 'message' && item.content) { - const textPart = item.content.find(c => c.type === 'output_text'); - if (textPart) parts.push({ text: textPart.text }); - } else if (item.type === 'reasoning' && item.summary) { - const textPart = item.summary.find(c => c.type === 'summary_text'); - if (textPart) parts.push({ text: textPart.text, thought: true }); - } else if (item.type === 'function_call') { - parts.push({ - functionCall: { - name: item.name, - args: typeof item.arguments === 'string' ? JSON.parse(item.arguments) : item.arguments - } - }); - } - }); - } - - return { - candidates: [{ - content: { - role: 'model', - parts: parts - }, - finishReason: 'STOP' - }], - usageMetadata: { - promptTokenCount: geminiResponse.response?.usage?.input_tokens || 0, - candidatesTokenCount: geminiResponse.response?.usage?.output_tokens || 0, - totalTokenCount: geminiResponse.response?.usage?.total_tokens || 0 - }, - modelVersion: model, - responseId: geminiResponse.response?.id - }; - } - - /** - * Gemini流式响应 -> Codex流式响应 (实际上是 Codex 转 Gemini) - */ - toCodexStreamChunk(codexChunk, model) { - const type = codexChunk.type; - const resId = codexChunk.response?.id || 'default'; - - const template = { - candidates: [{ - content: { - role: "model", - parts: [] - } - }], - modelVersion: model, - responseId: resId - }; - - if (type === 'response.reasoning_summary_text.delta') { - template.candidates[0].content.parts.push({ text: codexChunk.delta, thought: true }); - return template; - } - - if (type === 'response.output_text.delta') { - template.candidates[0].content.parts.push({ text: codexChunk.delta }); - return template; - } - - if (type === 'response.output_item.done' && codexChunk.item?.type === 'function_call') { - template.candidates[0].content.parts.push({ - functionCall: { - name: codexChunk.item.name, - args: typeof codexChunk.item.arguments === 'string' ? JSON.parse(codexChunk.item.arguments) : codexChunk.item.arguments - } - }); - return template; - } - - if (type === 'response.completed') { - template.candidates[0].finishReason = "STOP"; - template.usageMetadata = { - promptTokenCount: codexChunk.response.usage?.input_tokens || 0, - candidatesTokenCount: codexChunk.response.usage?.output_tokens || 0, - totalTokenCount: codexChunk.response.usage?.total_tokens || 0 - }; - return template; - } - - return null; - } -} - -export default GeminiConverter; \ No newline at end of file diff --git a/src/converters/strategies/GrokConverter.js b/src/converters/strategies/GrokConverter.js deleted file mode 100644 index 7fb48f4858bfec2a337bfd58cc187967a6aa1c18..0000000000000000000000000000000000000000 --- a/src/converters/strategies/GrokConverter.js +++ /dev/null @@ -1,1153 +0,0 @@ -/** - * Grok转换器 - * 处理Grok协议与其他协议之间的转换 - */ - -import { v4 as uuidv4 } from 'uuid'; -import logger from '../../utils/logger.js'; -import { BaseConverter } from '../BaseConverter.js'; -import { MODEL_PROTOCOL_PREFIX } from '../../utils/common.js'; - -/** - * Grok转换器类 - * 实现Grok协议到其他协议的转换 - */ -export class GrokConverter extends BaseConverter { - // 静态属性,确保所有实例共享最新的基础 URL 和 UUID 配置 - static sharedRequestBaseUrl = ""; - static sharedUuid = null; - - constructor() { - super('grok'); - // 用于跟踪每个请求的状态 - this.requestStates = new Map(); - } - - /** - * 设置请求的基础 URL - */ - setRequestBaseUrl(baseUrl) { - if (baseUrl) { - GrokConverter.sharedRequestBaseUrl = baseUrl; - } - } - - /** - * 设置账号的 UUID - */ - setUuid(uuid) { - if (uuid) { - GrokConverter.sharedUuid = uuid; - } - } - - /** - * 为 assets.grok.com 域名的资源 URL 添加 uuid 参数,并转换为本地代理 URL - */ - _appendSsoToken(url, state = null) { - const requestBaseUrl = state?.requestBaseUrl || GrokConverter.sharedRequestBaseUrl; - const uuid = state?.uuid || GrokConverter.sharedUuid; - - if (!url || !uuid) return url; - - // 检查是否为 assets.grok.com 域名或相对路径 - const isGrokAsset = url.includes('assets.grok.com') || (!url.startsWith('http') && !url.startsWith('data:')); - - if (!isGrokAsset) return url; - - // 构造完整的原始 URL - let originalUrl = url; - if (!url.startsWith('http')) { - originalUrl = `https://assets.grok.com${url.startsWith('/') ? '' : '/'}${url}`; - } - - // 返回本地代理接口 URL - // 使用 uuid 以提高安全性,防止 token 泄露在链接中 - const authParam = `uuid=${encodeURIComponent(uuid)}`; - - const proxyPath = `/api/grok/assets?url=${encodeURIComponent(originalUrl)}&${authParam}`; - if (requestBaseUrl) { - return `${requestBaseUrl}${proxyPath}`; - } - return proxyPath; - } - - /** - * 在文本中查找并替换所有 assets.grok.com 的资源链接为绝对代理链接 - */ - _processGrokAssetsInText(text, state = null) { - const uuid = state?.uuid || GrokConverter.sharedUuid; - if (!text || !uuid) return text; - - // 更宽松的正则匹配 assets.grok.com 的 URL - const grokUrlRegex = /https?:\/\/assets\.grok\.com\/[^\s\)\"\'\>]+/g; - - return text.replace(grokUrlRegex, (url) => { - return this._appendSsoToken(url, state); - }); - } - - /** - * 获取或初始化请求状态 - */ - _getState(requestId) { - if (!this.requestStates.has(requestId)) { - this.requestStates.set(requestId, { - think_opened: false, - image_think_active: false, - video_think_active: false, - role_sent: false, - tool_buffer: "", - last_is_thinking: false, - fingerprint: "", - content_buffer: "", // 用于缓存内容以解析工具调用 - has_tool_call: false, - rollout_id: "", - in_tool_call: false, // 是否处于 块内 - requestBaseUrl: "", - uuid: null, - pending_text_buffer: "" // 用于处理流式输出中被截断的 URL - }); - } - return this.requestStates.get(requestId); - } - - /** - * 构建工具系统提示词 (build_tool_prompt) - */ - buildToolPrompt(tools, toolChoice = "auto", parallelToolCalls = true) { - if (!tools || tools.length === 0 || toolChoice === "none") { - return ""; - } - - const lines = [ - "# Available Tools", - "", - "You have access to the following tools. To call a tool, output a block with a JSON object containing \"name\" and \"arguments\".", - "", - "Format:", - "", - '{"name": "function_name", "arguments": {"param": "value"}}', - "", - "", - ]; - - if (parallelToolCalls) { - lines.push("You may make multiple tool calls in a single response by using multiple blocks."); - lines.push(""); - } - - lines.push("## Tool Definitions"); - lines.push(""); - for (const tool of tools) { - if (tool.type !== "function") continue; - const func = tool.function || {}; - lines.push(`### ${func.name}`); - if (func.description) lines.push(func.description); - if (func.parameters) lines.push(`Parameters: ${JSON.stringify(func.parameters)}`); - lines.push(""); - } - - if (toolChoice === "required") { - lines.push("IMPORTANT: You MUST call at least one tool in your response. Do not respond with only text."); - } else if (typeof toolChoice === 'object' && toolChoice.function?.name) { - lines.push(`IMPORTANT: You MUST call the tool "${toolChoice.function.name}" in your response.`); - } else { - lines.push("Decide whether to call a tool based on the user's request. If you don't need a tool, respond normally with text only."); - } - - lines.push(""); - lines.push("When you call a tool, you may include text before or after the blocks, but the tool call blocks must be valid JSON."); - - return lines.join("\n"); - } - - /** - * 格式化工具历史 (format_tool_history) - */ - formatToolHistory(messages) { - const result = []; - for (const msg of messages) { - const role = msg.role; - const content = msg.content; - const toolCalls = msg.tool_calls; - - if (role === "assistant" && toolCalls && toolCalls.length > 0) { - const parts = []; - if (content) parts.push(typeof content === 'string' ? content : JSON.stringify(content)); - for (const tc of toolCalls) { - const func = tc.function || {}; - parts.push(`{"name":"${func.name}","arguments":${func.arguments || "{}"}}`); - } - result.push({ role: "assistant", content: parts.join("\n") }); - } else if (role === "tool") { - const toolName = msg.name || "unknown"; - const callId = msg.tool_call_id || ""; - const contentStr = typeof content === 'string' ? content : JSON.stringify(content); - result.push({ - role: "user", - content: `tool (${toolName}, ${callId}): ${contentStr}` - }); - } else { - result.push(msg); - } - } - return result; - } - - /** - * 解析工具调用 (parse_tool_calls) - */ - parseToolCalls(content) { - if (!content) return { text: content, toolCalls: null }; - - const toolCallRegex = /\s*(.*?)\s*<\/tool_call>/gs; - const matches = [...content.matchAll(toolCallRegex)]; - - if (matches.length === 0) return { text: content, toolCalls: null }; - - const toolCalls = []; - for (const match of matches) { - try { - const parsed = JSON.parse(match[1].trim()); - if (parsed.name) { - let args = parsed.arguments || {}; - const argumentsStr = typeof args === 'string' ? args : JSON.stringify(args); - - toolCalls.push({ - id: `call_${uuidv4().replace(/-/g, '').slice(0, 24)}`, - type: "function", - function: { - name: parsed.name, - arguments: argumentsStr - } - }); - } - } catch (e) { - // 忽略解析失败的块 - } - } - - if (toolCalls.length === 0) return { text: content, toolCalls: null }; - - // 提取文本内容 - let text = content; - for (const match of matches) { - text = text.replace(match[0], ""); - } - text = text.trim() || null; - - return { text, toolCalls }; - } - - /** - * 转换请求 - */ - convertRequest(data, targetProtocol) { - switch (targetProtocol) { - default: - return data; - } - } - - /** - * 转换响应 - */ - convertResponse(data, targetProtocol, model) { - switch (targetProtocol) { - case MODEL_PROTOCOL_PREFIX.OPENAI: - return this.toOpenAIResponse(data, model); - case MODEL_PROTOCOL_PREFIX.GEMINI: - return this.toGeminiResponse(data, model); - case MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES: - return this.toOpenAIResponsesResponse(data, model); - case MODEL_PROTOCOL_PREFIX.CODEX: - return this.toCodexResponse(data, model); - default: - return data; - } - } - - /** - * 转换流式响应块 - */ - convertStreamChunk(chunk, targetProtocol, model) { - switch (targetProtocol) { - case MODEL_PROTOCOL_PREFIX.OPENAI: - return this.toOpenAIStreamChunk(chunk, model); - case MODEL_PROTOCOL_PREFIX.GEMINI: - return this.toGeminiStreamChunk(chunk, model); - case MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES: - return this.toOpenAIResponsesStreamChunk(chunk, model); - case MODEL_PROTOCOL_PREFIX.CODEX: - return this.toCodexStreamChunk(chunk, model); - default: - return chunk; - } - } - - /** - * 转换模型列表 - */ - convertModelList(data, targetProtocol) { - switch (targetProtocol) { - case MODEL_PROTOCOL_PREFIX.OPENAI: - return this.toOpenAIModelList(data); - case MODEL_PROTOCOL_PREFIX.GEMINI: - return this.toGeminiModelList(data); - default: - return data; - } - } - - /** - * 构建工具覆盖配置 (build_tool_overrides) - */ - buildToolOverrides(tools) { - if (!tools || !Array.isArray(tools)) { - return {}; - } - - const toolOverrides = {}; - for (const tool of tools) { - if (tool.type !== "function") continue; - const func = tool.function || {}; - const name = func.name; - if (!name) continue; - - toolOverrides[name] = { - "enabled": true, - "description": func.description || "", - "parameters": func.parameters || {} - }; - } - - return toolOverrides; - } - - /** - * 递归收集响应中的图片 URL - */ - _collectImages(obj) { - const urls = []; - const seen = new Set(); - - const add = (url) => { - if (!url || seen.has(url)) return; - seen.add(url); - urls.push(url); - }; - - const walk = (value) => { - if (value && typeof value === 'object') { - if (Array.isArray(value)) { - value.forEach(walk); - } else { - for (const [key, item] of Object.entries(value)) { - if (key === "generatedImageUrls" || key === "imageUrls" || key === "imageURLs") { - if (Array.isArray(item)) { - item.forEach(url => typeof url === 'string' && add(url)); - } else if (typeof item === 'string') { - add(item); - } - continue; - } - walk(item); - } - } - } - }; - - walk(obj); - return urls; - } - - /** - * 渲染图片为 Markdown - */ - _renderImage(url, imageId = "image", state = null) { - let finalUrl = url; - if (!url.startsWith('http')) { - finalUrl = `https://assets.grok.com${url.startsWith('/') ? '' : '/'}${url}`; - } - finalUrl = this._appendSsoToken(finalUrl, state); - return `![${imageId}](${finalUrl})`; - } - - /** - * 渲染视频为 Markdown/HTML (render_video) - */ - _renderVideo(videoUrl, thumbnailImageUrl = "", state = null) { - let finalVideoUrl = videoUrl; - if (!videoUrl.startsWith('http')) { - finalVideoUrl = `https://assets.grok.com${videoUrl.startsWith('/') ? '' : '/'}${videoUrl}`; - } - - let finalThumbUrl = thumbnailImageUrl; - if (thumbnailImageUrl && !thumbnailImageUrl.startsWith('http')) { - finalThumbUrl = `https://assets.grok.com${thumbnailImageUrl.startsWith('/') ? '' : '/'}${thumbnailImageUrl}`; - } - - const defaultThumb = 'https://assets.grok.com/favicon.ico'; - return `\n[![video](${finalThumbUrl || defaultThumb})](${finalVideoUrl})\n[Play Video](${finalVideoUrl})\n`; - } - - /** - * 提取工具卡片文本 (extract_tool_text) - */ - _extractToolText(raw, rolloutId = "") { - if (!raw) return ""; - - const nameMatch = raw.match(/(.*?)<\/xai:tool_name>/s); - const argsMatch = raw.match(/(.*?)<\/xai:tool_args>/s); - - let name = nameMatch ? nameMatch[1].replace(//gs, "$1").trim() : ""; - let args = argsMatch ? argsMatch[1].replace(//gs, "$1").trim() : ""; - - let payload = null; - if (args) { - try { - payload = JSON.parse(args); - } catch (e) { - payload = null; - } - } - - let label = name; - let text = args; - const prefix = rolloutId ? `[${rolloutId}]` : ""; - - if (name === "web_search") { - label = `${prefix}[WebSearch]`; - if (payload && typeof payload === 'object') { - text = payload.query || payload.q || ""; - } - } else if (name === "search_images") { - label = `${prefix}[SearchImage]`; - if (payload && typeof payload === 'object') { - text = payload.image_description || payload.description || payload.query || ""; - } - } else if (name === "chatroom_send") { - label = `${prefix}[AgentThink]`; - if (payload && typeof payload === 'object') { - text = payload.message || ""; - } - } - - if (label && text) return `${label} ${text}`.trim(); - if (label) return label; - if (text) return text; - return raw.replace(/<[^>]+>/g, "").trim(); - } - - /** - * 过滤特殊标签 - */ - _filterToken(token, requestId = "") { - if (!token) return token; - - let filtered = token; - - // 移除 xai:tool_usage_card 及其内容,不显示工具调用的过程输出 - filtered = filtered.replace(/]*>.*?<\/xai:tool_usage_card>/gs, ""); - filtered = filtered.replace(/]*\/>/gs, ""); - - // 移除其他内部标签 - const tagsToFilter = ["rolloutId", "responseId", "isThinking"]; - for (const tag of tagsToFilter) { - const pattern = new RegExp(`<${tag}[^>]*>.*?<\\/${tag}>|<${tag}[^>]*\\/>`, 'gs'); - filtered = filtered.replace(pattern, ""); - } - - return filtered; - } - - /** - * Grok响应 -> OpenAI响应 - */ - toOpenAIResponse(grokResponse, model) { - if (!grokResponse) return null; - - const responseId = grokResponse.responseId || `chatcmpl-${uuidv4()}`; - let content = grokResponse.message || ""; - const modelHash = grokResponse.llmInfo?.modelHash || ""; - - const state = this._getState(this._formatResponseId(responseId)); - if (grokResponse._requestBaseUrl) { - state.requestBaseUrl = grokResponse._requestBaseUrl; - } - if (grokResponse._uuid) { - state.uuid = grokResponse._uuid; - } - - // 过滤内容并处理其中的 Grok 资源链接 - content = this._filterToken(content, responseId); - content = this._processGrokAssetsInText(content, state); - - // 收集图片并追加 - const imageUrls = this._collectImages(grokResponse); - if (imageUrls.length > 0) { - content += "\n"; - for (const url of imageUrls) { - content += this._renderImage(url, "image", state) + "\n"; - } - } - - // 处理视频 (非流式模式) - if (grokResponse.finalVideoUrl) { - content += this._renderVideo(grokResponse.finalVideoUrl, grokResponse.finalThumbnailUrl, state); - } - - // 解析工具调用 - const { text, toolCalls } = this.parseToolCalls(content); - - const result = { - id: responseId, - object: "chat.completion", - created: Math.floor(Date.now() / 1000), - model: model, - system_fingerprint: modelHash, - choices: [{ - index: 0, - message: { - role: "assistant", - content: text, - }, - finish_reason: toolCalls ? "tool_calls" : "stop", - }], - usage: { - prompt_tokens: 0, - completion_tokens: 0, - total_tokens: 0, - }, - }; - - if (toolCalls) { - result.choices[0].message.tool_calls = toolCalls; - } - - return result; - } - - _formatResponseId(id) { - if (!id) return `chatcmpl-${uuidv4()}`; - if (id.startsWith('chatcmpl-')) return id; - return `chatcmpl-${id}`; - } - - /** - * Grok流式响应块 -> OpenAI流式响应块 - */ - toOpenAIStreamChunk(grokChunk, model) { - if (!grokChunk || !grokChunk.result || !grokChunk.result.response) { - return null; - } - - const resp = grokChunk.result.response; - const rawResponseId = resp.responseId || ""; - const responseId = this._formatResponseId(rawResponseId); - const state = this._getState(responseId); - - // 从响应块中同步 uuid 和基础 URL - if (resp._requestBaseUrl) { - state.requestBaseUrl = resp._requestBaseUrl; - } - if (resp._uuid) { - state.uuid = resp._uuid; - } - - if (resp.llmInfo?.modelHash && !state.fingerprint) { - state.fingerprint = resp.llmInfo.modelHash; - } - if (resp.rolloutId) { - state.rollout_id = String(resp.rolloutId); - } - - const chunks = []; - - // 0. 发送角色信息(仅第一次) - if (!state.role_sent) { - chunks.push({ - id: responseId, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model: model, - system_fingerprint: state.fingerprint, - choices: [{ - index: 0, - delta: { role: "assistant", content: "" }, - finish_reason: null - }] - }); - state.role_sent = true; - } - - // 处理结束标志 - if (resp.isDone) { - let finalContent = ""; - // 处理剩余的缓冲区 - if (state.pending_text_buffer) { - finalContent += this._processGrokAssetsInText(state.pending_text_buffer, state); - state.pending_text_buffer = ""; - } - - // 处理 buffer 中的工具调用 - const { text, toolCalls } = this.parseToolCalls(state.content_buffer); - - if (toolCalls) { - chunks.push({ - id: responseId, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model: model, - system_fingerprint: state.fingerprint, - choices: [{ - index: 0, - delta: { - content: (finalContent + (text || "")).trim() || null, - tool_calls: toolCalls - }, - finish_reason: "tool_calls" - }] - }); - } else { - chunks.push({ - id: responseId, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model: model, - system_fingerprint: state.fingerprint, - choices: [{ - index: 0, - delta: { content: finalContent || null }, - finish_reason: "stop" - }] - }); - } - - // 清理状态 - this.requestStates.delete(responseId); - return chunks; - } - - let deltaContent = ""; - let deltaReasoning = ""; - - // 1. 处理图片生成进度 - if (resp.streamingImageGenerationResponse) { - const img = resp.streamingImageGenerationResponse; - state.image_think_active = true; - /* - if (!state.think_opened) { - deltaReasoning += "\n"; - state.think_opened = true; - } - */ - const idx = (img.imageIndex || 0) + 1; - const progress = img.progress || 0; - deltaReasoning += `正在生成第${idx}张图片中,当前进度${progress}%\n`; - } - - // 2. 处理视频生成进度 (VideoStreamProcessor) - if (resp.streamingVideoGenerationResponse) { - const vid = resp.streamingVideoGenerationResponse; - state.video_think_active = true; - /* - if (!state.think_opened) { - deltaReasoning += "\n"; - state.think_opened = true; - } - */ - const progress = vid.progress || 0; - deltaReasoning += `正在生成视频中,当前进度${progress}%\n`; - - if (progress === 100 && vid.videoUrl) { - /* - if (state.think_opened) { - deltaContent += "\n\n"; - state.think_opened = false; - } - */ - state.video_think_active = false; - deltaContent += this._renderVideo(vid.videoUrl, vid.thumbnailImageUrl, state); - } - } - - // 3. 处理模型响应(通常包含完整消息或图片) - if (resp.modelResponse) { - const mr = resp.modelResponse; - /* - if ((state.image_think_active || state.video_think_active) && state.think_opened) { - deltaContent += "\n\n"; - state.think_opened = false; - } - */ - state.image_think_active = false; - state.video_think_active = false; - - const imageUrls = this._collectImages(mr); - for (const url of imageUrls) { - deltaContent += this._renderImage(url, "image", state) + "\n"; - } - - if (mr.metadata?.llm_info?.modelHash) { - state.fingerprint = mr.metadata.llm_info.modelHash; - } - } - - // 4. 处理卡片附件 - if (resp.cardAttachment) { - const card = resp.cardAttachment; - if (card.jsonData) { - try { - const cardData = JSON.parse(card.jsonData); - let original = cardData.image?.original; - const title = cardData.image?.title || "image"; - if (original) { - // 确保是绝对路径 - if (!original.startsWith('http')) { - original = `https://assets.grok.com${original.startsWith('/') ? '' : '/'}${original}`; - } - original = this._appendSsoToken(original, state); - deltaContent += `![${title}](${original})\n`; - } - } catch (e) { - // 忽略 JSON 解析错误 - } - } - } - - // 5. 处理普通 Token 和 思考状态 - if (resp.token !== undefined && resp.token !== null) { - const token = resp.token; - const filtered = this._filterToken(token, responseId); - const isThinking = !!resp.isThinking; - const inThink = isThinking || state.image_think_active || state.video_think_active; - - if (inThink) { - deltaReasoning += filtered; - } else { - // 将新 token 加入待处理缓冲区,解决 URL 被截断的问题 - state.pending_text_buffer += filtered; - - let outputFromBuffer = ""; - - // 启发式逻辑:检查缓冲区是否包含完整的 URL - if (state.pending_text_buffer.includes("https://assets.grok.com")) { - const lastUrlIndex = state.pending_text_buffer.lastIndexOf("https://assets.grok.com"); - const textAfterUrl = state.pending_text_buffer.slice(lastUrlIndex); - - // 检查 URL 是否结束(空格、右括号、引号、换行、大于号等) - const terminatorMatch = textAfterUrl.match(/[\s\)\"\'\>\n]/); - if (terminatorMatch) { - // URL 已结束,可以安全地处理并输出缓冲区 - outputFromBuffer = this._processGrokAssetsInText(state.pending_text_buffer, state); - state.pending_text_buffer = ""; - } else if (state.pending_text_buffer.length > 1000) { - // 缓冲区过长,强制处理输出,避免过度延迟 - outputFromBuffer = this._processGrokAssetsInText(state.pending_text_buffer, state); - state.pending_text_buffer = ""; - } - } else { - // 不包含 Grok URL,直接输出 - outputFromBuffer = state.pending_text_buffer; - state.pending_text_buffer = ""; - } - - if (outputFromBuffer) { - // 工具调用抑制逻辑:不向客户端输出 块及其内容 - let outputToken = outputFromBuffer; - - // 简单的状态切换检测 - if (outputToken.includes('')) { - state.in_tool_call = true; - state.has_tool_call = true; - // 移除标签之后的部分(如果有) - outputToken = outputToken.split('')[0]; - } else if (state.in_tool_call && outputToken.includes('')) { - state.in_tool_call = false; - // 只保留标签之后的部分 - outputToken = outputToken.split('')[1] || ""; - } else if (state.in_tool_call) { - // 处于块内,完全抑制 - outputToken = ""; - } - - deltaContent += outputToken; - } - - // 将内容加入 buffer 用于最终解析工具调用 - state.content_buffer += filtered; - } - state.last_is_thinking = isThinking; - } - - if (deltaContent || deltaReasoning) { - const delta = {}; - if (deltaContent) delta.content = deltaContent; - if (deltaReasoning) delta.reasoning_content = deltaReasoning; - - chunks.push({ - id: responseId, - object: "chat.completion.chunk", - created: Math.floor(Date.now() / 1000), - model: model, - system_fingerprint: state.fingerprint, - choices: [{ - index: 0, - delta: delta, - finish_reason: null - }] - }); - } - - return chunks.length > 0 ? chunks : null; - } - - /** - * Grok响应 -> Gemini响应 - */ - toGeminiResponse(grokResponse, model) { - const openaiRes = this.toOpenAIResponse(grokResponse, model); - if (!openaiRes) return null; - - const choice = openaiRes.choices[0]; - const message = choice.message; - const parts = []; - - if (message.reasoning_content) { - parts.push({ text: message.reasoning_content, thought: true }); - } - - if (message.content) { - parts.push({ text: message.content }); - } - - if (message.tool_calls) { - for (const tc of message.tool_calls) { - parts.push({ - functionCall: { - name: tc.function.name, - args: typeof tc.function.arguments === 'string' ? JSON.parse(tc.function.arguments) : tc.function.arguments - } - }); - } - } - - return { - candidates: [{ - content: { - role: 'model', - parts: parts - }, - finishReason: choice.finish_reason === 'tool_calls' ? 'STOP' : (choice.finish_reason === 'length' ? 'MAX_TOKENS' : 'STOP') - }], - usageMetadata: { - promptTokenCount: openaiRes.usage.prompt_tokens, - candidatesTokenCount: openaiRes.usage.completion_tokens, - totalTokenCount: openaiRes.usage.total_tokens - } - }; - } - - /** - * Grok流式响应块 -> Gemini流式响应块 - */ - toGeminiStreamChunk(grokChunk, model) { - const openaiChunks = this.toOpenAIStreamChunk(grokChunk, model); - if (!openaiChunks) return null; - - const geminiChunks = []; - for (const oachunk of openaiChunks) { - const choice = oachunk.choices[0]; - const delta = choice.delta; - const parts = []; - - if (delta.reasoning_content) { - parts.push({ text: delta.reasoning_content, thought: true }); - } - if (delta.content) { - parts.push({ text: delta.content }); - } - if (delta.tool_calls) { - for (const tc of delta.tool_calls) { - parts.push({ - functionCall: { - name: tc.function.name, - args: typeof tc.function.arguments === 'string' ? JSON.parse(tc.function.arguments) : tc.function.arguments - } - }); - } - } - - if (parts.length > 0 || choice.finish_reason) { - const gchunk = { - candidates: [{ - content: { - role: 'model', - parts: parts - } - }] - }; - if (choice.finish_reason) { - gchunk.candidates[0].finishReason = choice.finish_reason === 'length' ? 'MAX_TOKENS' : 'STOP'; - } - geminiChunks.push(gchunk); - } - } - - return geminiChunks.length > 0 ? geminiChunks : null; - } - - /** - * Grok响应 -> OpenAI Responses响应 - */ - toOpenAIResponsesResponse(grokResponse, model) { - const openaiRes = this.toOpenAIResponse(grokResponse, model); - if (!openaiRes) return null; - - const choice = openaiRes.choices[0]; - const message = choice.message; - const output = []; - - const content = []; - if (message.content) { - content.push({ - type: "output_text", - text: message.content - }); - } - - output.push({ - id: `msg_${uuidv4().replace(/-/g, '')}`, - type: "message", - role: "assistant", - status: "completed", - content: content - }); - - if (message.tool_calls) { - for (const tc of message.tool_calls) { - output.push({ - id: tc.id, - type: "function_call", - name: tc.function.name, - arguments: tc.function.arguments, - status: "completed" - }); - } - } - - return { - id: `resp_${uuidv4().replace(/-/g, '')}`, - object: "response", - created_at: Math.floor(Date.now() / 1000), - status: "completed", - model: model, - output: output, - usage: { - input_tokens: openaiRes.usage.prompt_tokens, - output_tokens: openaiRes.usage.completion_tokens, - total_tokens: openaiRes.usage.total_tokens - } - }; - } - - /** - * Grok流式响应块 -> OpenAI Responses流式响应块 - */ - toOpenAIResponsesStreamChunk(grokChunk, model) { - const openaiChunks = this.toOpenAIStreamChunk(grokChunk, model); - if (!openaiChunks) return null; - - const events = []; - for (const oachunk of openaiChunks) { - const choice = oachunk.choices[0]; - const delta = choice.delta; - - if (delta.role === 'assistant') { - events.push({ type: "response.created", response: { id: oachunk.id, model: model } }); - } - - if (delta.reasoning_content) { - events.push({ - type: "response.reasoning_summary_text.delta", - delta: delta.reasoning_content, - response_id: oachunk.id - }); - } - - if (delta.content) { - events.push({ - type: "response.output_text.delta", - delta: delta.content, - response_id: oachunk.id - }); - } - - if (delta.tool_calls) { - for (const tc of delta.tool_calls) { - if (tc.function?.name) { - events.push({ - type: "response.output_item.added", - item: { id: tc.id, type: "function_call", name: tc.function.name, arguments: "" }, - response_id: oachunk.id - }); - } - if (tc.function?.arguments) { - events.push({ - type: "response.custom_tool_call_input.delta", - delta: tc.function.arguments, - item_id: tc.id, - response_id: oachunk.id - }); - } - } - } - - if (choice.finish_reason) { - events.push({ type: "response.completed", response: { id: oachunk.id, status: "completed" } }); - } - } - - return events; - } - - /** - * Grok响应 -> Codex响应 - */ - toCodexResponse(grokResponse, model) { - const openaiRes = this.toOpenAIResponse(grokResponse, model); - if (!openaiRes) return null; - - const choice = openaiRes.choices[0]; - const message = choice.message; - const output = []; - - if (message.content) { - output.push({ - type: "message", - role: "assistant", - content: [{ type: "output_text", text: message.content }] - }); - } - - if (message.reasoning_content) { - output.push({ - type: "reasoning", - summary: [{ type: "summary_text", text: message.reasoning_content }] - }); - } - - if (message.tool_calls) { - for (const tc of message.tool_calls) { - output.push({ - type: "function_call", - call_id: tc.id, - name: tc.function.name, - arguments: tc.function.arguments - }); - } - } - - return { - response: { - id: openaiRes.id, - output: output, - usage: { - input_tokens: openaiRes.usage.prompt_tokens, - output_tokens: openaiRes.usage.completion_tokens, - total_tokens: openaiRes.usage.total_tokens - } - } - }; - } - - /** - * Grok流式响应块 -> Codex流式响应块 - */ - toCodexStreamChunk(grokChunk, model) { - const openaiChunks = this.toOpenAIStreamChunk(grokChunk, model); - if (!openaiChunks) return null; - - const codexChunks = []; - for (const oachunk of openaiChunks) { - const choice = oachunk.choices[0]; - const delta = choice.delta; - - if (delta.role === 'assistant') { - codexChunks.push({ type: "response.created", response: { id: oachunk.id } }); - } - - if (delta.reasoning_content) { - codexChunks.push({ - type: "response.reasoning_summary_text.delta", - delta: delta.reasoning_content, - response: { id: oachunk.id } - }); - } - - if (delta.content) { - codexChunks.push({ - type: "response.output_text.delta", - delta: delta.content, - response: { id: oachunk.id } - }); - } - - if (delta.tool_calls) { - for (const tc of delta.tool_calls) { - if (tc.function?.arguments) { - codexChunks.push({ - type: "response.custom_tool_call_input.delta", - delta: tc.function.arguments, - item_id: tc.id, - response: { id: oachunk.id } - }); - } - } - } - - if (choice.finish_reason) { - codexChunks.push({ type: "response.completed", response: { id: oachunk.id, usage: oachunk.usage } }); - } - } - - return codexChunks.length > 0 ? codexChunks : null; - } - - /** - * Grok模型列表 -> OpenAI模型列表 - */ - toOpenAIModelList(grokModels) { - const models = Array.isArray(grokModels) ? grokModels : (grokModels?.models || grokModels?.data || []); - return { - object: "list", - data: models.map(m => ({ - id: m.id || m.name || (typeof m === 'string' ? m : ''), - object: "model", - created: Math.floor(Date.now() / 1000), - owned_by: "xai", - display_name: m.display_name || m.name || m.id || (typeof m === 'string' ? m : ''), - })), - }; - } - - /** - * Grok模型列表 -> Gemini模型列表 - */ - toGeminiModelList(grokModels) { - const models = Array.isArray(grokModels) ? grokModels : (grokModels?.models || grokModels?.data || []); - return { - models: models.map(m => ({ - name: `models/${m.id || m.name || (typeof m === 'string' ? m : '')}`, - version: "1.0", - displayName: m.display_name || m.name || m.id || (typeof m === 'string' ? m : ''), - description: m.description || `Grok model: ${m.name || m.id || (typeof m === 'string' ? m : '')}`, - inputTokenLimit: 131072, - outputTokenLimit: 8192, - supportedGenerationMethods: ["generateContent", "streamGenerateContent"] - })) - }; - } -} diff --git a/src/converters/strategies/OpenAIConverter.js b/src/converters/strategies/OpenAIConverter.js deleted file mode 100644 index 9bdd1459e6a983ccf3c1c2088744efe84cf2cdf5..0000000000000000000000000000000000000000 --- a/src/converters/strategies/OpenAIConverter.js +++ /dev/null @@ -1,1769 +0,0 @@ -/** - * OpenAI转换器 - * 处理OpenAI协议与其他协议之间的转换 - */ - -import { v4 as uuidv4 } from 'uuid'; -import logger from '../../utils/logger.js'; -import { BaseConverter } from '../BaseConverter.js'; -import { CodexConverter } from './CodexConverter.js'; -import { - extractAndProcessSystemMessages as extractSystemMessages, - extractTextFromMessageContent as extractText, - safeParseJSON, - checkAndAssignOrDefault, - extractThinkingFromOpenAIText, - mapFinishReason, - cleanJsonSchemaProperties as cleanJsonSchema, - CLAUDE_DEFAULT_MAX_TOKENS, - CLAUDE_DEFAULT_TEMPERATURE, - CLAUDE_DEFAULT_TOP_P, - GEMINI_DEFAULT_MAX_TOKENS, - GEMINI_DEFAULT_TEMPERATURE, - GEMINI_DEFAULT_TOP_P, - OPENAI_DEFAULT_INPUT_TOKEN_LIMIT, - OPENAI_DEFAULT_OUTPUT_TOKEN_LIMIT -} from '../utils.js'; -import { MODEL_PROTOCOL_PREFIX } from '../../utils/common.js'; -import { - generateResponseCreated, - generateResponseInProgress, - generateOutputItemAdded, - generateContentPartAdded, - generateOutputTextDone, - generateContentPartDone, - generateOutputItemDone, - generateResponseCompleted -} from '../../providers/openai/openai-responses-core.mjs'; - -/** - * OpenAI转换器类 - * 实现OpenAI协议到其他协议的转换 - */ -export class OpenAIConverter extends BaseConverter { - constructor() { - super('openai'); - // 创建 CodexConverter 实例用于委托 - this.codexConverter = new CodexConverter(); - } - - /** - * 转换请求 - */ - convertRequest(data, targetProtocol) { - switch (targetProtocol) { - case MODEL_PROTOCOL_PREFIX.CLAUDE: - return this.toClaudeRequest(data); - case MODEL_PROTOCOL_PREFIX.GEMINI: - return this.toGeminiRequest(data); - case MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES: - return this.toOpenAIResponsesRequest(data); - case MODEL_PROTOCOL_PREFIX.CODEX: - return this.toCodexRequest(data); - case MODEL_PROTOCOL_PREFIX.GROK: - return this.toGrokRequest(data); - default: - throw new Error(`Unsupported target protocol: ${targetProtocol}`); - } - } - - /** - * 转换响应 - */ - convertResponse(data, targetProtocol, model) { - // OpenAI作为源格式时,通常不需要转换响应 - // 因为其他协议会转换到OpenAI格式 - switch (targetProtocol) { - case MODEL_PROTOCOL_PREFIX.CLAUDE: - return this.toClaudeResponse(data, model); - case MODEL_PROTOCOL_PREFIX.GEMINI: - return this.toGeminiResponse(data, model); - case MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES: - return this.toOpenAIResponsesResponse(data, model); - case MODEL_PROTOCOL_PREFIX.GROK: - return this.toGrokResponse(data, model); - default: - throw new Error(`Unsupported target protocol: ${targetProtocol}`); - } - } - - /** - * 转换流式响应块 - */ - convertStreamChunk(chunk, targetProtocol, model) { - switch (targetProtocol) { - case MODEL_PROTOCOL_PREFIX.CLAUDE: - return this.toClaudeStreamChunk(chunk, model); - case MODEL_PROTOCOL_PREFIX.GEMINI: - return this.toGeminiStreamChunk(chunk, model); - case MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES: - return this.toOpenAIResponsesStreamChunk(chunk, model); - case MODEL_PROTOCOL_PREFIX.GROK: - return this.toGrokStreamChunk(chunk, model); - default: - throw new Error(`Unsupported target protocol: ${targetProtocol}`); - } - } - - /** - * 转换模型列表 - */ - convertModelList(data, targetProtocol) { - switch (targetProtocol) { - case MODEL_PROTOCOL_PREFIX.CLAUDE: - return this.toClaudeModelList(data); - case MODEL_PROTOCOL_PREFIX.GEMINI: - return this.toGeminiModelList(data); - default: - return this.ensureDisplayName(data); - } - } - - /** - * Ensure display_name field exists in OpenAI model list - */ - ensureDisplayName(openaiModels) { - if (!openaiModels || !openaiModels.data) { - return openaiModels; - } - - return { - ...openaiModels, - data: openaiModels.data.map(model => ({ - ...model, - display_name: model.display_name || model.id, - })), - }; - } - - // ========================================================================= - // OpenAI -> Claude 转换 - // ========================================================================= - - /** - * OpenAI请求 -> Claude请求 - */ - toClaudeRequest(openaiRequest) { - const messages = openaiRequest.messages || []; - const { systemInstruction, nonSystemMessages } = extractSystemMessages(messages); - - const claudeMessages = []; - - for (const message of nonSystemMessages) { - const role = message.role === 'assistant' ? 'assistant' : 'user'; - let content = []; - - if (message.role === 'tool') { - // 工具结果消息 - let toolContent = message.content; - if (typeof toolContent === 'object' && toolContent !== null) { - toolContent = JSON.stringify(toolContent); - } - content.push({ - type: 'tool_result', - tool_use_id: message.tool_call_id || message.tool_use_id, - content: toolContent - }); - claudeMessages.push({ role: 'user', content: content }); - } else if (message.role === 'assistant' && (message.tool_calls?.length || message.function_calls?.length)) { - // 助手工具调用消息 - 支持tool_calls和function_calls - const calls = message.tool_calls || message.function_calls || []; - const toolUseBlocks = calls.map(tc => ({ - type: 'tool_use', - id: tc.id, - name: tc.function.name, - input: safeParseJSON(tc.function.arguments) - })); - claudeMessages.push({ role: 'assistant', content: toolUseBlocks }); - } else { - // 普通消息 - if (typeof message.content === 'string') { - if (message.content) { - content.push({ type: 'text', text: message.content.trim() }); - } - } else if (Array.isArray(message.content)) { - message.content.forEach(item => { - if (!item) return; - switch (item.type) { - case 'text': - if (item.text) { - content.push({ type: 'text', text: item.text.trim() }); - } - break; - case 'image_url': - if (item.image_url) { - const imageUrl = typeof item.image_url === 'string' - ? item.image_url - : item.image_url.url; - if (imageUrl.startsWith('data:')) { - const [header, data] = imageUrl.split(','); - const mediaType = header.match(/data:([^;]+)/)?.[1] || 'image/jpeg'; - content.push({ - type: 'image', - source: { - type: 'base64', - media_type: mediaType, - data: data - } - }); - } else { - content.push({ type: 'text', text: `[Image: ${imageUrl}]` }); - } - } - break; - case 'audio': - if (item.audio_url) { - const audioUrl = typeof item.audio_url === 'string' - ? item.audio_url - : item.audio_url.url; - content.push({ type: 'text', text: `[Audio: ${audioUrl}]` }); - } - break; - case 'input_audio': - // OpenAI 官方 input_audio 格式 - if (item.input_audio) { - // Claude 不直接支持音频输入,转换为文本描述 - content.push({ type: 'text', text: `[Audio Input: ${item.input_audio.format || 'audio'}]` }); - } - break; - case 'tool_use': - content.push({ - type: 'tool_use', - id: item.id, - name: item.name, - input: typeof item.input === 'string' ? safeParseJSON(item.input) : (item.input || {}) - }); - break; - case 'tool_result': { - let resultContent = item.content; - if (typeof resultContent === 'object' && resultContent !== null) { - resultContent = JSON.stringify(resultContent); - } - content.push({ - type: 'tool_result', - tool_use_id: item.tool_use_id || item.id, - content: resultContent - }); - break; - } - } - }); - } - if (content.length > 0) { - claudeMessages.push({ role: role, content: content }); - } - } - } - // 合并相邻相同 role 的消息 - const mergedClaudeMessages = []; - for (let i = 0; i < claudeMessages.length; i++) { - const currentMessage = claudeMessages[i]; - - if (mergedClaudeMessages.length === 0) { - mergedClaudeMessages.push(currentMessage); - } else { - const lastMessage = mergedClaudeMessages[mergedClaudeMessages.length - 1]; - - // 如果当前消息的 role 与上一条消息的 role 相同,则合并 content 数组 - if (lastMessage.role === currentMessage.role) { - lastMessage.content = lastMessage.content.concat(currentMessage.content); - } else { - mergedClaudeMessages.push(currentMessage); - } - } - } - - // 清理最后一条 assistant 消息的尾部空白 - if (mergedClaudeMessages.length > 0) { - const lastMessage = mergedClaudeMessages[mergedClaudeMessages.length - 1]; - if (lastMessage.role === 'assistant' && Array.isArray(lastMessage.content)) { - // 从后往前找到最后一个 text 类型的内容块 - for (let i = lastMessage.content.length - 1; i >= 0; i--) { - const contentBlock = lastMessage.content[i]; - if (contentBlock.type === 'text' && contentBlock.text) { - // 移除尾部空白字符 - contentBlock.text = contentBlock.text.trimEnd(); - break; - } - } - } - } - - - const claudeRequest = { - model: openaiRequest.model, - messages: mergedClaudeMessages, - max_tokens: checkAndAssignOrDefault(openaiRequest.max_tokens, CLAUDE_DEFAULT_MAX_TOKENS), - temperature: checkAndAssignOrDefault(openaiRequest.temperature, CLAUDE_DEFAULT_TEMPERATURE), - top_p: checkAndAssignOrDefault(openaiRequest.top_p, CLAUDE_DEFAULT_TOP_P), - }; - - if (systemInstruction) { - claudeRequest.system = extractText(systemInstruction.parts[0].text); - } - - if (openaiRequest.tools?.length) { - claudeRequest.tools = openaiRequest.tools - .filter(t => t && ((t.function && t.function.name) || t.name)) - .map(t => { - if (t.function) { - return { - name: t.function.name, - description: t.function.description || '', - input_schema: t.function.parameters || { type: 'object', properties: {} } - }; - } - return { - name: t.name, - description: t.description || '', - input_schema: t.input_schema || { type: 'object', properties: {} } - }; - }); - if (claudeRequest.tools.length > 0) { - claudeRequest.tool_choice = this.buildClaudeToolChoice(openaiRequest.tool_choice); - } - } - - // Optional passthrough: request-side "thinking" controls for Claude/Kiro. - // OpenAI-compatible clients can provide these via `extra_body.anthropic.thinking`. - // We intentionally keep normalization minimal here; provider implementations - // (e.g. Kiro) clamp budgets and apply defaults. - const extThinking = openaiRequest?.extra_body?.anthropic?.thinking; - if (extThinking && typeof extThinking === 'object' && !Array.isArray(extThinking)) { - const type = String(extThinking.type || '').toLowerCase().trim(); - if (type === 'enabled') { - const thinkingCfg = { type: 'enabled' }; - if (extThinking.budget_tokens !== undefined) { - const n = parseInt(extThinking.budget_tokens, 10); - if (Number.isFinite(n)) { - thinkingCfg.budget_tokens = n; - } - } - claudeRequest.thinking = thinkingCfg; - } else if (type === 'adaptive') { - const effortRaw = typeof extThinking.effort === 'string' ? extThinking.effort : ''; - const effort = effortRaw.toLowerCase().trim(); - const normalizedEffort = (effort === 'low' || effort === 'medium' || effort === 'high') ? effort : 'high'; - claudeRequest.thinking = { type: 'adaptive', effort: normalizedEffort }; - } else if (type === 'disabled') { - // Explicitly disabled: omit thinking config. - } - } - - return claudeRequest; - } - - /** - * OpenAI响应 -> Claude响应 - */ - toClaudeResponse(openaiResponse, model) { - if (!openaiResponse || !openaiResponse.choices || openaiResponse.choices.length === 0) { - return { - id: `msg_${uuidv4()}`, - type: "message", - role: "assistant", - content: [], - model: model, - stop_reason: "end_turn", - stop_sequence: null, - usage: { - input_tokens: openaiResponse?.usage?.prompt_tokens || 0, - output_tokens: openaiResponse?.usage?.completion_tokens || 0 - } - }; - } - - const choice = openaiResponse.choices[0]; - const contentList = []; - - // 处理工具调用 - 支持tool_calls和function_calls - const toolCalls = choice.message?.tool_calls || choice.message?.function_calls || []; - for (const toolCall of toolCalls.filter(tc => tc && typeof tc === 'object')) { - if (toolCall.function) { - const func = toolCall.function; - const argStr = func.arguments || "{}"; - let argObj; - try { - argObj = typeof argStr === 'string' ? JSON.parse(argStr) : argStr; - } catch (e) { - argObj = {}; - } - contentList.push({ - type: "tool_use", - id: toolCall.id || "", - name: func.name || "", - input: argObj, - }); - } - } - - // 处理reasoning_content(推理内容) - const reasoningContent = choice.message?.reasoning_content || ""; - if (reasoningContent) { - contentList.push({ - type: "thinking", - thinking: reasoningContent - }); - } - - // 处理文本内容 - const contentText = choice.message?.content || ""; - if (contentText) { - const extractedContent = extractThinkingFromOpenAIText(contentText); - if (Array.isArray(extractedContent)) { - contentList.push(...extractedContent); - } else { - contentList.push({ type: "text", text: extractedContent }); - } - } - - // 映射结束原因 - const stopReason = mapFinishReason( - choice.finish_reason || "stop", - "openai", - "anthropic" - ); - - return { - id: `msg_${uuidv4()}`, - type: "message", - role: "assistant", - content: contentList, - model: model, - stop_reason: stopReason, - stop_sequence: null, - usage: { - input_tokens: openaiResponse.usage?.prompt_tokens || 0, - cache_creation_input_tokens: 0, - cache_read_input_tokens: openaiResponse.usage?.prompt_tokens_details?.cached_tokens || 0, - output_tokens: openaiResponse.usage?.completion_tokens || 0 - } - }; - } - - /** - * OpenAI流式响应 -> Claude流式响应 - * - * 这个方法实现了与 ClaudeConverter.toOpenAIStreamChunk 相反的转换逻辑 - * 将 OpenAI 的流式 chunk 转换为 Claude 的流式事件 - */ - toClaudeStreamChunk(openaiChunk, model) { - if (!openaiChunk) return null; - - // 处理 OpenAI chunk 对象 - if (typeof openaiChunk === 'object' && !Array.isArray(openaiChunk)) { - const choice = openaiChunk.choices?.[0]; - if (!choice) { - return null; - } - - const delta = choice.delta; - const finishReason = choice.finish_reason; - const events = []; - - // 注释部分是为了兼容claude code,但是不兼容cherry studio - // 1. 处理 role (对应 message_start) - // if (delta?.role === "assistant") { - // events.push({ - // type: "message_start", - // message: { - // id: openaiChunk.id || `msg_${uuidv4()}`, - // type: "message", - // role: "assistant", - // content: [], - // model: model || openaiChunk.model || "unknown", - // stop_reason: null, - // stop_sequence: null, - // usage: { - // input_tokens: openaiChunk.usage?.prompt_tokens || 0, - // output_tokens: 0 - // } - // } - // }); - // events.push({ - // type: "content_block_start", - // index: 0, - // content_block: { - // type: "text", - // text: "" - // } - // }); - // } - - // 2. 处理 tool_calls (对应 content_block_start 和 content_block_delta) - // if (delta?.tool_calls) { - // const toolCalls = delta.tool_calls; - // for (const toolCall of toolCalls) { - // // 如果有 function.name,说明是工具调用开始 - // if (toolCall.function?.name) { - // events.push({ - // type: "content_block_start", - // index: toolCall.index || 0, - // content_block: { - // type: "tool_use", - // id: toolCall.id || `tool_${uuidv4()}`, - // name: toolCall.function.name, - // input: {} - // } - // }); - // } - - // // 如果有 function.arguments,说明是参数增量 - // if (toolCall.function?.arguments) { - // events.push({ - // type: "content_block_delta", - // index: toolCall.index || 0, - // delta: { - // type: "input_json_delta", - // partial_json: toolCall.function.arguments - // } - // }); - // } - // } - // } - - // 3. 处理 reasoning_content (对应 thinking 类型的 content_block) - if (delta?.reasoning_content) { - // 注意:这里可能需要先发送 content_block_start,但由于状态管理复杂, - // 我们假设调用方会处理这个逻辑 - events.push({ - type: "content_block_delta", - index: 0, - delta: { - type: "thinking_delta", - thinking: delta.reasoning_content - } - }); - } - - // 4. 处理普通文本 content (对应 text 类型的 content_block) - if (delta?.content) { - events.push({ - type: "content_block_delta", - index: 0, - delta: { - type: "text_delta", - text: delta.content - } - }); - } - - // 5. 处理 finish_reason (对应 message_delta 和 message_stop) - if (finishReason) { - // 映射 finish_reason - const stopReason = finishReason === "stop" ? "end_turn" : - finishReason === "length" ? "max_tokens" : - "end_turn"; - - events.push({ - type: "content_block_stop", - index: 0 - }); - // 发送 message_delta - events.push({ - type: "message_delta", - delta: { - stop_reason: stopReason, - stop_sequence: null - }, - usage: { - input_tokens: openaiChunk.usage?.prompt_tokens || 0, - cache_creation_input_tokens: 0, - cache_read_input_tokens: openaiChunk.usage?.prompt_tokens_details?.cached_tokens || 0, - output_tokens: openaiChunk.usage?.completion_tokens || 0 - } - }); - - // 发送 message_stop - events.push({ - type: "message_stop" - }); - } - - return events.length > 0 ? events : null; - } - - // 向后兼容:处理字符串格式 - if (typeof openaiChunk === 'string') { - return { - type: "content_block_delta", - index: 0, - delta: { - type: "text_delta", - text: openaiChunk - } - }; - } - - return null; - } - - /** - * OpenAI模型列表 -> Claude模型列表 - */ - toClaudeModelList(openaiModels) { - return { - models: openaiModels.data.map(m => ({ - name: m.id, - description: "", - })), - }; - } - - /** - * 将 OpenAI 模型列表转换为 Gemini 模型列表 - */ - toGeminiModelList(openaiModels) { - const models = openaiModels.data || []; - return { - models: models.map(m => ({ - name: `models/${m.id}`, - version: m.version || "1.0.0", - displayName: m.displayName || m.id, - description: m.description || `A generative model for text and chat generation. ID: ${m.id}`, - inputTokenLimit: m.inputTokenLimit || OPENAI_DEFAULT_INPUT_TOKEN_LIMIT, - outputTokenLimit: m.outputTokenLimit || OPENAI_DEFAULT_OUTPUT_TOKEN_LIMIT, - supportedGenerationMethods: m.supportedGenerationMethods || ["generateContent", "streamGenerateContent"] - })) - }; - } - - /** - * 构建Claude工具选择 - */ - buildClaudeToolChoice(toolChoice) { - if (typeof toolChoice === 'string') { - const mapping = { auto: 'auto', none: 'none', required: 'any' }; - return { type: mapping[toolChoice] }; - } - if (typeof toolChoice === 'object') { - // Claude 原生格式:{ type, name } - if (toolChoice.type && toolChoice.name) { - return { type: toolChoice.type, name: toolChoice.name }; - } - // OpenAI 格式:{ function: { name } } - if (toolChoice.function) { - return { type: 'tool', name: toolChoice.function.name }; - } - } - return undefined; - } - - // ========================================================================= - // OpenAI -> Gemini 转换 - // ========================================================================= - - // Gemini Openai thought signature constant - static GEMINI_OPENAI_THOUGHT_SIGNATURE = "skip_thought_signature_validator"; - /** - * OpenAI请求 -> Gemini请求 - */ - toGeminiRequest(openaiRequest) { - const messages = openaiRequest.messages || []; - const model = openaiRequest.model || ''; - - // 构建 tool_call_id -> function_name 映射 - const tcID2Name = {}; - for (const message of messages) { - if (message.role === 'assistant' && message.tool_calls) { - for (const tc of message.tool_calls) { - if (tc.type === 'function' && tc.id && tc.function?.name) { - tcID2Name[tc.id] = tc.function.name; - } - } - } - // Claude 格式:content 数组中的 tool_use - if (message.role === 'assistant' && Array.isArray(message.content)) { - for (const item of message.content) { - if (item && item.type === 'tool_use' && item.id && item.name) { - tcID2Name[item.id] = item.name; - } - } - } - } - - // 构建 tool_call_id -> response 映射 - const toolResponses = {}; - for (const message of messages) { - if (message.role === 'tool' && message.tool_call_id) { - toolResponses[message.tool_call_id] = message.content; - } - // Claude 格式:user content 数组中的 tool_result - if (message.role === 'user' && Array.isArray(message.content)) { - for (const item of message.content) { - if (item && item.type === 'tool_result' && item.tool_use_id) { - toolResponses[item.tool_use_id] = item.content; - } - } - } - } - - const processedMessages = []; - let systemInstruction = null; - - for (let i = 0; i < messages.length; i++) { - const message = messages[i]; - const role = message.role; - const content = message.content; - - if (role === 'system' || role === 'developer') { - // system -> system_instruction - if (messages.length > 1) { - if (typeof content === 'string') { - systemInstruction = { - role: 'user', - parts: [{ text: content }] - }; - } else if (Array.isArray(content)) { - const parts = content - .filter(item => item.type === 'text' && item.text) - .map(item => ({ text: item.text })); - if (parts.length > 0) { - systemInstruction = { - role: 'user', - parts: parts - }; - } - } else if (typeof content === 'object' && content.type === 'text') { - systemInstruction = { - role: 'user', - parts: [{ text: content.text }] - }; - } - } else { - // 只有一条 system 消息时,作为 user 消息处理 - const node = { role: 'user', parts: [] }; - if (typeof content === 'string') { - node.parts.push({ text: content }); - } else if (Array.isArray(content)) { - for (const item of content) { - if (item.type === 'text' && item.text) { - node.parts.push({ text: item.text }); - } - } - } - if (node.parts.length > 0) { - processedMessages.push(node); - } - } - } else if (role === 'user') { - // user -> user content - const node = { role: 'user', parts: [] }; - if (typeof content === 'string') { - node.parts.push({ text: content }); - } else if (Array.isArray(content)) { - for (const item of content) { - if (!item) continue; - switch (item.type) { - case 'text': - if (item.text) { - node.parts.push({ text: item.text }); - } - break; - case 'image_url': - if (item.image_url) { - const imageUrl = typeof item.image_url === 'string' - ? item.image_url - : item.image_url.url; - if (imageUrl && imageUrl.startsWith('data:')) { - const commaIndex = imageUrl.indexOf(','); - if (commaIndex > 5) { - const header = imageUrl.substring(5, commaIndex); - const semicolonIndex = header.indexOf(';'); - if (semicolonIndex > 0) { - const mimeType = header.substring(0, semicolonIndex); - const data = imageUrl.substring(commaIndex + 1); - node.parts.push({ - inlineData: { - mimeType: mimeType, - data: data - }, - thoughtSignature: OpenAIConverter.GEMINI_OPENAI_THOUGHT_SIGNATURE - }); - } - } - } else if (imageUrl) { - node.parts.push({ - fileData: { - mimeType: 'image/jpeg', - fileUri: imageUrl - } - }); - } - } - break; - case 'file': - if (item.file) { - const filename = item.file.filename || ''; - const fileData = item.file.file_data || ''; - const ext = filename.includes('.') - ? filename.split('.').pop().toLowerCase() - : ''; - const mimeTypes = { - 'pdf': 'application/pdf', - 'txt': 'text/plain', - 'html': 'text/html', - 'css': 'text/css', - 'js': 'application/javascript', - 'json': 'application/json', - 'xml': 'application/xml', - 'csv': 'text/csv', - 'md': 'text/markdown', - 'py': 'text/x-python', - 'java': 'text/x-java', - 'c': 'text/x-c', - 'cpp': 'text/x-c++', - 'h': 'text/x-c', - 'hpp': 'text/x-c++', - 'go': 'text/x-go', - 'rs': 'text/x-rust', - 'ts': 'text/typescript', - 'tsx': 'text/typescript', - 'jsx': 'text/javascript', - 'png': 'image/png', - 'jpg': 'image/jpeg', - 'jpeg': 'image/jpeg', - 'gif': 'image/gif', - 'webp': 'image/webp', - 'svg': 'image/svg+xml', - 'mp3': 'audio/mpeg', - 'wav': 'audio/wav', - 'mp4': 'video/mp4', - 'webm': 'video/webm' - }; - const mimeType = mimeTypes[ext]; - if (mimeType && fileData) { - node.parts.push({ - inlineData: { - mimeType: mimeType, - data: fileData - } - }); - } - } - break; - } - } - } - if (node.parts.length > 0) { - processedMessages.push(node); - } - } else if (role === 'assistant') { - // assistant -> model content - const node = { role: 'model', parts: [] }; - - // 处理文本内容 - const functionCallIds = []; - if (typeof content === 'string' && content) { - node.parts.push({ text: content }); - } else if (Array.isArray(content)) { - for (const item of content) { - if (!item) continue; - if (item.type === 'text' && item.text) { - node.parts.push({ text: item.text }); - } else if (item.type === 'tool_use') { - // Claude 格式 tool_use -> Gemini functionCall - const fid = item.id || ''; - const fname = item.name || ''; - const argsObj = typeof item.input === 'string' ? (() => { try { return JSON.parse(item.input); } catch(e) { return {}; } })() : (item.input || {}); - node.parts.push({ - functionCall: { - name: fname, - args: argsObj - }, - thoughtSignature: OpenAIConverter.GEMINI_OPENAI_THOUGHT_SIGNATURE - }); - if (fid) functionCallIds.push(fid); - } else if (item.type === 'image_url' && item.image_url) { - const imageUrl = typeof item.image_url === 'string' - ? item.image_url - : item.image_url.url; - if (imageUrl && imageUrl.startsWith('data:')) { - const commaIndex = imageUrl.indexOf(','); - if (commaIndex > 5) { - const header = imageUrl.substring(5, commaIndex); - const semicolonIndex = header.indexOf(';'); - if (semicolonIndex > 0) { - const mimeType = header.substring(0, semicolonIndex); - const data = imageUrl.substring(commaIndex + 1); - node.parts.push({ - inlineData: { - mimeType: mimeType, - data: data - }, - thoughtSignature: OpenAIConverter.GEMINI_OPENAI_THOUGHT_SIGNATURE - }); - } - } - } - } - } - } - - // 处理 OpenAI 格式 tool_calls -> functionCall - if (message.tool_calls && Array.isArray(message.tool_calls)) { - for (const tc of message.tool_calls) { - if (tc.type !== 'function') continue; - const fid = tc.id || ''; - const fname = tc.function?.name || ''; - const fargs = tc.function?.arguments || '{}'; - - let argsObj; - try { - argsObj = typeof fargs === 'string' ? JSON.parse(fargs) : fargs; - } catch (e) { - argsObj = {}; - } - - node.parts.push({ - functionCall: { - name: fname, - args: argsObj - }, - thoughtSignature: OpenAIConverter.GEMINI_OPENAI_THOUGHT_SIGNATURE - }); - - if (fid) { - functionCallIds.push(fid); - } - } - } - - // 添加 model 消息 - if (node.parts.length > 0) { - processedMessages.push(node); - } - - // 添加对应的 functionResponse(作为 user 消息) - if (functionCallIds.length > 0) { - const toolNode = { role: 'user', parts: [] }; - for (const fid of functionCallIds) { - const name = tcID2Name[fid]; - if (name) { - let resp = toolResponses[fid] || '{}'; - if (typeof resp !== 'string') { - resp = JSON.stringify(resp); - } - toolNode.parts.push({ - functionResponse: { - name: name, - response: { - result: resp - } - } - }); - } - } - if (toolNode.parts.length > 0) { - processedMessages.push(toolNode); - } - } - } else if (role === 'tool') { - // 处理独立的 tool role 消息(OpenAI 格式) - // 转换为 Gemini 的 functionResponse 格式 - const toolNode = { role: 'user', parts: [] }; - - // 从 tool_call_id 查找对应的函数名 - const toolCallId = message.tool_call_id; - const functionName = tcID2Name[toolCallId]; - - if (functionName) { - let responseContent = message.content; - if (typeof responseContent !== 'string') { - responseContent = JSON.stringify(responseContent); - } - - toolNode.parts.push({ - functionResponse: { - name: functionName, - response: { - result: responseContent - } - } - }); - - if (toolNode.parts.length > 0) { - processedMessages.push(toolNode); - } - } - } - // 其他 role 类型跳过 - } - - // 构建 Gemini 请求 - const geminiRequest = { - contents: processedMessages.filter(item => item.parts && item.parts.length > 0) - }; - - // 添加 model - if (model) { - geminiRequest.model = model; - } - - // 添加 system_instruction - if (systemInstruction) { - geminiRequest.system_instruction = systemInstruction; - } - - // 处理 reasoning_effort -> thinkingConfig - if (openaiRequest.reasoning_effort) { - const effort = String(openaiRequest.reasoning_effort).toLowerCase().trim(); - if (this.modelSupportsThinking(model)) { - if (this.isGemini3Model(model)) { - // Gemini 3 模型使用 thinkingLevel - if (effort === 'none') { - // 不添加 thinkingConfig - } else if (effort === 'auto') { - geminiRequest.generationConfig = geminiRequest.generationConfig || {}; - geminiRequest.generationConfig.thinkingConfig = { - includeThoughts: true - }; - } else { - const level = this.validateGemini3ThinkingLevel(model, effort); - if (level) { - geminiRequest.generationConfig = geminiRequest.generationConfig || {}; - geminiRequest.generationConfig.thinkingConfig = { - thinkingLevel: level - }; - } - } - } else if (!this.modelUsesThinkingLevels(model)) { - // 使用 thinkingBudget 的模型 - geminiRequest.generationConfig = geminiRequest.generationConfig || {}; - geminiRequest.generationConfig.thinkingConfig = this.applyReasoningEffortToGemini(effort); - } - } - } - - // 处理 extra_body.google.thinking_config(Cherry Studio 扩展) - if (!openaiRequest.reasoning_effort && openaiRequest.extra_body?.google?.thinking_config) { - const tc = openaiRequest.extra_body.google.thinking_config; - if (this.modelSupportsThinking(model) && !this.modelUsesThinkingLevels(model)) { - geminiRequest.generationConfig = geminiRequest.generationConfig || {}; - geminiRequest.generationConfig.thinkingConfig = geminiRequest.generationConfig.thinkingConfig || {}; - - let setBudget = false; - let budget = 0; - - if (tc.thinkingBudget !== undefined) { - budget = parseInt(tc.thinkingBudget, 10); - geminiRequest.generationConfig.thinkingConfig.thinkingBudget = budget; - setBudget = true; - } else if (tc.thinking_budget !== undefined) { - budget = parseInt(tc.thinking_budget, 10); - geminiRequest.generationConfig.thinkingConfig.thinkingBudget = budget; - setBudget = true; - } - - if (tc.includeThoughts !== undefined) { - geminiRequest.generationConfig.thinkingConfig.includeThoughts = tc.includeThoughts; - } else if (tc.include_thoughts !== undefined) { - geminiRequest.generationConfig.thinkingConfig.includeThoughts = tc.include_thoughts; - } else if (setBudget && budget !== 0) { - geminiRequest.generationConfig.thinkingConfig.includeThoughts = true; - } - } - } - - // 处理 modalities -> responseModalities - if (openaiRequest.modalities && Array.isArray(openaiRequest.modalities)) { - const responseMods = []; - for (const m of openaiRequest.modalities) { - const mod = String(m).toLowerCase(); - if (mod === 'text') { - responseMods.push('TEXT'); - } else if (mod === 'image') { - responseMods.push('IMAGE'); - } - } - if (responseMods.length > 0) { - geminiRequest.generationConfig = geminiRequest.generationConfig || {}; - geminiRequest.generationConfig.responseModalities = responseMods; - } - } - - // 处理 image_config(OpenRouter 风格) - if (openaiRequest.image_config) { - const imgCfg = openaiRequest.image_config; - if (imgCfg.aspect_ratio) { - geminiRequest.generationConfig = geminiRequest.generationConfig || {}; - geminiRequest.generationConfig.imageConfig = geminiRequest.generationConfig.imageConfig || {}; - geminiRequest.generationConfig.imageConfig.aspectRatio = imgCfg.aspect_ratio; - } - if (imgCfg.image_size) { - geminiRequest.generationConfig = geminiRequest.generationConfig || {}; - geminiRequest.generationConfig.imageConfig = geminiRequest.generationConfig.imageConfig || {}; - geminiRequest.generationConfig.imageConfig.imageSize = imgCfg.image_size; - } - } - - // 处理 tools -> functionDeclarations - if (openaiRequest.tools?.length) { - const functionDeclarations = []; - let hasGoogleSearch = false; - - for (const t of openaiRequest.tools) { - if (!t || typeof t !== 'object') continue; - - if (t.type === 'function' && t.function) { - const func = t.function; - let fnDecl = { - name: String(func.name || ''), - description: String(func.description || '') - }; - - // 处理 parameters -> parametersJsonSchema - if (func.parameters) { - fnDecl.parametersJsonSchema = cleanJsonSchema(func.parameters); - } else { - fnDecl.parametersJsonSchema = { - type: 'object', - properties: {} - }; - } - - functionDeclarations.push(fnDecl); - } else if (t.name) { - functionDeclarations.push({ - name: String(t.name), - description: String(t.description || ''), - parametersJsonSchema: cleanJsonSchema(t.input_schema || { type: 'object', properties: {} }) - }); - } - - // 处理 google_search 工具 - if (t.google_search) { - hasGoogleSearch = true; - } - } - - if (functionDeclarations.length > 0 || hasGoogleSearch) { - geminiRequest.tools = [{}]; - if (functionDeclarations.length > 0) { - geminiRequest.tools[0].functionDeclarations = functionDeclarations; - } - if (hasGoogleSearch) { - const googleSearchTool = openaiRequest.tools.find(t => t.google_search); - geminiRequest.tools[0].googleSearch = googleSearchTool.google_search; - } - } - } - - // 处理 tool_choice - if (openaiRequest.tool_choice) { - geminiRequest.toolConfig = this.buildGeminiToolConfig(openaiRequest.tool_choice); - } - - // 构建 generationConfig - const config = this.buildGeminiGenerationConfig(openaiRequest, model); - if (Object.keys(config).length) { - geminiRequest.generationConfig = { - ...config, - ...(geminiRequest.generationConfig || {}) - }; - } - - // 添加默认安全设置 - geminiRequest.safetySettings = this.getDefaultSafetySettings(); - - return geminiRequest; - } - - /** - * 检查模型是否支持 thinking - */ - modelSupportsThinking(model) { - if (!model) return false; - const m = model.toLowerCase(); - return m.includes('2.5') || m.includes('thinking') || m.includes('2.0-flash-thinking'); - } - - /** - * 检查是否是 Gemini 3 模型 - */ - isGemini3Model(model) { - if (!model) return false; - const m = model.toLowerCase(); - return m.includes('gemini-3') || m.includes('gemini3'); - } - - /** - * 检查模型是否使用 thinking levels(而不是 budget) - */ - modelUsesThinkingLevels(model) { - if (!model) return false; - // Gemini 3 模型使用 levels,其他使用 budget - return this.isGemini3Model(model); - } - - /** - * 验证 Gemini 3 thinking level - */ - validateGemini3ThinkingLevel(model, effort) { - const validLevels = ['low', 'medium', 'high']; - if (validLevels.includes(effort)) { - return effort.toUpperCase(); - } - return null; - } - - /** - * 将 reasoning_effort 转换为 Gemini thinkingConfig - */ - applyReasoningEffortToGemini(effort) { - const effortToBudget = { - 'low': 1024, - 'medium': 8192, - 'high': 24576 - }; - const budget = effortToBudget[effort] || effortToBudget['medium']; - return { - thinkingBudget: budget, - includeThoughts: true - }; - } - - /** - * 获取默认安全设置 - */ - getDefaultSafetySettings() { - return [ - { category: "HARM_CATEGORY_HARASSMENT", threshold: "OFF" }, - { category: "HARM_CATEGORY_HATE_SPEECH", threshold: "OFF" }, - { category: "HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold: "OFF" }, - { category: "HARM_CATEGORY_DANGEROUS_CONTENT", threshold: "OFF" }, - { category: "HARM_CATEGORY_CIVIC_INTEGRITY", threshold: "OFF" } - ]; - } - - /** - * 处理OpenAI内容到Gemini parts - */ - processOpenAIContentToGeminiParts(content) { - if (!content) return []; - if (typeof content === 'string') return [{ text: content }]; - - if (Array.isArray(content)) { - const parts = []; - - for (const item of content) { - if (!item) continue; - - if (item.type === 'text' && item.text) { - parts.push({ text: item.text }); - } else if (item.type === 'image_url' && item.image_url) { - const imageUrl = typeof item.image_url === 'string' - ? item.image_url - : item.image_url.url; - - if (imageUrl.startsWith('data:')) { - const [header, data] = imageUrl.split(','); - const mimeType = header.match(/data:([^;]+)/)?.[1] || 'image/jpeg'; - parts.push({ inlineData: { mimeType, data } }); - } else { - parts.push({ - fileData: { mimeType: 'image/jpeg', fileUri: imageUrl } - }); - } - } - } - - return parts; - } - - return []; - } - - /** - * 构建Gemini工具配置 - */ - buildGeminiToolConfig(toolChoice) { - if (typeof toolChoice === 'string' && ['none', 'auto'].includes(toolChoice)) { - return { functionCallingConfig: { mode: toolChoice.toUpperCase() } }; - } - if (typeof toolChoice === 'object' && toolChoice.function) { - return { functionCallingConfig: { mode: 'ANY', allowedFunctionNames: [toolChoice.function.name] } }; - } - return null; - } - - /** - * 构建Gemini生成配置 - */ - buildGeminiGenerationConfig({ temperature, max_tokens, top_p, stop, tools, response_format }, model) { - const config = {}; - config.temperature = checkAndAssignOrDefault(temperature, GEMINI_DEFAULT_TEMPERATURE); - config.maxOutputTokens = checkAndAssignOrDefault(max_tokens, GEMINI_DEFAULT_MAX_TOKENS); - config.topP = checkAndAssignOrDefault(top_p, GEMINI_DEFAULT_TOP_P); - if (stop !== undefined) config.stopSequences = Array.isArray(stop) ? stop : [stop]; - - // Handle response_format - if (response_format) { - if (response_format.type === 'json_object') { - config.responseMimeType = 'application/json'; - } else if (response_format.type === 'json_schema' && response_format.json_schema) { - config.responseMimeType = 'application/json'; - if (response_format.json_schema.schema) { - config.responseSchema = response_format.json_schema.schema; - } - } - } - - // Gemini 2.5 and thinking models require responseModalities: ["TEXT"] - // But this parameter cannot be added when using tools (causes 400 error) - const hasTools = tools && Array.isArray(tools) && tools.length > 0; - if (!hasTools && model && (model.includes('2.5') || model.includes('thinking') || model.includes('2.0-flash-thinking'))) { - logger.info(`[OpenAI->Gemini] Adding responseModalities: ["TEXT"] for model: ${model}`); - config.responseModalities = ["TEXT"]; - } else if (hasTools && model && (model.includes('2.5') || model.includes('thinking') || model.includes('2.0-flash-thinking'))) { - logger.info(`[OpenAI->Gemini] Skipping responseModalities for model ${model} because tools are present`); - } - - return config; - } - /** - * 将OpenAI响应转换为Gemini响应格式 - */ - toGeminiResponse(openaiResponse, model) { - if (!openaiResponse || !openaiResponse.choices || !openaiResponse.choices[0]) { - return { candidates: [], usageMetadata: {} }; - } - - const choice = openaiResponse.choices[0]; - const message = choice.message || {}; - const parts = []; - - // 处理文本内容 - if (message.content) { - parts.push({ text: message.content }); - } - - // 处理工具调用 - if (message.tool_calls && message.tool_calls.length > 0) { - for (const toolCall of message.tool_calls) { - if (toolCall.type === 'function') { - parts.push({ - functionCall: { - name: toolCall.function.name, - args: typeof toolCall.function.arguments === 'string' - ? JSON.parse(toolCall.function.arguments) - : toolCall.function.arguments - } - }); - } - } - } - - // 映射finish_reason - const finishReasonMap = { - 'stop': 'STOP', - 'length': 'MAX_TOKENS', - 'tool_calls': 'STOP', - 'content_filter': 'SAFETY' - }; - - return { - candidates: [{ - content: { - role: 'model', - parts: parts - }, - finishReason: finishReasonMap[choice.finish_reason] || 'STOP' - }], - usageMetadata: openaiResponse.usage ? { - promptTokenCount: openaiResponse.usage.prompt_tokens || 0, - candidatesTokenCount: openaiResponse.usage.completion_tokens || 0, - totalTokenCount: openaiResponse.usage.total_tokens || 0, - cachedContentTokenCount: openaiResponse.usage.prompt_tokens_details?.cached_tokens || 0, - promptTokensDetails: [{ - modality: "TEXT", - tokenCount: openaiResponse.usage.prompt_tokens || 0 - }], - candidatesTokensDetails: [{ - modality: "TEXT", - tokenCount: openaiResponse.usage.completion_tokens || 0 - }], - thoughtsTokenCount: openaiResponse.usage.completion_tokens_details?.reasoning_tokens || 0 - } : {} - }; - } - - /** - * 将OpenAI流式响应块转换为Gemini流式响应格式 - */ - toGeminiStreamChunk(openaiChunk, model) { - if (!openaiChunk || !openaiChunk.choices || !openaiChunk.choices[0]) { - return null; - } - - const choice = openaiChunk.choices[0]; - const delta = choice.delta || {}; - const parts = []; - - // 处理文本内容 - if (delta.content) { - parts.push({ text: delta.content }); - } - - // 处理工具调用 - if (delta.tool_calls && delta.tool_calls.length > 0) { - for (const toolCall of delta.tool_calls) { - if (toolCall.function) { - const functionCall = { - name: toolCall.function.name || '', - args: {} - }; - - if (toolCall.function.arguments) { - try { - functionCall.args = typeof toolCall.function.arguments === 'string' - ? JSON.parse(toolCall.function.arguments) - : toolCall.function.arguments; - } catch (e) { - // 部分参数,保持为字符串 - functionCall.args = { partial: toolCall.function.arguments }; - } - } - - parts.push({ functionCall }); - } - } - } - - const result = { - candidates: [{ - content: { - role: 'model', - parts: parts - } - }] - }; - - return result; - } - - // ========================================================================= - // OpenAI -> Grok 转换 - // ========================================================================= - - /** - * OpenAI请求 -> Grok请求 - */ - toGrokRequest(openaiRequest) { - // 我们需要 GrokConverter 来处理复杂的仿真逻辑 - const { ConverterFactory } = (import.meta.url ? { ConverterFactory: null } : { ConverterFactory: null }); // 这是一个占位,实际会从全局获取 - - // 直接返回结构化数据,由 GrokApiService.buildPayload 最终处理 - // 这样可以保留原始的 messages, tools, tool_choice 以进行高质量仿真 - return { - ...openaiRequest, - // 保持原始结构以便 GrokApiService 处理 - _isConverted: true - }; - } - - /** - * OpenAI响应 -> Grok响应(通常不使用) - */ - toGrokResponse(openaiResponse, model) { - return openaiResponse; - } - - /** - * OpenAI流式响应 -> Grok流式响应(通常不使用) - */ - toGrokStreamChunk(openaiChunk, model) { - return openaiChunk; - } - - /** - * OpenAI模型列表 -> Grok模型列表(通常不使用) - */ - toGrokModelList(openaiModels) { - return openaiModels; - } - - /** - * 将 OpenAI 模型列表转换为 Gemini 模型列表 - */ - toCodexRequest(openaiRequest) { - return this.codexConverter.toOpenAIRequestToCodexRequest(openaiRequest); - } - - /** - * 将OpenAI请求转换为OpenAI Responses格式 - */ - toOpenAIResponsesRequest(openaiRequest) { - const responsesRequest = { - model: openaiRequest.model, - instructions: '', - input: [], - stream: openaiRequest.stream || false, - max_output_tokens: openaiRequest.max_tokens, - temperature: openaiRequest.temperature, - top_p: openaiRequest.top_p, - parallel_tool_calls: openaiRequest.parallel_tool_calls, - tool_choice: openaiRequest.tool_choice - }; - - const { systemInstruction, nonSystemMessages } = extractSystemMessages(openaiRequest.messages || []); - - if (systemInstruction) { - responsesRequest.instructions = extractText(systemInstruction.parts[0].text); - } - - if (openaiRequest.reasoning_effort) { - responsesRequest.reasoning = { - effort: openaiRequest.reasoning_effort - }; - } - - // 转换messages到input - for (const msg of nonSystemMessages) { - if (msg.role === 'tool') { - responsesRequest.input.push({ - type: 'function_call_output', - call_id: msg.tool_call_id, - output: msg.content - }); - } else if (msg.role === 'assistant' && msg.tool_calls?.length) { - for (const tc of msg.tool_calls) { - responsesRequest.input.push({ - type: 'function_call', - call_id: tc.id, - name: tc.function.name, - arguments: tc.function.arguments - }); - } - } else { - let content = []; - if (typeof msg.content === 'string') { - content.push({ - type: msg.role === 'assistant' ? 'output_text' : 'input_text', - text: msg.content - }); - } else if (Array.isArray(msg.content)) { - msg.content.forEach(c => { - if (c.type === 'text') { - content.push({ - type: msg.role === 'assistant' ? 'output_text' : 'input_text', - text: c.text - }); - } else if (c.type === 'image_url') { - content.push({ - type: 'input_image', - image_url: c.image_url - }); - } - }); - } - - if (content.length > 0) { - responsesRequest.input.push({ - type: 'message', - role: msg.role, - content: content - }); - } - } - } - - // 处理工具 - if (openaiRequest.tools) { - responsesRequest.tools = openaiRequest.tools.map(t => ({ - type: t.type || 'function', - name: t.function?.name, - description: t.function?.description, - parameters: t.function?.parameters - })); - } - - return responsesRequest; - } - - /** - * 将OpenAI响应转换为OpenAI Responses格式 - */ - toOpenAIResponsesResponse(openaiResponse, model) { - if (!openaiResponse || !openaiResponse.choices || !openaiResponse.choices[0]) { - return { - id: `resp_${Date.now()}`, - object: 'response', - created_at: Math.floor(Date.now() / 1000), - status: 'completed', - model: model || 'unknown', - output: [], - usage: { - input_tokens: 0, - output_tokens: 0, - total_tokens: 0 - } - }; - } - - const choice = openaiResponse.choices[0]; - const message = choice.message || {}; - const output = []; - - // 构建message输出 - const messageContent = []; - if (message.content) { - messageContent.push({ - type: 'output_text', - text: message.content - }); - } - - output.push({ - type: 'message', - id: `msg_${Date.now()}`, - status: 'completed', - role: 'assistant', - content: messageContent - }); - - return { - id: openaiResponse.id || `resp_${Date.now()}`, - object: 'response', - created_at: openaiResponse.created || Math.floor(Date.now() / 1000), - status: choice.finish_reason === 'stop' ? 'completed' : 'in_progress', - model: model || openaiResponse.model || 'unknown', - output: output, - usage: openaiResponse.usage ? { - input_tokens: openaiResponse.usage.prompt_tokens || 0, - input_tokens_details: { - cached_tokens: openaiResponse.usage.prompt_tokens_details?.cached_tokens || 0 - }, - output_tokens: openaiResponse.usage.completion_tokens || 0, - output_tokens_details: { - reasoning_tokens: openaiResponse.usage.completion_tokens_details?.reasoning_tokens || 0 - }, - total_tokens: openaiResponse.usage.total_tokens || 0 - } : { - input_tokens: 0, - input_tokens_details: { - cached_tokens: 0 - }, - output_tokens: 0, - output_tokens_details: { - reasoning_tokens: 0 - }, - total_tokens: 0 - } - }; - } - - /** - * 将OpenAI流式响应转换为OpenAI Responses流式格式 - * 参考 ClaudeConverter.toOpenAIResponsesStreamChunk 的实现逻辑 - */ - toOpenAIResponsesStreamChunk(openaiChunk, model, requestId = null) { - if (!openaiChunk || !openaiChunk.choices || !openaiChunk.choices[0]) { - return []; - } - - const responseId = requestId || `resp_${uuidv4().replace(/-/g, '')}`; - const choice = openaiChunk.choices[0]; - const delta = choice.delta || {}; - const events = []; - - // 第一个chunk - role为assistant时调用 getOpenAIResponsesStreamChunkBegin - if (delta.role === 'assistant') { - events.push( - generateResponseCreated(responseId, model || openaiChunk.model || 'unknown'), - generateResponseInProgress(responseId), - generateOutputItemAdded(responseId), - generateContentPartAdded(responseId) - ); - } - - // 处理 reasoning_content(推理内容) - if (delta.reasoning_content) { - events.push({ - delta: delta.reasoning_content, - item_id: `thinking_${uuidv4().replace(/-/g, '')}`, - output_index: 0, - sequence_number: 3, - type: "response.reasoning_summary_text.delta" - }); - } - - // 处理 tool_calls(工具调用) - if (delta.tool_calls && delta.tool_calls.length > 0) { - for (const toolCall of delta.tool_calls) { - const outputIndex = toolCall.index || 0; - - // 如果有 function.name,说明是工具调用开始 - if (toolCall.function && toolCall.function.name) { - events.push({ - item: { - id: toolCall.id || `call_${uuidv4().replace(/-/g, '')}`, - type: "function_call", - name: toolCall.function.name, - arguments: "", - status: "in_progress" - }, - output_index: outputIndex, - sequence_number: 2, - type: "response.output_item.added" - }); - } - - // 如果有 function.arguments,说明是参数增量 - if (toolCall.function && toolCall.function.arguments) { - events.push({ - delta: toolCall.function.arguments, - item_id: toolCall.id || `call_${uuidv4().replace(/-/g, '')}`, - output_index: outputIndex, - sequence_number: 3, - type: "response.custom_tool_call_input.delta" - }); - } - } - } - - // 处理普通文本内容 - if (delta.content) { - events.push({ - delta: delta.content, - item_id: `msg_${uuidv4().replace(/-/g, '')}`, - output_index: 0, - sequence_number: 3, - type: "response.output_text.delta" - }); - } - - // 处理完成状态 - 调用 getOpenAIResponsesStreamChunkEnd - if (choice.finish_reason) { - events.push( - generateOutputTextDone(responseId), - generateContentPartDone(responseId), - generateOutputItemDone(responseId), - generateResponseCompleted(responseId) - ); - - // 如果有 usage 信息,更新最后一个事件 - if (openaiChunk.usage && events.length > 0) { - const lastEvent = events[events.length - 1]; - if (lastEvent.response) { - lastEvent.response.usage = { - input_tokens: openaiChunk.usage.prompt_tokens || 0, - input_tokens_details: { - cached_tokens: openaiChunk.usage.prompt_tokens_details?.cached_tokens || 0 - }, - output_tokens: openaiChunk.usage.completion_tokens || 0, - output_tokens_details: { - reasoning_tokens: openaiChunk.usage.completion_tokens_details?.reasoning_tokens || 0 - }, - total_tokens: openaiChunk.usage.total_tokens || 0 - }; - } - } - } - - return events; - } - -} - -export default OpenAIConverter; diff --git a/src/converters/strategies/OpenAIResponsesConverter.js b/src/converters/strategies/OpenAIResponsesConverter.js deleted file mode 100644 index 943ba4eb072134167b50eee797b8d56a0272783c..0000000000000000000000000000000000000000 --- a/src/converters/strategies/OpenAIResponsesConverter.js +++ /dev/null @@ -1,1032 +0,0 @@ -/** - * OpenAI Responses API 转换器 - * 处理 OpenAI Responses API 格式与其他协议之间的转换 - */ - -import { v4 as uuidv4 } from 'uuid'; -import { BaseConverter } from '../BaseConverter.js'; -import { CodexConverter } from './CodexConverter.js'; -import { MODEL_PROTOCOL_PREFIX } from '../../utils/common.js'; -import { - extractAndProcessSystemMessages as extractSystemMessages, - extractTextFromMessageContent as extractText, - CLAUDE_DEFAULT_MAX_TOKENS, - GEMINI_DEFAULT_INPUT_TOKEN_LIMIT, - GEMINI_DEFAULT_OUTPUT_TOKEN_LIMIT -} from '../utils.js'; -import { - generateResponseCreated, - generateResponseInProgress, - generateOutputItemAdded, - generateContentPartAdded, - generateOutputTextDone, - generateContentPartDone, - generateOutputItemDone, - generateResponseCompleted -} from '../../providers/openai/openai-responses-core.mjs'; - -/** - * OpenAI Responses API 转换器类 - * 支持 OpenAI Responses 格式与 OpenAI、Claude、Gemini 之间的转换 - */ -export class OpenAIResponsesConverter extends BaseConverter { - constructor() { - super(MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES); - this.codexConverter = new CodexConverter(); - } - - // ============================================================================= - // 请求转换 - // ============================================================================= - - /** - * 转换请求到目标协议 - */ - convertRequest(data, toProtocol) { - switch (toProtocol) { - case MODEL_PROTOCOL_PREFIX.OPENAI: - return this.toOpenAIRequest(data); - case MODEL_PROTOCOL_PREFIX.CLAUDE: - return this.toClaudeRequest(data); - case MODEL_PROTOCOL_PREFIX.GEMINI: - return this.toGeminiRequest(data); - case MODEL_PROTOCOL_PREFIX.CODEX: - return this.toCodexRequest(data); - case MODEL_PROTOCOL_PREFIX.GROK: - return this.toGrokRequest(data); - default: - throw new Error(`Unsupported target protocol: ${toProtocol}`); - } - } - - /** - * 转换响应到目标协议 - */ - convertResponse(data, toProtocol, model) { - switch (toProtocol) { - case MODEL_PROTOCOL_PREFIX.OPENAI: - return this.toOpenAIResponse(data, model); - case MODEL_PROTOCOL_PREFIX.CLAUDE: - return this.toClaudeResponse(data, model); - case MODEL_PROTOCOL_PREFIX.GEMINI: - return this.toGeminiResponse(data, model); - case MODEL_PROTOCOL_PREFIX.CODEX: - return this.toCodexResponse(data, model); - default: - throw new Error(`Unsupported target protocol: ${toProtocol}`); - } - } - - /** - * 转换流式响应块到目标协议 - */ - convertStreamChunk(chunk, toProtocol, model) { - switch (toProtocol) { - case MODEL_PROTOCOL_PREFIX.OPENAI: - return this.toOpenAIStreamChunk(chunk, model); - case MODEL_PROTOCOL_PREFIX.CLAUDE: - return this.toClaudeStreamChunk(chunk, model); - case MODEL_PROTOCOL_PREFIX.GEMINI: - return this.toGeminiStreamChunk(chunk, model); - case MODEL_PROTOCOL_PREFIX.CODEX: - return this.toCodexStreamChunk(chunk, model); - default: - throw new Error(`Unsupported target protocol: ${toProtocol}`); - } - } - - /** - * 转换模型列表到目标协议 - */ - convertModelList(data, targetProtocol) { - switch (targetProtocol) { - case MODEL_PROTOCOL_PREFIX.OPENAI: - return this.toOpenAIModelList(data); - case MODEL_PROTOCOL_PREFIX.CLAUDE: - return this.toClaudeModelList(data); - case MODEL_PROTOCOL_PREFIX.GEMINI: - return this.toGeminiModelList(data); - default: - return data; - } - } - - // ============================================================================= - // 转换到 OpenAI 格式 - // ============================================================================= - - /** - * 将 OpenAI Responses 请求转换为标准 OpenAI 请求 - */ - toOpenAIRequest(responsesRequest) { - const openaiRequest = { - model: responsesRequest.model, - messages: [], - stream: responsesRequest.stream || false - }; - - // 复制其他参数 - if (responsesRequest.temperature !== undefined) { - openaiRequest.temperature = responsesRequest.temperature; - } - if (responsesRequest.max_output_tokens !== undefined) { - openaiRequest.max_tokens = responsesRequest.max_output_tokens; - } else if (responsesRequest.max_tokens !== undefined) { - openaiRequest.max_tokens = responsesRequest.max_tokens; - } - if (responsesRequest.top_p !== undefined) { - openaiRequest.top_p = responsesRequest.top_p; - } - if (responsesRequest.parallel_tool_calls !== undefined) { - openaiRequest.parallel_tool_calls = responsesRequest.parallel_tool_calls; - } - - // OpenAI Responses API 使用 instructions 和 input 字段 - // 需要转换为标准的 messages 格式 - if (responsesRequest.instructions) { - // instructions 作为系统消息 - openaiRequest.messages.push({ - role: 'system', - content: responsesRequest.instructions - }); - } - - // input 包含用户消息和历史对话 - if (responsesRequest.input && Array.isArray(responsesRequest.input)) { - responsesRequest.input.forEach(item => { - const itemType = item.type || (item.role ? 'message' : ''); - - switch (itemType) { - case 'message': - // 提取消息内容 - let content = ''; - if (Array.isArray(item.content)) { - content = item.content - .filter(c => c.type === 'input_text' || c.type === 'output_text') - .map(c => c.text) - .join('\n'); - } else if (typeof item.content === 'string') { - content = item.content; - } - - if (content || (item.role === 'assistant' || item.role === 'developer')) { - openaiRequest.messages.push({ - role: item.role === 'developer' ? 'assistant' : item.role, - content: content - }); - } - break; - - case 'function_call': - openaiRequest.messages.push({ - role: 'assistant', - tool_calls: [{ - id: item.call_id, - type: 'function', - function: { - name: item.name, - arguments: typeof item.arguments === 'string' ? item.arguments : JSON.stringify(item.arguments) - } - }] - }); - break; - - case 'function_call_output': - openaiRequest.messages.push({ - role: 'tool', - tool_call_id: item.call_id, - content: item.output - }); - break; - } - }); - } - - // 如果有标准的 messages 字段,也支持 - if (responsesRequest.messages && Array.isArray(responsesRequest.messages)) { - responsesRequest.messages.forEach(msg => { - openaiRequest.messages.push({ - role: msg.role, - content: msg.content - }); - }); - } - - // 处理工具 - if (responsesRequest.tools && Array.isArray(responsesRequest.tools)) { - openaiRequest.tools = responsesRequest.tools - .map(tool => { - if (tool.type && tool.type !== 'function') { - return null; - } - - const name = tool.name || (tool.function && tool.function.name); - const description = tool.description || (tool.function && tool.function.description); - const parameters = tool.parameters || (tool.function && tool.function.parameters) || tool.parametersJsonSchema || { type: 'object', properties: {} }; - - // 如果没有名称,则该工具无效,稍后过滤掉 - if (!name) { - return null; - } - - return { - type: 'function', - function: { - name: name, - description: description, - parameters: parameters - } - }; - }) - .filter(tool => tool !== null); - } - - if (responsesRequest.tool_choice) { - openaiRequest.tool_choice = responsesRequest.tool_choice; - } - - return openaiRequest; - } - - /** - * 将 OpenAI Responses 响应转换为标准 OpenAI 响应 - */ - toOpenAIResponse(responsesResponse, model) { - const choices = []; - let usage = { - prompt_tokens: 0, - completion_tokens: 0, - total_tokens: 0, - prompt_tokens_details: { cached_tokens: 0 }, - completion_tokens_details: { reasoning_tokens: 0 } - }; - - if (responsesResponse.output && Array.isArray(responsesResponse.output)) { - responsesResponse.output.forEach((item, index) => { - if (item.type === 'message') { - const content = item.content - ?.filter(c => c.type === 'output_text') - .map(c => c.text) - .join('') || ''; - - choices.push({ - index: index, - message: { - role: 'assistant', - content: content - }, - finish_reason: responsesResponse.status === 'completed' ? 'stop' : null - }); - } else if (item.type === 'function_call') { - choices.push({ - index: index, - message: { - role: 'assistant', - tool_calls: [{ - id: item.call_id, - type: 'function', - function: { - name: item.name, - arguments: item.arguments - } - }] - }, - finish_reason: 'tool_calls' - }); - } - }); - } - - if (responsesResponse.usage) { - usage = { - prompt_tokens: responsesResponse.usage.input_tokens || 0, - completion_tokens: responsesResponse.usage.output_tokens || 0, - total_tokens: responsesResponse.usage.total_tokens || 0, - prompt_tokens_details: { - cached_tokens: responsesResponse.usage.input_tokens_details?.cached_tokens || 0 - }, - completion_tokens_details: { - reasoning_tokens: responsesResponse.usage.output_tokens_details?.reasoning_tokens || 0 - } - }; - } - - return { - id: responsesResponse.id || `chatcmpl-${Date.now()}`, - object: 'chat.completion', - created: responsesResponse.created_at || Math.floor(Date.now() / 1000), - model: model || responsesResponse.model, - choices: choices.length > 0 ? choices : [{ - index: 0, - message: { - role: 'assistant', - content: '' - }, - finish_reason: 'stop' - }], - usage: usage - }; - } - - /** - * 将 OpenAI Responses 流式块转换为标准 OpenAI 流式块 - */ - toOpenAIStreamChunk(responsesChunk, model) { - const resId = responsesChunk.response?.id || responsesChunk.id || `chatcmpl-${Date.now()}`; - const created = responsesChunk.response?.created_at || responsesChunk.created || Math.floor(Date.now() / 1000); - - const delta = {}; - let finish_reason = null; - - if (responsesChunk.type === 'response.output_text.delta') { - delta.content = responsesChunk.delta; - } else if (responsesChunk.type === 'response.function_call_arguments.delta') { - delta.tool_calls = [{ - index: responsesChunk.output_index || 0, - function: { - arguments: responsesChunk.delta - } - }]; - } else if (responsesChunk.type === 'response.output_item.added' && responsesChunk.item?.type === 'function_call') { - delta.tool_calls = [{ - index: responsesChunk.output_index || 0, - id: responsesChunk.item.call_id, - type: 'function', - function: { - name: responsesChunk.item.name, - arguments: '' - } - }]; - } else if (responsesChunk.type === 'response.completed') { - finish_reason = 'stop'; - } - - return { - id: resId, - object: 'chat.completion.chunk', - created: created, - model: model || responsesChunk.response?.model || responsesChunk.model, - choices: [{ - index: 0, - delta: delta, - finish_reason: finish_reason - }] - }; - } - - // ============================================================================= - // 转换到 Claude 格式 - // ============================================================================= - - /** - * 将 OpenAI Responses 请求转换为 Claude 请求 - */ - toClaudeRequest(responsesRequest) { - const claudeRequest = { - model: responsesRequest.model, - messages: [], - max_tokens: responsesRequest.max_output_tokens || responsesRequest.max_tokens || CLAUDE_DEFAULT_MAX_TOKENS, - stream: responsesRequest.stream || false - }; - - // 处理 instructions 作为系统消息 - if (responsesRequest.instructions) { - claudeRequest.system = responsesRequest.instructions; - } - - // 处理 reasoning effort - if (responsesRequest.reasoning?.effort) { - const effort = String(responsesRequest.reasoning.effort || '').toLowerCase().trim(); - let budgetTokens = 20000; - if (effort === 'low') budgetTokens = 2048; - else if (effort === 'medium') budgetTokens = 8192; - else if (effort === 'high') budgetTokens = 20000; - claudeRequest.thinking = { - type: 'enabled', - budget_tokens: budgetTokens - }; - } - - // 处理 input 数组中的消息 - if (responsesRequest.input && Array.isArray(responsesRequest.input)) { - responsesRequest.input.forEach(item => { - const itemType = item.type || (item.role ? 'message' : ''); - - switch (itemType) { - case 'message': - const content = []; - if (Array.isArray(item.content)) { - item.content.forEach(c => { - if (c.type === 'input_text' || c.type === 'output_text') { - content.push({ type: 'text', text: c.text }); - } else if (c.type === 'input_image') { - const url = c.image_url?.url || c.url; - if (url && url.startsWith('data:')) { - const [mediaInfo, data] = url.split(';base64,'); - const mediaType = mediaInfo.replace('data:', ''); - content.push({ - type: 'image', - source: { - type: 'base64', - media_type: mediaType, - data: data - } - }); - } - } - }); - } else if (typeof item.content === 'string') { - content.push({ type: 'text', text: item.content }); - } - - if (content.length > 0) { - claudeRequest.messages.push({ - role: item.role === 'assistant' ? 'assistant' : 'user', - content: content.length === 1 && content[0].type === 'text' ? content[0].text : content - }); - } - break; - - case 'function_call': - claudeRequest.messages.push({ - role: 'assistant', - content: [{ - type: 'tool_use', - id: item.call_id || `toolu_${uuidv4().replace(/-/g, '')}`, - name: item.name, - input: typeof item.arguments === 'string' ? JSON.parse(item.arguments) : item.arguments - }] - }); - break; - - case 'function_call_output': - claudeRequest.messages.push({ - role: 'user', - content: [{ - type: 'tool_result', - tool_use_id: item.call_id, - content: item.output - }] - }); - break; - } - }); - } - - // 处理工具 - if (responsesRequest.tools && Array.isArray(responsesRequest.tools)) { - claudeRequest.tools = responsesRequest.tools.map(tool => ({ - name: tool.name, - description: tool.description, - input_schema: tool.parameters || tool.parametersJsonSchema || { type: 'object', properties: {} } - })); - } - - if (responsesRequest.tool_choice) { - if (typeof responsesRequest.tool_choice === 'string') { - if (responsesRequest.tool_choice === 'auto') { - claudeRequest.tool_choice = { type: 'auto' }; - } else if (responsesRequest.tool_choice === 'required') { - claudeRequest.tool_choice = { type: 'any' }; - } - } else if (responsesRequest.tool_choice.type === 'function') { - claudeRequest.tool_choice = { - type: 'tool', - name: responsesRequest.tool_choice.function.name - }; - } - } - - return claudeRequest; - } - - /** - * 将 OpenAI Responses 响应转换为 Claude 响应 - */ - toClaudeResponse(responsesResponse, model) { - const content = []; - let stop_reason = 'end_turn'; - - if (responsesResponse.output && Array.isArray(responsesResponse.output)) { - responsesResponse.output.forEach(item => { - if (item.type === 'message') { - const text = item.content - ?.filter(c => c.type === 'output_text') - .map(c => c.text) - .join('') || ''; - if (text) { - content.push({ type: 'text', text: text }); - } - } else if (item.type === 'function_call') { - content.push({ - type: 'tool_use', - id: item.call_id, - name: item.name, - input: typeof item.arguments === 'string' ? JSON.parse(item.arguments) : item.arguments - }); - stop_reason = 'tool_use'; - } - }); - } - - return { - id: responsesResponse.id || `msg_${Date.now()}`, - type: 'message', - role: 'assistant', - content: content, - model: model || responsesResponse.model, - stop_reason: stop_reason, - usage: { - input_tokens: responsesResponse.usage?.input_tokens || 0, - output_tokens: responsesResponse.usage?.output_tokens || 0, - total_tokens: responsesResponse.usage?.total_tokens || 0 - } - }; - } - - /** - * 将 OpenAI Responses 流式块转换为 Claude 流式块 - */ - toClaudeStreamChunk(responsesChunk, model) { - if (responsesChunk.type === 'response.created') { - return { - type: 'message_start', - message: { - id: responsesChunk.response.id, - type: 'message', - role: 'assistant', - content: [], - model: model || responsesChunk.response.model - } - }; - } - - if (responsesChunk.type === 'response.output_text.delta') { - return { - type: 'content_block_delta', - index: 0, - delta: { - type: 'text_delta', - text: responsesChunk.delta - } - }; - } - - if (responsesChunk.type === 'response.function_call_arguments.delta') { - return { - type: 'content_block_delta', - index: responsesChunk.output_index || 0, - delta: { - type: 'input_json_delta', - partial_json: responsesChunk.delta - } - }; - } - - if (responsesChunk.type === 'response.output_item.added' && responsesChunk.item?.type === 'function_call') { - return { - type: 'content_block_start', - index: responsesChunk.output_index || 0, - content_block: { - type: 'tool_use', - id: responsesChunk.item.call_id, - name: responsesChunk.item.name, - input: {} - } - }; - } - - if (responsesChunk.type === 'response.completed') { - return { - type: 'message_stop' - }; - } - - return null; - } - - // ============================================================================= - // 转换到 Gemini 格式 - // ============================================================================= - - /** - * 将 OpenAI Responses 请求转换为 Gemini 请求 - */ - toGeminiRequest(responsesRequest) { - const geminiRequest = { - contents: [], - generationConfig: {} - }; - - // 处理 instructions 作为系统指令 - if (responsesRequest.instructions) { - geminiRequest.systemInstruction = { - parts: [{ - text: responsesRequest.instructions - }] - }; - } - - // 处理 input 数组中的消息 - if (responsesRequest.input && Array.isArray(responsesRequest.input)) { - responsesRequest.input.forEach(item => { - const itemType = item.type || (item.role ? 'message' : ''); - - switch (itemType) { - case 'message': - const parts = []; - if (Array.isArray(item.content)) { - item.content.forEach(c => { - if (c.type === 'input_text' || c.type === 'output_text') { - parts.push({ text: c.text }); - } else if (c.type === 'input_image') { - const url = c.image_url?.url || c.url; - if (url && url.startsWith('data:')) { - const [mediaInfo, data] = url.split(';base64,'); - const mimeType = mediaInfo.replace('data:', ''); - parts.push({ - inlineData: { - mimeType: mimeType, - data: data - } - }); - } - } - }); - } else if (typeof item.content === 'string') { - parts.push({ text: item.content }); - } - - if (parts.length > 0) { - geminiRequest.contents.push({ - role: item.role === 'assistant' ? 'model' : 'user', - parts: parts - }); - } - break; - - case 'function_call': - geminiRequest.contents.push({ - role: 'model', - parts: [{ - functionCall: { - name: item.name, - args: typeof item.arguments === 'string' ? JSON.parse(item.arguments) : item.arguments - } - }] - }); - break; - - case 'function_call_output': - geminiRequest.contents.push({ - role: 'user', // Gemini function response role is user or tool? usually user/model - parts: [{ - functionResponse: { - name: item.name, - response: { content: item.output } - } - }] - }); - break; - } - }); - } - - // 设置生成配置 - if (responsesRequest.temperature !== undefined) { - geminiRequest.generationConfig.temperature = responsesRequest.temperature; - } - if (responsesRequest.max_output_tokens !== undefined) { - geminiRequest.generationConfig.maxOutputTokens = responsesRequest.max_output_tokens; - } else if (responsesRequest.max_tokens !== undefined) { - geminiRequest.generationConfig.maxOutputTokens = responsesRequest.max_tokens; - } - if (responsesRequest.top_p !== undefined) { - geminiRequest.generationConfig.topP = responsesRequest.top_p; - } - - // 处理工具 - if (responsesRequest.tools && Array.isArray(responsesRequest.tools)) { - geminiRequest.tools = [{ - functionDeclarations: responsesRequest.tools - .filter(tool => !tool.type || tool.type === 'function') - .map(tool => ({ - name: tool.name, - description: tool.description, - parameters: tool.parameters || tool.parametersJsonSchema || { type: 'object', properties: {} } - })) - }]; - } - - return geminiRequest; - } - - /** - * 将 OpenAI Responses 响应转换为 Gemini 响应 - */ - toGeminiResponse(responsesResponse, model) { - const parts = []; - let finishReason = 'STOP'; - - if (responsesResponse.output && Array.isArray(responsesResponse.output)) { - responsesResponse.output.forEach(item => { - if (item.type === 'message') { - const text = item.content - ?.filter(c => c.type === 'output_text') - .map(c => c.text) - .join('') || ''; - if (text) { - parts.push({ text: text }); - } - } else if (item.type === 'function_call') { - parts.push({ - functionCall: { - name: item.name, - args: typeof item.arguments === 'string' ? JSON.parse(item.arguments) : item.arguments - } - }); - } - }); - } - - return { - candidates: [{ - content: { - parts: parts, - role: 'model' - }, - finishReason: finishReason, - index: 0 - }], - usageMetadata: { - promptTokenCount: responsesResponse.usage?.input_tokens || 0, - candidatesTokenCount: responsesResponse.usage?.output_tokens || 0, - totalTokenCount: responsesResponse.usage?.total_tokens || 0 - } - }; - } - - /** - * 将 OpenAI Responses 流式块转换为 Gemini 流式块 - */ - toGeminiStreamChunk(responsesChunk, model) { - if (responsesChunk.type === 'response.output_text.delta') { - return { - candidates: [{ - content: { - parts: [{ text: responsesChunk.delta }], - role: 'model' - }, - index: 0 - }] - }; - } - - if (responsesChunk.type === 'response.function_call_arguments.delta') { - // Gemini 不太支持流式 functionCall 参数,这里只能简单映射 - return { - candidates: [{ - content: { - parts: [{ - functionCall: { - name: '', // 无法在 delta 中获取名称 - args: responsesChunk.delta - } - }], - role: 'model' - }, - index: 0 - }] - }; - } - - return null; - } - - /** - * OpenAI Responses → Codex 请求转换 - */ - toCodexRequest(responsesRequest) { - return this.codexConverter.toOpenAIResponsesToCodexRequest(responsesRequest); - } - - /** - * OpenAI Responses → Grok 请求转换 - */ - toGrokRequest(responsesRequest) { - // 先转换为 OpenAI 格式 - const openaiRequest = this.toOpenAIRequest(responsesRequest); - return { - ...openaiRequest, - _isConverted: true - }; - } - - // ============================================================================= - // 辅助方法 - // ============================================================================= - - /** - * 映射完成原因 - */ - mapFinishReason(reason) { - const reasonMap = { - 'stop': 'STOP', - 'length': 'MAX_TOKENS', - 'content_filter': 'SAFETY', - 'end_turn': 'STOP' - }; - return reasonMap[reason] || 'STOP'; - } - - /** - * 将 OpenAI Responses 模型列表转换为标准 OpenAI 模型列表 - */ - toOpenAIModelList(responsesModels) { - // OpenAI Responses 格式的模型列表已经是标准 OpenAI 格式 - // 如果输入已经是标准格式,直接返回 - if (responsesModels.object === 'list' && responsesModels.data) { - return responsesModels; - } - - // 如果是其他格式,转换为标准格式 - return { - object: "list", - data: (responsesModels.models || responsesModels.data || []).map(m => ({ - id: m.id || m.name, - object: "model", - created: m.created || Math.floor(Date.now() / 1000), - owned_by: m.owned_by || "openai", - })), - }; - } - - /** - * 将 OpenAI Responses 模型列表转换为 Claude 模型列表 - */ - toClaudeModelList(responsesModels) { - const models = responsesModels.data || responsesModels.models || []; - return { - models: models.map(m => ({ - name: m.id || m.name, - description: m.description || "", - })), - }; - } - - /** - * 将 OpenAI Responses 模型列表转换为 Gemini 模型列表 - */ - toGeminiModelList(responsesModels) { - const models = responsesModels.data || responsesModels.models || []; - return { - models: models.map(m => ({ - name: `models/${m.id || m.name}`, - version: m.version || "1.0.0", - displayName: m.displayName || m.id || m.name, - description: m.description || `A generative model for text and chat generation. ID: ${m.id || m.name}`, - inputTokenLimit: m.inputTokenLimit || GEMINI_DEFAULT_INPUT_TOKEN_LIMIT, - outputTokenLimit: m.outputTokenLimit || GEMINI_DEFAULT_OUTPUT_TOKEN_LIMIT, - supportedGenerationMethods: m.supportedGenerationMethods || ["generateContent", "streamGenerateContent"] - })) - }; - } - - - /** - * OpenAI Responses → Codex 响应转换 (实际上是 Codex 转 OpenAI Responses) - */ - toCodexResponse(codexResponse, model) { - const output = []; - const responseData = codexResponse.response || codexResponse; - - if (responseData.output && Array.isArray(responseData.output)) { - responseData.output.forEach(item => { - if (item.type === 'message' && item.content) { - const content = item.content.map(c => ({ - type: c.type === 'output_text' ? 'output_text' : 'input_text', - text: c.text, - annotations: [] - })); - output.push({ - id: item.id || `msg_${uuidv4().replace(/-/g, '')}`, - type: "message", - role: item.role || "assistant", - status: item.status || "completed", - content: content - }); - } else if (item.type === 'reasoning') { - output.push({ - id: item.id || `rs_${uuidv4().replace(/-/g, '')}`, - type: "reasoning", - status: item.status || "completed", - summary: item.summary || [] - }); - } else if (item.type === 'function_call') { - output.push({ - id: item.id || `fc_${uuidv4().replace(/-/g, '')}`, - call_id: item.call_id, - type: "function_call", - name: item.name, - arguments: typeof item.arguments === 'string' ? item.arguments : JSON.stringify(item.arguments), - status: item.status || "completed" - }); - } - }); - } - - return { - id: responseData.id || `resp_${uuidv4().replace(/-/g, '')}`, - object: "response", - created_at: responseData.created_at || Math.floor(Date.now() / 1000), - model: model || responseData.model, - status: responseData.status || "completed", - output: output, - usage: { - input_tokens: responseData.usage?.input_tokens || 0, - output_tokens: responseData.usage?.output_tokens || 0, - total_tokens: responseData.usage?.total_tokens || 0 - } - }; - } - - /** - * OpenAI Responses → Codex 流式响应转换 (实际上是 Codex 转 OpenAI Responses) - */ - toCodexStreamChunk(codexChunk, model) { - const type = codexChunk.type; - const resId = codexChunk.response?.id || 'default'; - const events = []; - - if (type === 'response.created') { - events.push( - generateResponseCreated(resId, model || codexChunk.response?.model), - generateResponseInProgress(resId) - ); - return events; - } - - if (type === 'response.reasoning_summary_text.delta') { - events.push({ - type: "response.reasoning_summary_text.delta", - response_id: resId, - item_id: codexChunk.item_id, - output_index: codexChunk.output_index, - summary_index: codexChunk.summary_index, - delta: codexChunk.delta - }); - return events; - } - - if (type === 'response.output_text.delta') { - events.push({ - type: "response.output_text.delta", - response_id: resId, - item_id: codexChunk.item_id, - output_index: codexChunk.output_index, - content_index: codexChunk.content_index, - delta: codexChunk.delta - }); - return events; - } - - if (type === 'response.function_call_arguments.delta') { - events.push({ - type: "response.function_call_arguments.delta", - response_id: resId, - item_id: codexChunk.item_id, - output_index: codexChunk.output_index, - delta: codexChunk.delta - }); - return events; - } - - if (type === 'response.output_item.added') { - events.push({ - type: "response.output_item.added", - response_id: resId, - output_index: codexChunk.output_index, - item: codexChunk.item - }); - return events; - } - - if (type === 'response.completed') { - const completedEvent = generateResponseCompleted(resId); - completedEvent.response = { - ...completedEvent.response, - ...codexChunk.response - }; - events.push(completedEvent); - return events; - } - - // 透传其他 response.* 事件 - if (type && type.startsWith('response.')) { - return [codexChunk]; - } - - return null; - } - -} diff --git a/src/converters/utils.js b/src/converters/utils.js deleted file mode 100644 index 1b5933cab28936598101da87ba92029ce5bb3253..0000000000000000000000000000000000000000 --- a/src/converters/utils.js +++ /dev/null @@ -1,369 +0,0 @@ -/** - * 转换器公共工具函数模块 - * 提供各种协议转换所需的通用辅助函数 - */ - -import { v4 as uuidv4 } from 'uuid'; -import logger from '../utils/logger.js'; - -// ============================================================================= -// 常量定义 -// ============================================================================= - -// 通用默认值 -export const DEFAULT_MAX_TOKENS = 8192; -export const DEFAULT_TEMPERATURE = 1; -export const DEFAULT_TOP_P = 0.95; - -// ============================================================================= -// OpenAI 相关常量 -// ============================================================================= -export const OPENAI_DEFAULT_MAX_TOKENS = 128000; -export const OPENAI_DEFAULT_TEMPERATURE = 1; -export const OPENAI_DEFAULT_TOP_P = 0.95; -export const OPENAI_DEFAULT_INPUT_TOKEN_LIMIT = 32768; -export const OPENAI_DEFAULT_OUTPUT_TOKEN_LIMIT = 128000; - -// ============================================================================= -// Claude 相关常量 -// ============================================================================= -export const CLAUDE_DEFAULT_MAX_TOKENS = 200000; -export const CLAUDE_DEFAULT_TEMPERATURE = 1; -export const CLAUDE_DEFAULT_TOP_P = 0.95; - -// ============================================================================= -// Gemini 相关常量 -// ============================================================================= -export const GEMINI_DEFAULT_MAX_TOKENS = 65534; -export const GEMINI_DEFAULT_TEMPERATURE = 1; -export const GEMINI_DEFAULT_TOP_P = 0.95; -export const GEMINI_DEFAULT_INPUT_TOKEN_LIMIT = 32768; -export const GEMINI_DEFAULT_OUTPUT_TOKEN_LIMIT = 65534; - -// ============================================================================= -// OpenAI Responses 相关常量 -// ============================================================================= -export const OPENAI_RESPONSES_DEFAULT_MAX_TOKENS = 128000; -export const OPENAI_RESPONSES_DEFAULT_TEMPERATURE = 1; -export const OPENAI_RESPONSES_DEFAULT_TOP_P = 0.95; -export const OPENAI_RESPONSES_DEFAULT_INPUT_TOKEN_LIMIT = 32768; -export const OPENAI_RESPONSES_DEFAULT_OUTPUT_TOKEN_LIMIT = 128000; - -// ============================================================================= -// 通用辅助函数 -// ============================================================================= - -/** - * 判断值是否为 undefined 或 0,并返回默认值 - * @param {*} value - 要检查的值 - * @param {*} defaultValue - 默认值 - * @returns {*} 处理后的值 - */ -export function checkAndAssignOrDefault(value, defaultValue) { - if (value !== undefined && value !== 0) { - return value; - } - return defaultValue; -} - -/** - * 生成唯一ID - * @param {string} prefix - ID前缀 - * @returns {string} 生成的ID - */ -export function generateId(prefix = '') { - return prefix ? `${prefix}_${uuidv4()}` : uuidv4(); -} - -/** - * 安全解析JSON字符串 - * @param {string} str - JSON字符串 - * @returns {*} 解析后的对象或原始字符串 - */ -export function safeParseJSON(str) { - if (!str) { - return str; - } - let cleanedStr = str; - - // 处理可能被截断的转义序列 - if (cleanedStr.endsWith('\\') && !cleanedStr.endsWith('\\\\')) { - cleanedStr = cleanedStr.substring(0, cleanedStr.length - 1); - } else if (cleanedStr.endsWith('\\u') || cleanedStr.endsWith('\\u0') || cleanedStr.endsWith('\\u00')) { - const idx = cleanedStr.lastIndexOf('\\u'); - cleanedStr = cleanedStr.substring(0, idx); - } - - try { - return JSON.parse(cleanedStr || '{}'); - } catch (e) { - return str; - } -} - -/** - * 提取消息内容中的文本 - * @param {string|Array} content - 消息内容 - * @returns {string} 提取的文本 - */ -export function extractTextFromMessageContent(content) { - if (typeof content === 'string') { - return content; - } - if (Array.isArray(content)) { - return content - .filter(part => part.type === 'text' && part.text) - .map(part => part.text) - .join('\n'); - } - return ''; -} - -/** - * 提取并处理系统消息 - * @param {Array} messages - 消息数组 - * @returns {{systemInstruction: Object|null, nonSystemMessages: Array}} - */ -export function extractAndProcessSystemMessages(messages) { - const systemContents = []; - const nonSystemMessages = []; - - for (const message of messages) { - if (message.role === 'system') { - systemContents.push(extractTextFromMessageContent(message.content)); - } else { - nonSystemMessages.push(message); - } - } - - let systemInstruction = null; - if (systemContents.length > 0) { - systemInstruction = { - parts: [{ - text: systemContents.join('\n') - }] - }; - } - return { systemInstruction, nonSystemMessages }; -} - -/** - * 清理JSON Schema属性(移除Gemini不支持的属性) - * Google Gemini API 只支持有限的 JSON Schema 属性,不支持以下属性: - * - exclusiveMinimum, exclusiveMaximum, minimum, maximum - * - minLength, maxLength, minItems, maxItems - * - pattern, format, default, const - * - additionalProperties, $schema, $ref, $id - * - allOf, anyOf, oneOf, not - * @param {Object} schema - JSON Schema - * @returns {Object} 清理后的JSON Schema - */ -export function cleanJsonSchemaProperties(schema) { - if (!schema || typeof schema !== 'object') { - return schema; - } - - // 如果是数组,递归处理每个元素 - if (Array.isArray(schema)) { - return schema.map(item => cleanJsonSchemaProperties(item)); - } - - // Gemini 支持的 JSON Schema 属性白名单 - const allowedKeys = [ - "type", - "description", - "properties", - "required", - "enum", - "items", - "nullable" - ]; - - const sanitized = {}; - for (const [key, value] of Object.entries(schema)) { - if (allowedKeys.includes(key)) { - // 对于需要递归处理的属性 - if (key === 'properties' && typeof value === 'object' && value !== null) { - const cleanProperties = {}; - for (const [propName, propSchema] of Object.entries(value)) { - cleanProperties[propName] = cleanJsonSchemaProperties(propSchema); - } - sanitized[key] = cleanProperties; - } else if (key === 'items') { - sanitized[key] = cleanJsonSchemaProperties(value); - } else if (key === 'type') { - // Google Gemini API 不支持数组形式的 type (如 ["string", "null"]) - // 必须是单个字符串,且通常需要大写 (STRING, NUMBER, OBJECT, ARRAY, BOOLEAN, INTEGER) - if (Array.isArray(value)) { - // 如果包含 null,设置 nullable 为 true - if (value.includes('null')) { - sanitized.nullable = true; - } - // 取第一个非 null 类型 - const actualType = value.find(t => t !== 'null'); - if (actualType) { - sanitized[key] = actualType.toUpperCase(); - } - } else if (typeof value === 'string') { - sanitized[key] = value.toUpperCase(); - } - } else { - sanitized[key] = value; - } - } - // 其他属性(如 exclusiveMinimum, minimum, maximum, pattern 等)被忽略 - } - - return sanitized; -} - -/** - * 映射结束原因 - * @param {string} reason - 结束原因 - * @param {string} sourceFormat - 源格式 - * @param {string} targetFormat - 目标格式 - * @returns {string} 映射后的结束原因 - */ -export function mapFinishReason(reason, sourceFormat, targetFormat) { - const reasonMappings = { - openai: { - anthropic: { - stop: "end_turn", - length: "max_tokens", - content_filter: "stop_sequence", - tool_calls: "tool_use" - } - }, - gemini: { - anthropic: { - STOP: "end_turn", - MAX_TOKENS: "max_tokens", - SAFETY: "stop_sequence", - RECITATION: "stop_sequence", - stop: "end_turn", - length: "max_tokens", - safety: "stop_sequence", - recitation: "stop_sequence", - other: "end_turn" - } - } - }; - - try { - return reasonMappings[sourceFormat][targetFormat][reason] || "end_turn"; - } catch (e) { - return "end_turn"; - } -} - -/** - * 根据budget_tokens智能判断OpenAI reasoning_effort等级 - * @param {number|null} budgetTokens - Anthropic thinking的budget_tokens值 - * @returns {string} OpenAI reasoning_effort等级 - */ -export function determineReasoningEffortFromBudget(budgetTokens) { - if (budgetTokens === null || budgetTokens === undefined) { - logger.info("No budget_tokens provided, defaulting to reasoning_effort='high'"); - return "high"; - } - - const LOW_THRESHOLD = 50; - const HIGH_THRESHOLD = 200; - - logger.debug(`Threshold configuration: low <= ${LOW_THRESHOLD}, medium <= ${HIGH_THRESHOLD}, high > ${HIGH_THRESHOLD}`); - - let effort; - if (budgetTokens <= LOW_THRESHOLD) { - effort = "low"; - } else if (budgetTokens <= HIGH_THRESHOLD) { - effort = "medium"; - } else { - effort = "high"; - } - - logger.info(`🎯 Budget tokens ${budgetTokens} -> reasoning_effort '${effort}' (thresholds: low<=${LOW_THRESHOLD}, high<=${HIGH_THRESHOLD})`); - return effort; -} - -/** - * 从OpenAI文本中提取thinking内容 - * @param {string} text - 文本内容 - * @returns {string|Array} 提取后的内容 - */ -export function extractThinkingFromOpenAIText(text) { - const thinkingPattern = /\s*(.*?)\s*<\/thinking>/gs; - const matches = [...text.matchAll(thinkingPattern)]; - - const contentBlocks = []; - let lastEnd = 0; - - for (const match of matches) { - const beforeText = text.substring(lastEnd, match.index).trim(); - if (beforeText) { - contentBlocks.push({ - type: "text", - text: beforeText - }); - } - - const thinkingText = match[1].trim(); - if (thinkingText) { - contentBlocks.push({ - type: "thinking", - thinking: thinkingText - }); - } - - lastEnd = match.index + match[0].length; - } - - const afterText = text.substring(lastEnd).trim(); - if (afterText) { - contentBlocks.push({ - type: "text", - text: afterText - }); - } - - if (contentBlocks.length === 0) { - return text; - } - - if (contentBlocks.length === 1 && contentBlocks[0].type === "text") { - return contentBlocks[0].text; - } - - return contentBlocks; -} - -// ============================================================================= -// 工具状态管理器(单例模式) -// ============================================================================= - -/** - * 全局工具状态管理器 - */ -class ToolStateManager { - constructor() { - if (ToolStateManager.instance) { - return ToolStateManager.instance; - } - ToolStateManager.instance = this; - this._toolMappings = {}; - return this; - } - - storeToolMapping(funcName, toolId) { - this._toolMappings[funcName] = toolId; - } - - getToolId(funcName) { - return this._toolMappings[funcName] || null; - } - - clearMappings() { - this._toolMappings = {}; - } -} - -export const toolStateManager = new ToolStateManager(); \ No newline at end of file diff --git a/src/core/config-manager.js b/src/core/config-manager.js deleted file mode 100644 index 364dd99f1b5d03947e01cc3f5610d983d30b58c3..0000000000000000000000000000000000000000 --- a/src/core/config-manager.js +++ /dev/null @@ -1,249 +0,0 @@ -import * as fs from 'fs'; -import { promises as pfs } from 'fs'; -import { INPUT_SYSTEM_PROMPT_FILE, MODEL_PROVIDER } from '../utils/common.js'; -import logger from '../utils/logger.js'; - -export let CONFIG = {}; // Make CONFIG exportable -export let PROMPT_LOG_FILENAME = ''; // Make PROMPT_LOG_FILENAME exportable - -const ALL_MODEL_PROVIDERS = Object.values(MODEL_PROVIDER); - -function normalizeConfiguredProviders(config) { - const fallbackProvider = MODEL_PROVIDER.GEMINI_CLI; - const dedupedProviders = []; - - const addProvider = (value) => { - if (typeof value !== 'string') { - return; - } - const trimmed = value.trim(); - if (!trimmed) { - return; - } - const matched = ALL_MODEL_PROVIDERS.find((provider) => provider.toLowerCase() === trimmed.toLowerCase()); - if (!matched) { - logger.warn(`[Config Warning] Unknown model provider '${trimmed}'. This entry will be ignored.`); - return; - } - if (!dedupedProviders.includes(matched)) { - dedupedProviders.push(matched); - } - }; - - const rawValue = config.MODEL_PROVIDER; - if (Array.isArray(rawValue)) { - rawValue.forEach((entry) => addProvider(typeof entry === 'string' ? entry : String(entry))); - } else if (typeof rawValue === 'string') { - rawValue.split(',').forEach(addProvider); - } else if (rawValue != null) { - addProvider(String(rawValue)); - } - - if (dedupedProviders.length === 0) { - dedupedProviders.push(fallbackProvider); - } - - config.DEFAULT_MODEL_PROVIDERS = dedupedProviders; - config.MODEL_PROVIDER = dedupedProviders[0]; -} - -/** - * Initializes the server configuration from config.json and command-line arguments. - * @param {string[]} args - Command-line arguments. - * @param {string} [configFilePath='configs/config.json'] - Path to the configuration file. - * @returns {Object} The initialized configuration object. - */ -export async function initializeConfig(args = process.argv.slice(2), configFilePath = 'configs/config.json') { - const defaultConfig = { - REQUIRED_API_KEY: "123456", - SERVER_PORT: 3000, - HOST: '0.0.0.0', - MODEL_PROVIDER: MODEL_PROVIDER.GEMINI_CLI, - SYSTEM_PROMPT_FILE_PATH: INPUT_SYSTEM_PROMPT_FILE, // Default value - SYSTEM_PROMPT_MODE: 'append', - PROXY_URL: null, // HTTP/HTTPS/SOCKS5 代理地址,如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080 - PROXY_ENABLED_PROVIDERS: [], // 启用代理的提供商列表,如 ['gemini-cli-oauth', 'claude-kiro-oauth'] - PROMPT_LOG_BASE_NAME: "prompt_log", - PROMPT_LOG_MODE: "none", - REQUEST_MAX_RETRIES: 3, - REQUEST_BASE_DELAY: 1000, - CREDENTIAL_SWITCH_MAX_RETRIES: 5, // 坏凭证切换最大重试次数(用于认证错误后切换凭证) - CRON_NEAR_MINUTES: 15, - CRON_REFRESH_TOKEN: false, - LOGIN_EXPIRY: 3600, // 登录过期时间(秒),默认1小时 - LOGIN_MAX_ATTEMPTS: 5, // 最大失败重试次数 - LOGIN_LOCKOUT_DURATION: 1800, // 锁定持续时间(秒),默认30分钟 - LOGIN_MIN_INTERVAL: 5000, // 两次尝试之间的最小间隔(毫秒),默认1秒 - PROVIDER_POOLS_FILE_PATH: null, // 新增号池配置文件路径 - MAX_ERROR_COUNT: 10, // 提供商最大错误次数 - providerFallbackChain: {}, // 跨类型 Fallback 链配置 - LOG_ENABLED: true, - LOG_OUTPUT_MODE: "all", - LOG_LEVEL: "info", - LOG_DIR: "logs", - LOG_INCLUDE_REQUEST_ID: true, - LOG_INCLUDE_TIMESTAMP: true, - LOG_MAX_FILE_SIZE: 10485760, - LOG_MAX_FILES: 10, - TLS_SIDECAR_ENABLED: false, // 启用 Go uTLS sidecar(需要编译 tls-sidecar 二进制) - TLS_SIDECAR_ENABLED_PROVIDERS: [], // 启用 TLS Sidecar 的提供商列表 - TLS_SIDECAR_PORT: 9090, // sidecar 监听端口 - TLS_SIDECAR_BINARY_PATH: null, // 自定义二进制路径(默认自动搜索) - TLS_SIDECAR_PROXY_URL: null // TLS Sidecar 专用的上游代理地址 - }; - - let currentConfig = { ...defaultConfig }; - - try { - const configData = fs.readFileSync(configFilePath, 'utf8'); - const loadedConfig = JSON.parse(configData); - Object.assign(currentConfig, loadedConfig); - logger.info('[Config] Loaded configuration from configs/config.json'); - } catch (error) { - if (error.code !== 'ENOENT') { - logger.error('[Config Error] Failed to load configs/config.json:', error.message); - } else { - logger.info('[Config] configs/config.json not found, using default configuration.'); - } - } - - - // CLI argument definitions: { flag, configKey, type, validValues? } - // type: 'string' | 'int' | 'bool' | 'enum' - const cliArgDefs = [ - { flag: '--api-key', configKey: 'REQUIRED_API_KEY', type: 'string' }, - { flag: '--log-prompts', configKey: 'PROMPT_LOG_MODE', type: 'enum', validValues: ['console', 'file'] }, - { flag: '--port', configKey: 'SERVER_PORT', type: 'int' }, - { flag: '--model-provider', configKey: 'MODEL_PROVIDER', type: 'string' }, - { flag: '--system-prompt-file', configKey: 'SYSTEM_PROMPT_FILE_PATH', type: 'string' }, - { flag: '--system-prompt-mode', configKey: 'SYSTEM_PROMPT_MODE', type: 'enum', validValues: ['overwrite', 'append'] }, - { flag: '--host', configKey: 'HOST', type: 'string' }, - { flag: '--prompt-log-base-name', configKey: 'PROMPT_LOG_BASE_NAME', type: 'string' }, - { flag: '--cron-near-minutes', configKey: 'CRON_NEAR_MINUTES', type: 'int' }, - { flag: '--cron-refresh-token', configKey: 'CRON_REFRESH_TOKEN', type: 'bool' }, - { flag: '--provider-pools-file', configKey: 'PROVIDER_POOLS_FILE_PATH', type: 'string' }, - { flag: '--max-error-count', configKey: 'MAX_ERROR_COUNT', type: 'int' }, - { flag: '--login-max-attempts', configKey: 'LOGIN_MAX_ATTEMPTS', type: 'int' }, - { flag: '--login-lockout-duration', configKey: 'LOGIN_LOCKOUT_DURATION', type: 'int' }, - { flag: '--login-min-interval', configKey: 'LOGIN_MIN_INTERVAL', type: 'int' }, - ]; - - // Parse command-line arguments using definitions - const flagMap = new Map(cliArgDefs.map(def => [def.flag, def])); - for (let i = 0; i < args.length; i++) { - const def = flagMap.get(args[i]); - if (!def) continue; - - if (i + 1 >= args.length) { - logger.warn(`[Config Warning] ${def.flag} flag requires a value.`); - continue; - } - - const rawValue = args[++i]; - switch (def.type) { - case 'string': - currentConfig[def.configKey] = rawValue; - break; - case 'int': - currentConfig[def.configKey] = parseInt(rawValue, 10); - break; - case 'bool': - currentConfig[def.configKey] = rawValue.toLowerCase() === 'true'; - break; - case 'enum': - if (def.validValues.includes(rawValue)) { - currentConfig[def.configKey] = rawValue; - } else { - logger.warn(`[Config Warning] Invalid value for ${def.flag}. Expected one of: ${def.validValues.join(', ')}.`); - } - break; - } - } - - normalizeConfiguredProviders(currentConfig); - - if (!currentConfig.SYSTEM_PROMPT_FILE_PATH) { - currentConfig.SYSTEM_PROMPT_FILE_PATH = INPUT_SYSTEM_PROMPT_FILE; - } - currentConfig.SYSTEM_PROMPT_CONTENT = await getSystemPromptFileContent(currentConfig.SYSTEM_PROMPT_FILE_PATH); - - // 加载号池配置 - if (!currentConfig.PROVIDER_POOLS_FILE_PATH) { - currentConfig.PROVIDER_POOLS_FILE_PATH = 'configs/provider_pools.json'; - } - if (currentConfig.PROVIDER_POOLS_FILE_PATH) { - try { - const poolsData = await pfs.readFile(currentConfig.PROVIDER_POOLS_FILE_PATH, 'utf8'); - currentConfig.providerPools = JSON.parse(poolsData); - logger.info(`[Config] Loaded provider pools from ${currentConfig.PROVIDER_POOLS_FILE_PATH}`); - } catch (error) { - logger.error(`[Config Error] Failed to load provider pools from ${currentConfig.PROVIDER_POOLS_FILE_PATH}: ${error.message}`); - currentConfig.providerPools = {}; - } - } else { - currentConfig.providerPools = {}; - } - - // Set PROMPT_LOG_FILENAME based on the determined config - if (currentConfig.PROMPT_LOG_MODE === 'file') { - const now = new Date(); - const pad = (num) => String(num).padStart(2, '0'); - const timestamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`; - PROMPT_LOG_FILENAME = `${currentConfig.PROMPT_LOG_BASE_NAME}-${timestamp}.log`; - } else { - PROMPT_LOG_FILENAME = ''; // Clear if not logging to file - } - - // Assign to the exported CONFIG - Object.assign(CONFIG, currentConfig); - - // Initialize logger - logger.initialize({ - enabled: CONFIG.LOG_ENABLED ?? true, - outputMode: CONFIG.LOG_OUTPUT_MODE || "all", - logLevel: CONFIG.LOG_LEVEL || "info", - logDir: CONFIG.LOG_DIR || "logs", - includeRequestId: CONFIG.LOG_INCLUDE_REQUEST_ID ?? true, - includeTimestamp: CONFIG.LOG_INCLUDE_TIMESTAMP ?? true, - maxFileSize: CONFIG.LOG_MAX_FILE_SIZE || 10485760, - maxFiles: CONFIG.LOG_MAX_FILES || 10 - }); - - // Cleanup old logs periodically - logger.cleanupOldLogs(); - - return CONFIG; -} - -/** - * Gets system prompt content from the specified file path. - * @param {string} filePath - Path to the system prompt file. - * @returns {Promise} File content, or null if the file does not exist, is empty, or an error occurs. - */ -export async function getSystemPromptFileContent(filePath) { - try { - await pfs.access(filePath, pfs.constants.F_OK); - } catch (error) { - if (error.code === 'ENOENT') { - logger.warn(`[System Prompt] Specified system prompt file not found: ${filePath}`); - } else { - logger.error(`[System Prompt] Error accessing system prompt file ${filePath}: ${error.message}`); - } - return null; - } - - try { - const content = await pfs.readFile(filePath, 'utf8'); - if (!content.trim()) { - return null; - } - logger.info(`[System Prompt] Loaded system prompt from ${filePath}`); - return content; - } catch (error) { - logger.error(`[System Prompt] Error reading system prompt file ${filePath}: ${error.message}`); - return null; - } -} - -export { ALL_MODEL_PROVIDERS }; - diff --git a/src/core/master.js b/src/core/master.js deleted file mode 100644 index 8cf208a8b3614dcea1ef61b742ac3cd4848a81f1..0000000000000000000000000000000000000000 --- a/src/core/master.js +++ /dev/null @@ -1,395 +0,0 @@ -/** - * 主进程 (Master Process) - * - * 负责管理子进程的生命周期,包括: - * - 启动子进程 - * - 监控子进程状态 - * - 处理子进程重启请求 - * - 提供 IPC 通信 - * - * 使用方式: - * node src/core/master.js [原有的命令行参数] - */ - -import { fork } from 'child_process'; -import logger from '../utils/logger.js'; -import * as http from 'http'; -import * as path from 'path'; -import { fileURLToPath } from 'url'; -import { isRetryableNetworkError } from '../utils/common.js'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -// 子进程实例 -let workerProcess = null; - -// 子进程状态 -let workerStatus = { - pid: null, - startTime: null, - restartCount: 0, - lastRestartTime: null, - isRestarting: false -}; - -// 配置 -const config = { - workerScript: path.join(__dirname, '../services/api-server.js'), - maxRestartAttempts: 10, - restartDelay: 1000, // 重启延迟(毫秒) - masterPort: parseInt(process.env.MASTER_PORT) || 3100, // 主进程管理端口 - args: process.argv.slice(2) // 传递给子进程的参数 -}; - -/** - * 启动子进程 - */ -function startWorker() { - if (workerProcess) { - logger.info('[Master] Worker process already running, PID:', workerProcess.pid); - return; - } - - logger.info('[Master] Starting worker process...'); - logger.info('[Master] Worker script:', config.workerScript); - logger.info('[Master] Worker args:', config.args.join(' ')); - - workerProcess = fork(config.workerScript, config.args, { - stdio: ['inherit', 'inherit', 'inherit', 'ipc'], - env: { - ...process.env, - IS_WORKER_PROCESS: 'true' - } - }); - - workerStatus.pid = workerProcess.pid; - workerStatus.startTime = new Date().toISOString(); - - logger.info('[Master] Worker process started, PID:', workerProcess.pid); - - // 监听子进程消息 - workerProcess.on('message', (message) => { - logger.info('[Master] Received message from worker:', message); - handleWorkerMessage(message); - }); - - // 监听子进程退出 - workerProcess.on('exit', (code, signal) => { - logger.info(`[Master] Worker process exited with code ${code}, signal ${signal}`); - workerProcess = null; - workerStatus.pid = null; - - // 如果不是主动重启导致的退出,尝试自动重启 - if (!workerStatus.isRestarting && code !== 0) { - logger.info('[Master] Worker crashed, attempting auto-restart...'); - scheduleRestart(); - } - }); - - // 监听子进程错误 - workerProcess.on('error', (error) => { - logger.error('[Master] Worker process error:', error.message); - }); -} - -/** - * 停止子进程 - * @param {boolean} graceful - 是否优雅关闭 - * @returns {Promise} - */ -function stopWorker(graceful = true) { - return new Promise((resolve) => { - if (!workerProcess) { - logger.info('[Master] No worker process to stop'); - resolve(); - return; - } - - logger.info('[Master] Stopping worker process, PID:', workerProcess.pid); - - const timeout = setTimeout(() => { - if (workerProcess) { - logger.info('[Master] Force killing worker process...'); - workerProcess.kill('SIGKILL'); - } - resolve(); - }, 5000); // 5秒超时后强制杀死 - - workerProcess.once('exit', () => { - clearTimeout(timeout); - workerProcess = null; - workerStatus.pid = null; - logger.info('[Master] Worker process stopped'); - resolve(); - }); - - if (graceful) { - // 发送优雅关闭信号 - workerProcess.send({ type: 'shutdown' }); - workerProcess.kill('SIGTERM'); - } else { - workerProcess.kill('SIGKILL'); - } - }); -} - -/** - * 重启子进程 - * @returns {Promise} - */ -async function restartWorker() { - if (workerStatus.isRestarting) { - logger.info('[Master] Restart already in progress'); - return { success: false, message: 'Restart already in progress' }; - } - - workerStatus.isRestarting = true; - workerStatus.restartCount++; - workerStatus.lastRestartTime = new Date().toISOString(); - - logger.info('[Master] Restarting worker process...'); - - try { - await stopWorker(true); - - // 等待一小段时间确保端口释放 - await new Promise(resolve => setTimeout(resolve, config.restartDelay)); - - startWorker(); - workerStatus.isRestarting = false; - - return { - success: true, - message: 'Worker restarted successfully', - pid: workerStatus.pid, - restartCount: workerStatus.restartCount - }; - } catch (error) { - workerStatus.isRestarting = false; - logger.error('[Master] Failed to restart worker:', error.message); - return { - success: false, - message: 'Failed to restart worker: ' + error.message - }; - } -} - -/** - * 计划重启(用于崩溃后自动重启) - */ -function scheduleRestart() { - if (workerStatus.restartCount >= config.maxRestartAttempts) { - logger.error('[Master] Max restart attempts reached, giving up'); - return; - } - - const delay = Math.min(config.restartDelay * Math.pow(2, workerStatus.restartCount), 30000); - logger.info(`[Master] Scheduling restart in ${delay}ms...`); - - setTimeout(() => { - restartWorker(); - }, delay); -} - -/** - * 处理来自子进程的消息 - * @param {Object} message - 消息对象 - */ -function handleWorkerMessage(message) { - if (!message || !message.type) return; - - switch (message.type) { - case 'ready': - logger.info('[Master] Worker is ready'); - break; - case 'restart_request': - logger.info('[Master] Worker requested restart'); - restartWorker(); - break; - case 'status': - logger.info('[Master] Worker status:', message.data); - break; - default: - logger.info('[Master] Unknown message type:', message.type); - } -} - -/** - * 获取状态信息 - * @returns {Object} - */ -function getStatus() { - return { - master: { - pid: process.pid, - uptime: process.uptime(), - memoryUsage: process.memoryUsage() - }, - worker: { - pid: workerStatus.pid, - startTime: workerStatus.startTime, - restartCount: workerStatus.restartCount, - lastRestartTime: workerStatus.lastRestartTime, - isRestarting: workerStatus.isRestarting, - isRunning: workerProcess !== null - } - }; -} - -/** - * 创建主进程管理 HTTP 服务器 - */ -function createMasterServer() { - const server = http.createServer(async (req, res) => { - const url = new URL(req.url, `http://${req.headers.host}`); - const path = url.pathname; - const method = req.method; - - // 设置 CORS 头 - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization'); - - if (method === 'OPTIONS') { - res.writeHead(204); - res.end(); - return; - } - - // 状态端点 - if (method === 'GET' && path === '/master/status') { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(getStatus())); - return; - } - - // 重启端点 - if (method === 'POST' && path === '/master/restart') { - logger.info('[Master] Restart requested via API'); - const result = await restartWorker(); - res.writeHead(result.success ? 200 : 500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(result)); - return; - } - - // 停止端点 - if (method === 'POST' && path === '/master/stop') { - logger.info('[Master] Stop requested via API'); - await stopWorker(true); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ success: true, message: 'Worker stopped' })); - return; - } - - // 启动端点 - if (method === 'POST' && path === '/master/start') { - logger.info('[Master] Start requested via API'); - if (workerProcess) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ success: false, message: 'Worker already running' })); - return; - } - startWorker(); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ success: true, message: 'Worker started', pid: workerStatus.pid })); - return; - } - - // 健康检查 - if (method === 'GET' && path === '/master/health') { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - status: 'healthy', - workerRunning: workerProcess !== null, - timestamp: new Date().toISOString() - })); - return; - } - - // 404 - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Not Found' })); - }); - - server.listen(config.masterPort, () => { - logger.info(`[Master] Management server listening on port ${config.masterPort}`); - logger.info(`[Master] Available endpoints:`); - logger.info(` GET /master/status - Get master and worker status`); - logger.info(` GET /master/health - Health check`); - logger.info(` POST /master/restart - Restart worker process`); - logger.info(` POST /master/stop - Stop worker process`); - logger.info(` POST /master/start - Start worker process`); - }); - - return server; -} - -/** - * 处理进程信号 - */ -function setupSignalHandlers() { - // 优雅关闭 - process.on('SIGTERM', async () => { - logger.info('[Master] Received SIGTERM, shutting down...'); - await stopWorker(true); - process.exit(0); - }); - - process.on('SIGINT', async () => { - logger.info('[Master] Received SIGINT, shutting down...'); - await stopWorker(true); - process.exit(0); - }); - - // 未捕获的异常 - process.on('uncaughtException', (error) => { - logger.error('[Master] Uncaught exception:', error); - - // 检查是否为可重试的网络错误 - if (isRetryableNetworkError(error)) { - logger.warn('[Master] Network error detected, continuing operation...'); - return; // 不退出程序,继续运行 - } - - // 对于其他严重错误,记录但不退出(由主进程管理子进程) - logger.error('[Master] Fatal error detected in master process'); - }); - - process.on('unhandledRejection', (reason, promise) => { - logger.error('[Master] Unhandled rejection at:', promise, 'reason:', reason); - - // 检查是否为可重试的网络错误 - if (reason && isRetryableNetworkError(reason)) { - logger.warn('[Master] Network error in promise rejection, continuing operation...'); - return; // 不退出程序,继续运行 - } - }); -} - -/** - * 主函数 - */ -async function main() { - logger.info('='.repeat(50)); - logger.info('[Master] AIClient2API Master Process'); - logger.info('[Master] PID:', process.pid); - logger.info('[Master] Node version:', process.version); - logger.info('[Master] Working directory:', process.cwd()); - logger.info('='.repeat(50)); - - // 设置信号处理 - setupSignalHandlers(); - - // 创建管理服务器 - createMasterServer(); - - // 启动子进程 - startWorker(); -} - -// 启动主进程 -main().catch(error => { - logger.error('[Master] Failed to start:', error); - process.exit(1); -}); \ No newline at end of file diff --git a/src/core/plugin-manager.js b/src/core/plugin-manager.js deleted file mode 100644 index a3b7b7277e37cbf3f69c3a5c74bed5d40fdff808..0000000000000000000000000000000000000000 --- a/src/core/plugin-manager.js +++ /dev/null @@ -1,549 +0,0 @@ -/** - * 插件管理器 - 可插拔插件系统核心 - * - * 功能: - * 1. 插件注册与加载 - * 2. 生命周期管理(init/destroy) - * 3. 扩展点管理(中间件、路由、钩子) - * 4. 插件配置管理 - */ - -import { promises as fs } from 'fs'; -import logger from '../utils/logger.js'; -import { existsSync } from 'fs'; -import path from 'path'; - -// 插件配置文件路径 -const PLUGINS_CONFIG_FILE = path.join(process.cwd(), 'configs', 'plugins.json'); - -// 默认禁用的插件列表 -const DEFAULT_DISABLED_PLUGINS = ['api-potluck', 'ai-monitor']; - -/** - * 插件类型常量 - */ -export const PLUGIN_TYPE = { - AUTH: 'auth', // 认证插件,参与认证流程 - MIDDLEWARE: 'middleware' // 普通中间件,不参与认证 -}; - -/** - * 插件接口定义(JSDoc 类型) - * @typedef {Object} Plugin - * @property {string} name - 插件名称(唯一标识) - * @property {string} version - 插件版本 - * @property {string} [description] - 插件描述 - * @property {string} [type] - 插件类型:'auth'(认证插件)或 'middleware'(普通中间件,默认) - * @property {boolean} [enabled] - 是否启用(默认 true) - * @property {number} [_priority] - 优先级,数字越小越先执行(默认 100) - * @property {boolean} [_builtin] - 是否为内置插件(内置插件最后执行) - * @property {Function} [init] - 初始化钩子 (config) => Promise - * @property {Function} [destroy] - 销毁钩子 () => Promise - * @property {Function} [middleware] - 请求中间件 (req, res, requestUrl, config) => Promise<{handled: boolean, data?: Object}> - * @property {Function} [authenticate] - 认证方法(仅 type='auth' 时有效)(req, res, requestUrl, config) => Promise<{handled: boolean, authorized: boolean|null, error?: Object, data?: Object}> - * @property {Array<{method: string, path: string|RegExp, handler: Function}>} [routes] - 路由定义 - * @property {string[]} [staticPaths] - 静态文件路径(相对于 static 目录) - * @property {Object} [hooks] - 钩子函数 - * @property {Function} [hooks.onBeforeRequest] - 请求前钩子 (req, config) => Promise - * @property {Function} [hooks.onAfterResponse] - 响应后钩子 (req, res, config) => Promise - * @property {Function} [hooks.onContentGenerated] - 内容生成后钩子 (config) => Promise - */ - -/** - * 插件管理器类 - */ -class PluginManager { - constructor() { - /** @type {Map} */ - this.plugins = new Map(); - /** @type {Object} */ - this.pluginsConfig = { plugins: {} }; - /** @type {boolean} */ - this.initialized = false; - } - - /** - * 加载插件配置文件 - * 永远生成默认配置,如果本地文件存在则合并,但不覆盖 enabled 字段 - */ - async loadConfig() { - try { - // 1. 永远生成默认配置 - const defaultConfig = await this.generateDefaultConfig(); - - // 2. 如果本地文件存在,读取并合并 - if (existsSync(PLUGINS_CONFIG_FILE)) { - const content = await fs.readFile(PLUGINS_CONFIG_FILE, 'utf8'); - const localConfig = JSON.parse(content); - - // 3. 合并配置:遍历默认配置中的所有插件 - for (const [pluginName, defaultPluginConfig] of Object.entries(defaultConfig.plugins)) { - const localPluginConfig = localConfig.plugins?.[pluginName]; - - if (localPluginConfig) { - // 本地配置存在,合并但保留本地的 enabled 字段 - defaultConfig.plugins[pluginName] = { - ...defaultPluginConfig, - ...localPluginConfig, - enabled: localPluginConfig.enabled // 保留本地的 enabled 字段 - }; - } - // 如果本地配置不存在该插件,使用默认配置 - } - } - - this.pluginsConfig = defaultConfig; - await this.saveConfig(); - } catch (error) { - logger.error('[PluginManager] Failed to load config:', error.message); - this.pluginsConfig = { plugins: {} }; - } - } - - /** - * 扫描 plugins 目录生成默认配置 - * @returns {Promise} 默认插件配置 - */ - async generateDefaultConfig() { - const defaultConfig = { plugins: {} }; - const pluginsDir = path.join(process.cwd(), 'src', 'plugins'); - - try { - if (!existsSync(pluginsDir)) { - return defaultConfig; - } - - const entries = await fs.readdir(pluginsDir, { withFileTypes: true }); - - for (const entry of entries) { - if (!entry.isDirectory()) continue; - - const pluginPath = path.join(pluginsDir, entry.name, 'index.js'); - if (!existsSync(pluginPath)) continue; - - try { - // 动态导入插件以获取其元信息 - const pluginModule = await import(`file://${pluginPath}`); - const plugin = pluginModule.default || pluginModule; - - if (plugin && plugin.name) { - // 检查是否在默认禁用列表中 - const enabled = !DEFAULT_DISABLED_PLUGINS.includes(plugin.name); - defaultConfig.plugins[plugin.name] = { - enabled: enabled, - description: plugin.description || '' - }; - logger.info(`[PluginManager] Found plugin for default config: ${plugin.name}`); - } - } catch (importError) { - // 如果导入失败,使用目录名作为插件名 - // 检查是否在默认禁用列表中 - const enabled = !DEFAULT_DISABLED_PLUGINS.includes(entry.name); - defaultConfig.plugins[entry.name] = { - enabled: enabled, - description: '' - }; - logger.warn(`[PluginManager] Could not import plugin ${entry.name}, using directory name:`, importError.message); - } - } - } catch (error) { - logger.error('[PluginManager] Failed to scan plugins directory:', error.message); - } - - return defaultConfig; - } - - /** - * 保存插件配置文件 - */ - async saveConfig() { - try { - const dir = path.dirname(PLUGINS_CONFIG_FILE); - if (!existsSync(dir)) { - await fs.mkdir(dir, { recursive: true }); - } - await fs.writeFile(PLUGINS_CONFIG_FILE, JSON.stringify(this.pluginsConfig, null, 2), 'utf8'); - } catch (error) { - logger.error('[PluginManager] Failed to save config:', error.message); - } - } - - /** - * 注册插件 - * @param {Plugin} plugin - 插件对象 - */ - register(plugin) { - if (!plugin.name) { - throw new Error('Plugin must have a name'); - } - if (this.plugins.has(plugin.name)) { - logger.warn(`[PluginManager] Plugin "${plugin.name}" is already registered, skipping`); - return; - } - this.plugins.set(plugin.name, plugin); - logger.info(`[PluginManager] Registered plugin: ${plugin.name} v${plugin.version || '1.0.0'}`); - } - - /** - * 初始化所有已启用的插件 - * @param {Object} config - 服务器配置 - */ - async initAll(config) { - await this.loadConfig(); - - for (const [name, plugin] of this.plugins) { - const pluginConfig = this.pluginsConfig.plugins[name] || {}; - const enabled = pluginConfig.enabled !== false; // 默认启用 - - if (!enabled) { - logger.info(`[PluginManager] Plugin "${name}" is disabled, skipping init`); - continue; - } - - try { - if (typeof plugin.init === 'function') { - await plugin.init(config); - logger.info(`[PluginManager] Initialized plugin: ${name}`); - } - plugin._enabled = true; - } catch (error) { - logger.error(`[PluginManager] Failed to init plugin "${name}":`, error.message); - plugin._enabled = false; - } - } - - this.initialized = true; - } - - /** - * 销毁所有插件 - */ - async destroyAll() { - for (const [name, plugin] of this.plugins) { - if (!plugin._enabled) continue; - - try { - if (typeof plugin.destroy === 'function') { - await plugin.destroy(); - logger.info(`[PluginManager] Destroyed plugin: ${name}`); - } - } catch (error) { - logger.error(`[PluginManager] Failed to destroy plugin "${name}":`, error.message); - } - } - this.initialized = false; - } - - /** - * 检查插件是否启用 - * @param {string} name - 插件名称 - * @returns {boolean} - */ - isEnabled(name) { - const plugin = this.plugins.get(name); - return plugin && plugin._enabled === true; - } - - /** - * 获取所有启用的插件(按优先级排序) - * 优先级数字越小越先执行,内置插件(_builtin: true)最后执行 - * @returns {Plugin[]} - */ - getEnabledPlugins() { - return Array.from(this.plugins.values()) - .filter(p => p._enabled) - .sort((a, b) => { - // 内置插件排在最后 - const aBuiltin = a._builtin ? 1 : 0; - const bBuiltin = b._builtin ? 1 : 0; - if (aBuiltin !== bBuiltin) return aBuiltin - bBuiltin; - - // 按优先级排序(数字越小越先执行) - const aPriority = a._priority || 100; - const bPriority = b._priority || 100; - return aPriority - bPriority; - }); - } - - /** - * 获取所有认证插件(按优先级排序) - * @returns {Plugin[]} - */ - getAuthPlugins() { - return this.getEnabledPlugins().filter(p => - p.type === PLUGIN_TYPE.AUTH && typeof p.authenticate === 'function' - ); - } - - /** - * 获取所有普通中间件插件(按优先级排序) - * @returns {Plugin[]} - */ - getMiddlewarePlugins() { - return this.getEnabledPlugins().filter(p => - p.type !== PLUGIN_TYPE.AUTH && typeof p.middleware === 'function' - ); - } - - /** - * 执行认证流程 - * 只有 type='auth' 的插件会参与认证 - * - * 认证插件返回值说明: - * - { handled: true } - 请求已被处理(如发送了错误响应),停止后续处理 - * - { authorized: true, data: {...} } - 认证成功,可附带数据 - * - { authorized: false } - 认证失败,已发送错误响应 - * - { authorized: null } - 此插件不处理该请求,继续下一个认证插件 - * - * @param {http.IncomingMessage} req - HTTP 请求 - * @param {http.ServerResponse} res - HTTP 响应 - * @param {URL} requestUrl - 解析后的 URL - * @param {Object} config - 服务器配置 - * @returns {Promise<{handled: boolean, authorized: boolean}>} - */ - async executeAuth(req, res, requestUrl, config) { - const authPlugins = this.getAuthPlugins(); - - for (const plugin of authPlugins) { - try { - const result = await plugin.authenticate(req, res, requestUrl, config); - - if (!result) continue; - - // 如果请求已被处理(如发送了错误响应),停止执行 - if (result.handled) { - return { handled: true, authorized: false }; - } - - // 如果认证失败,停止执行 - if (result.authorized === false) { - return { handled: true, authorized: false }; - } - - // 如果认证成功,合并数据并返回 - if (result.authorized === true) { - if (result.data) { - Object.assign(config, result.data); - } - return { handled: false, authorized: true }; - } - - // authorized === null 表示此插件不处理,继续下一个 - } catch (error) { - logger.error(`[PluginManager] Auth error in plugin "${plugin.name}":`, error.message); - } - } - - // 没有任何认证插件处理,返回未授权 - return { handled: false, authorized: false }; - } - - /** - * 执行普通中间件 - * 只有 type!='auth' 的插件会执行 - * - * 中间件返回值说明: - * - { handled: true } - 请求已被处理,停止后续处理 - * - { handled: false, data: {...} } - 继续处理,可附带数据 - * - null/undefined - 继续执行下一个中间件 - * - * @param {http.IncomingMessage} req - HTTP 请求 - * @param {http.ServerResponse} res - HTTP 响应 - * @param {URL} requestUrl - 解析后的 URL - * @param {Object} config - 服务器配置 - * @returns {Promise<{handled: boolean}>} - */ - async executeMiddleware(req, res, requestUrl, config) { - const middlewarePlugins = this.getMiddlewarePlugins(); - - for (const plugin of middlewarePlugins) { - try { - const result = await plugin.middleware(req, res, requestUrl, config); - - if (!result) continue; - - // 如果请求已被处理,停止执行 - if (result.handled) { - return { handled: true }; - } - - // 合并数据 - if (result.data) { - Object.assign(config, result.data); - } - } catch (error) { - logger.error(`[PluginManager] Middleware error in plugin "${plugin.name}":`, error.message); - } - } - - return { handled: false }; - } - - /** - * 执行所有插件的路由处理 - * @param {string} method - HTTP 方法 - * @param {string} path - 请求路径 - * @param {http.IncomingMessage} req - HTTP 请求 - * @param {http.ServerResponse} res - HTTP 响应 - * @returns {Promise} - 是否已处理 - */ - async executeRoutes(method, path, req, res) { - for (const plugin of this.getEnabledPlugins()) { - if (!Array.isArray(plugin.routes)) continue; - - for (const route of plugin.routes) { - const methodMatch = route.method === '*' || route.method.toUpperCase() === method; - if (!methodMatch) continue; - - let pathMatch = false; - if (route.path instanceof RegExp) { - pathMatch = route.path.test(path); - } else if (typeof route.path === 'string') { - pathMatch = path === route.path || path.startsWith(route.path + '/'); - } - - if (pathMatch) { - try { - const handled = await route.handler(method, path, req, res); - if (handled) return true; - } catch (error) { - logger.error(`[PluginManager] Route error in plugin "${plugin.name}":`, error.message); - } - } - } - } - return false; - } - - /** - * 获取所有插件的静态文件路径 - * @returns {string[]} - */ - getStaticPaths() { - const paths = []; - for (const plugin of this.getEnabledPlugins()) { - if (Array.isArray(plugin.staticPaths)) { - paths.push(...plugin.staticPaths); - } - } - return paths; - } - - /** - * 检查路径是否是插件静态文件 - * @param {string} path - 请求路径 - * @returns {boolean} - */ - isPluginStaticPath(path) { - const staticPaths = this.getStaticPaths(); - return staticPaths.some(sp => path === sp || path === '/' + sp); - } - - /** - * 执行钩子函数 - * @param {string} hookName - 钩子名称 - * @param {...any} args - 钩子参数 - */ - async executeHook(hookName, ...args) { - for (const plugin of this.getEnabledPlugins()) { - if (!plugin.hooks || typeof plugin.hooks[hookName] !== 'function') continue; - - try { - await plugin.hooks[hookName](...args); - } catch (error) { - logger.error(`[PluginManager] Hook "${hookName}" error in plugin "${plugin.name}":`, error.message); - } - } - } - - /** - * 获取插件列表(用于 API) - * @returns {Object[]} - */ - getPluginList() { - const list = []; - for (const [name, plugin] of this.plugins) { - const pluginConfig = this.pluginsConfig.plugins[name] || {}; - list.push({ - name: plugin.name, - version: plugin.version || '1.0.0', - description: plugin.description || pluginConfig.description || '', - enabled: plugin._enabled === true, - hasMiddleware: typeof plugin.middleware === 'function', - hasRoutes: Array.isArray(plugin.routes) && plugin.routes.length > 0, - hasHooks: plugin.hooks && Object.keys(plugin.hooks).length > 0 - }); - } - return list; - } - - /** - * 启用/禁用插件 - * @param {string} name - 插件名称 - * @param {boolean} enabled - 是否启用 - */ - async setPluginEnabled(name, enabled) { - if (!this.pluginsConfig.plugins[name]) { - this.pluginsConfig.plugins[name] = {}; - } - this.pluginsConfig.plugins[name].enabled = enabled; - await this.saveConfig(); - - const plugin = this.plugins.get(name); - if (plugin) { - plugin._enabled = enabled; - } - } -} - -// 单例实例 -const pluginManager = new PluginManager(); - -/** - * 自动发现并加载插件 - * 扫描 src/plugins/ 目录下的所有插件 - */ -export async function discoverPlugins() { - const pluginsDir = path.join(process.cwd(), 'src', 'plugins'); - - try { - if (!existsSync(pluginsDir)) { - await fs.mkdir(pluginsDir, { recursive: true }); - logger.info('[PluginManager] Created plugins directory'); - } - - const entries = await fs.readdir(pluginsDir, { withFileTypes: true }); - - for (const entry of entries) { - if (!entry.isDirectory()) continue; - - const pluginPath = path.join(pluginsDir, entry.name, 'index.js'); - if (!existsSync(pluginPath)) continue; - - try { - // 动态导入插件 - const pluginModule = await import(`file://${pluginPath}`); - const plugin = pluginModule.default || pluginModule; - - if (plugin && plugin.name) { - pluginManager.register(plugin); - } - } catch (error) { - logger.error(`[PluginManager] Failed to load plugin from ${entry.name}:`, error.message); - } - } - } catch (error) { - logger.error('[PluginManager] Failed to discover plugins:', error.message); - } -} - -/** - * 获取插件管理器实例 - * @returns {PluginManager} - */ -export function getPluginManager() { - return pluginManager; -} - -// 导出类和实例 -export { PluginManager, pluginManager }; \ No newline at end of file diff --git a/src/handlers/request-handler.js b/src/handlers/request-handler.js deleted file mode 100644 index ebc84da16a71d9ac0aaadea29f969baf2f7652dc..0000000000000000000000000000000000000000 --- a/src/handlers/request-handler.js +++ /dev/null @@ -1,271 +0,0 @@ -import deepmerge from 'deepmerge'; -import logger from '../utils/logger.js'; -import { handleError, getClientIp } from '../utils/common.js'; -import { handleUIApiRequests, serveStaticFiles } from '../services/ui-manager.js'; -import { handleAPIRequests } from '../services/api-manager.js'; -import { getApiService, getProviderStatus } from '../services/service-manager.js'; -import { getProviderPoolManager } from '../services/service-manager.js'; -import { MODEL_PROVIDER } from '../utils/common.js'; -import { getRegisteredProviders } from '../providers/adapter.js'; -import { countTokensAnthropic } from '../utils/token-utils.js'; -import { PROMPT_LOG_FILENAME } from '../core/config-manager.js'; -import { getPluginManager } from '../core/plugin-manager.js'; -import { randomUUID } from 'crypto'; -import { handleGrokAssetsProxy } from '../utils/grok-assets-proxy.js'; - -/** - * Generate a short unique request ID (8 characters) - */ -function generateRequestId() { - return randomUUID().slice(0, 8); -} - -/** - * Parse request body as JSON - */ -function parseRequestBody(req) { - return new Promise((resolve, reject) => { - let body = ''; - req.on('data', chunk => { body += chunk.toString(); }); - req.on('end', () => { - try { - resolve(body ? JSON.parse(body) : {}); - } catch (e) { - reject(new Error('Invalid JSON in request body')); - } - }); - req.on('error', reject); - }); -} - -/** - * Main request handler. It authenticates the request, determines the endpoint type, - * and delegates to the appropriate specialized handler function. - * @param {Object} config - The server configuration - * @param {Object} providerPoolManager - The provider pool manager instance - * @returns {Function} - The request handler function - */ -export function createRequestHandler(config, providerPoolManager) { - return async function requestHandler(req, res) { - // Generate unique request ID and set it in logger context - const clientIp = getClientIp(req); - const requestId = `${clientIp}:${generateRequestId()}`; - - return logger.runWithContext(requestId, async () => { - // Deep copy the config for each request to allow dynamic modification - const currentConfig = deepmerge({}, config); - - // 计算当前请求的基础 URL - const protocol = req.socket.encrypted || req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http'; - const host = req.headers.host; - currentConfig.requestBaseUrl = `${protocol}://${host}`; - - const requestUrl = new URL(req.url, `http://${req.headers.host}`); - let path = requestUrl.pathname; - const method = req.method; - - try { - // Set CORS headers for all requests - res.setHeader('Access-Control-Allow-Origin', '*'); - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS, PATCH'); - res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, x-goog-api-key, Model-Provider, X-Requested-With, Accept, Origin'); - res.setHeader('Access-Control-Max-Age', '86400'); // 24 hours cache for preflight - - // Handle CORS preflight requests - if (method === 'OPTIONS') { - res.writeHead(204); - res.end(); - return; - } - - // Serve static files for UI (除了登录页面需要认证) - // 检查是否是插件静态文件 - const pluginManager = getPluginManager(); - const isPluginStatic = pluginManager.isPluginStaticPath(path); - if (path.startsWith('/static/') || path === '/' || path === '/favicon.ico' || path === '/index.html' || path.startsWith('/app/') || path.startsWith('/components/') || path === '/login.html' || isPluginStatic) { - const served = await serveStaticFiles(path, res); - if (served) return; - } - - // 执行插件路由 - const pluginRouteHandled = await pluginManager.executeRoutes(method, path, req, res); - if (pluginRouteHandled) return; - - const uiHandled = await handleUIApiRequests(method, path, req, res, currentConfig, providerPoolManager); - if (uiHandled) return; - - // logger.info(`\n${new Date().toLocaleString()}`); - logger.info(`[Server] Received request: ${req.method} http://${req.headers.host}${req.url}`); - - // Health check endpoint - if (method === 'GET' && path === '/health') { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - status: 'healthy', - timestamp: new Date().toISOString(), - provider: currentConfig.MODEL_PROVIDER - })); - return true; - } - - // Grok assets proxy endpoint - if (method === 'GET' && path === '/api/grok/assets') { - await handleGrokAssetsProxy(req, res, currentConfig, providerPoolManager); - return true; - } - - // providers health endpoint - // url params: provider[string], customName[string], unhealthRatioThreshold[float] - // 支持provider, customName过滤记录 - // 支持unhealthRatioThreshold控制不健康比例的阈值, 当unhealthyRatio超过阈值返回summaryHealthy: false - if (method === 'GET' && path === '/provider_health') { - try { - const provider = requestUrl.searchParams.get('provider'); - const customName = requestUrl.searchParams.get('customName'); - let unhealthRatioThreshold = requestUrl.searchParams.get('unhealthRatioThreshold'); - unhealthRatioThreshold = unhealthRatioThreshold === null ? 0.0001 : parseFloat(unhealthRatioThreshold); - let provideStatus = await getProviderStatus(currentConfig, { provider, customName }); - let summaryHealth = true; - if (!isNaN(unhealthRatioThreshold)) { - summaryHealth = provideStatus.unhealthyRatio <= unhealthRatioThreshold; - } - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - timestamp: new Date().toISOString(), - items: provideStatus.providerPoolsSlim, - count: provideStatus.count, - unhealthyCount: provideStatus.unhealthyCount, - unhealthyRatio: provideStatus.unhealthyRatio, - unhealthySummeryMessage: provideStatus.unhealthySummeryMessage, - summaryHealth - })); - return true; - } catch (error) { - logger.info(`[Server] req provider_health error: ${error.message}`); - handleError(res, { statusCode: 500, message: `Failed to get providers health: ${error.message}` }, currentConfig.MODEL_PROVIDER); - return; - } - } - - - // Handle API requests - // Allow overriding MODEL_PROVIDER via request header - const modelProviderHeader = req.headers['model-provider']; - if (modelProviderHeader) { - const registeredProviders = getRegisteredProviders(); - if (registeredProviders.includes(modelProviderHeader)) { - currentConfig.MODEL_PROVIDER = modelProviderHeader; - logger.info(`[Config] MODEL_PROVIDER overridden by header to: ${currentConfig.MODEL_PROVIDER}`); - } else { - logger.warn(`[Config] Provider ${modelProviderHeader} in header is not available.`); - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: `Provider ${modelProviderHeader} is not available.` } })); - return; - } - } - - // Check if the first path segment matches a MODEL_PROVIDER and switch if it does - const pathSegments = path.split('/').filter(segment => segment.length > 0); - - if (pathSegments.length > 0) { - const firstSegment = pathSegments[0]; - const registeredProviders = getRegisteredProviders(); - const isValidProvider = registeredProviders.includes(firstSegment); - const isAutoMode = firstSegment === MODEL_PROVIDER.AUTO; - - if (firstSegment && (isValidProvider || isAutoMode)) { - currentConfig.MODEL_PROVIDER = firstSegment; - logger.info(`[Config] MODEL_PROVIDER overridden by path segment to: ${currentConfig.MODEL_PROVIDER}`); - pathSegments.shift(); - path = '/' + pathSegments.join('/'); - requestUrl.pathname = path; - } else if (firstSegment && Object.values(MODEL_PROVIDER).includes(firstSegment)) { - // 如果在 MODEL_PROVIDER 中但没注册适配器,拦截并报错 - logger.warn(`[Config] Provider ${firstSegment} is recognized but no adapter is registered.`); - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: `Provider ${firstSegment} is not available.` } })); - return; - } else if (firstSegment && !isValidProvider) { - logger.info(`[Config] Ignoring invalid MODEL_PROVIDER in path segment: ${firstSegment}`); - } - } - - // 1. 执行认证流程(只有 type='auth' 的插件参与) - const authResult = await pluginManager.executeAuth(req, res, requestUrl, currentConfig); - if (authResult.handled) { - // 认证插件已处理请求(如发送了错误响应) - return; - } - if (!authResult.authorized) { - // 没有认证插件授权,返回 401 - res.writeHead(401, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: 'Unauthorized: API key is invalid or missing.' } })); - return; - } - - // 2. 执行普通中间件(type!='auth' 的插件) - const middlewareResult = await pluginManager.executeMiddleware(req, res, requestUrl, currentConfig); - if (middlewareResult.handled) { - // 中间件已处理请求 - return; - } - - // Handle count_tokens requests (Anthropic API compatible) - if (path.includes('/count_tokens') && method === 'POST') { - try { - const body = await parseRequestBody(req); - logger.info(`[Server] Handling count_tokens request for model: ${body.model}`); - - // Use common utility method directly - try { - const result = countTokensAnthropic(body); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(result)); - } catch (tokenError) { - logger.warn(`[Server] Common countTokens failed, falling back: ${tokenError.message}`); - // Last resort: return 0 - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ input_tokens: 0 })); - } - return true; - } catch (error) { - logger.error(`[Server] count_tokens error: ${error.message}`); - handleError(res, { statusCode: 500, message: `Failed to count tokens: ${error.message}` }, currentConfig.MODEL_PROVIDER); - return; - } - } - - // 获取或选择 API Service 实例 - let apiService; - // try { - // apiService = await getApiService(currentConfig); - // } catch (error) { - // handleError(res, { statusCode: 500, message: `Failed to get API service: ${error.message}` }, currentConfig.MODEL_PROVIDER); - // const poolManager = getProviderPoolManager(); - // if (poolManager) { - // poolManager.markProviderUnhealthy(currentConfig.MODEL_PROVIDER, { - // uuid: currentConfig.uuid - // }); - // } - // return; - // } - - try { - // Handle API requests - const apiHandled = await handleAPIRequests(method, path, req, res, currentConfig, apiService, providerPoolManager, PROMPT_LOG_FILENAME); - if (apiHandled) return; - - // Fallback for unmatched routes - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: 'Not Found' } })); - } catch (error) { - handleError(res, error, currentConfig.MODEL_PROVIDER); - } - } finally { - // Clear request context after request is complete - logger.clearRequestContext(requestId); - } - }); - }; - -} diff --git a/src/plugins/ai-monitor/index.js b/src/plugins/ai-monitor/index.js deleted file mode 100644 index 6e259ac2d5ea12b9b0b6533e37d88cbc2615d5ff..0000000000000000000000000000000000000000 --- a/src/plugins/ai-monitor/index.js +++ /dev/null @@ -1,145 +0,0 @@ -import logger from '../../utils/logger.js'; - -/** - * AI 接口监控插件 - * 功能: - * 1. 捕获 AI 接口的请求参数(转换前和转换后) - * 2. 捕获 AI 接口的响应结果(转换前和转换后,流式响应聚合输出) - */ -const aiMonitorPlugin = { - name: 'ai-monitor', - version: '1.0.0', - description: 'AI 接口监控插件 - 捕获请求和响应参数(全链路协议转换监控,流式聚合输出,用于调试和分析)', - type: 'middleware', - _priority: 100, - - // 用于存储流式响应的中间状态 - streamCache: new Map(), - - async init(config) { - logger.info('[AI Monitor Plugin] Initialized'); - }, - - /** - * 中间件:初始化请求上下文 - */ - async middleware(req, res, requestUrl, config) { - const aiPaths = ['/v1/chat/completions', '/v1/responses', '/v1/messages', '/v1beta/models']; - const isAiPath = aiPaths.some(path => requestUrl.pathname.includes(path)); - - if (isAiPath && req.method === 'POST') { - // 在监控插件中生成请求标识,并存入 config 以供全链路追踪 - const requestId = Date.now() + Math.random().toString(36).substring(2, 10); - config._monitorRequestId = requestId; - } - - return { handled: false }; - }, - - hooks: { - /** - * 请求转换后的钩子 - */ - async onContentGenerated(config) { - const { originalRequestBody, processedRequestBody, fromProvider, toProvider, model, _monitorRequestId, isStream } = config; - if (!originalRequestBody) return; - - setImmediate(() => { - const hasConversion = JSON.stringify(originalRequestBody) !== JSON.stringify(processedRequestBody); - logger.info(`[AI Monitor][${_monitorRequestId}] >>> Req Protocol: ${fromProvider}${hasConversion ? ' -> ' + toProvider : ''} | Model: ${model}`); - - if (hasConversion) { - logger.info(`[AI Monitor][${_monitorRequestId}] [Req Original]: ${JSON.stringify(originalRequestBody)}`); - logger.info(`[AI Monitor][${_monitorRequestId}] [Req Processed]: ${JSON.stringify(processedRequestBody)}`); - } else { - logger.info(`[AI Monitor][${_monitorRequestId}] [Req]: ${JSON.stringify(originalRequestBody)}`); - } - }); - - // 处理流式响应的聚合输出 - if (isStream && _monitorRequestId) { - setTimeout(() => { - const cache = aiMonitorPlugin.streamCache.get(_monitorRequestId); - if (cache) { - const hasConversion = JSON.stringify(cache.nativeChunks) !== JSON.stringify(cache.convertedChunks); - logger.info(`[AI Monitor][${_monitorRequestId}] <<< Stream Response Aggregated: ${hasConversion ? cache.toProvider + ' -> ' : ''}${cache.fromProvider}`); - - if (hasConversion) { - logger.info(`[AI Monitor][${_monitorRequestId}] [Res Native Full]: ${JSON.stringify(cache.nativeChunks)}`); - logger.info(`[AI Monitor][${_monitorRequestId}] [Res Converted Full]: ${JSON.stringify(cache.convertedChunks)}`); - } else { - logger.info(`[AI Monitor][${_monitorRequestId}] [Res Full]: ${JSON.stringify(cache.nativeChunks)}`); - } - - aiMonitorPlugin.streamCache.delete(_monitorRequestId); - } - }, 2000); // 等待流传输完成 - } - }, - - /** - * 非流式响应转换监控 - */ - async onUnaryResponse({ nativeResponse, clientResponse, fromProvider, toProvider, requestId }) { - setImmediate(() => { - const reqId = requestId || 'N/A'; - const hasConversion = JSON.stringify(nativeResponse) !== JSON.stringify(clientResponse); - logger.info(`[AI Monitor][${reqId}] <<< Res Protocol: ${hasConversion ? toProvider + ' -> ' : ''}${fromProvider} (Unary)`); - - if (hasConversion) { - logger.info(`[AI Monitor][${reqId}] [Res Native]: ${JSON.stringify(nativeResponse)}`); - logger.info(`[AI Monitor][${reqId}] [Res Converted]: ${JSON.stringify(clientResponse)}`); - } else { - logger.info(`[AI Monitor][${reqId}] [Res]: ${JSON.stringify(nativeResponse)}`); - } - }); - }, - - /** - * 流式响应分块转换监控 - 聚合数据 - */ - async onStreamChunk({ nativeChunk, chunkToSend, fromProvider, toProvider, requestId }) { - if (!requestId) return; - - if (!aiMonitorPlugin.streamCache.has(requestId)) { - aiMonitorPlugin.streamCache.set(requestId, { - nativeChunks: [], - convertedChunks: [], - fromProvider, - toProvider - }); - } - - const cache = aiMonitorPlugin.streamCache.get(requestId); - - // 过滤 null 值,并判断是否为数组类型 - if (nativeChunk != null) { - if (Array.isArray(nativeChunk)) { - cache.nativeChunks.push(...nativeChunk.filter(item => item != null)); - } else { - cache.nativeChunks.push(nativeChunk); - } - } - - if (chunkToSend != null) { - if (Array.isArray(chunkToSend)) { - cache.convertedChunks.push(...chunkToSend.filter(item => item != null)); - } else { - cache.convertedChunks.push(chunkToSend); - } - } - }, - - /** - * 内部请求转换监控 - */ - async onInternalRequestConverted({ requestId, internalRequest, converterName }) { - setImmediate(() => { - const reqId = requestId || 'N/A'; - logger.info(`[AI Monitor][${reqId}] >>> Internal Req Converted [${converterName}]: ${JSON.stringify(internalRequest)}`); - }); - } - } -}; - -export default aiMonitorPlugin; \ No newline at end of file diff --git a/src/plugins/api-potluck/api-routes.js b/src/plugins/api-potluck/api-routes.js deleted file mode 100644 index dd761202dcb25909be611759125f3f16d23a3576..0000000000000000000000000000000000000000 --- a/src/plugins/api-potluck/api-routes.js +++ /dev/null @@ -1,452 +0,0 @@ -/** - * API 大锅饭 - 管理 API 路由 - * 提供 Key 管理的 RESTful API - */ - -import { - createKey, - listKeys, - getKey, - deleteKey, - updateKeyLimit, - resetKeyUsage, - toggleKey, - updateKeyName, - regenerateKey, - getStats, - validateKey, - KEY_PREFIX, - applyDailyLimitToAllKeys, - getAllKeyIds -} from './key-manager.js'; -import logger from '../../utils/logger.js'; - -/** - * 解析请求体 - * @param {http.IncomingMessage} req - * @returns {Promise} - */ -function parseRequestBody(req) { - return new Promise((resolve, reject) => { - let body = ''; - req.on('data', chunk => { - body += chunk.toString(); - }); - req.on('end', () => { - try { - resolve(body ? JSON.parse(body) : {}); - } catch (error) { - reject(new Error('JSON 格式无效')); - } - }); - req.on('error', reject); - }); -} - -/** - * 发送 JSON 响应 - * @param {http.ServerResponse} res - * @param {number} statusCode - * @param {Object} data - */ -function sendJson(res, statusCode, data) { - res.writeHead(statusCode, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(data)); -} - -/** - * 验证管理员 Token - * @param {http.IncomingMessage} req - * @returns {Promise} - */ -async function checkAdminAuth(req) { - const authHeader = req.headers.authorization; - if (!authHeader || !authHeader.startsWith('Bearer ')) { - return false; - } - - // 动态导入 ui-manager 中的 token 验证逻辑 - try { - const { existsSync, readFileSync } = await import('fs'); - const { promises: fs } = await import('fs'); - const path = await import('path'); - - const TOKEN_STORE_FILE = path.join(process.cwd(), 'configs', 'token-store.json'); - - if (!existsSync(TOKEN_STORE_FILE)) { - return false; - } - - const content = readFileSync(TOKEN_STORE_FILE, 'utf8'); - const tokenStore = JSON.parse(content); - const token = authHeader.substring(7); - const tokenInfo = tokenStore.tokens[token]; - - if (!tokenInfo) { - return false; - } - - // 检查是否过期 - if (Date.now() > tokenInfo.expiryTime) { - return false; - } - - return true; - } catch (error) { - logger.error('[API Potluck] Auth check error:', error.message); - return false; - } -} - -/** - * 处理 Potluck 管理 API 请求 - * @param {string} method - HTTP 方法 - * @param {string} path - 请求路径 - * @param {http.IncomingMessage} req - HTTP 请求对象 - * @param {http.ServerResponse} res - HTTP 响应对象 - * @returns {Promise} - 是否处理了请求 - */ -export async function handlePotluckApiRoutes(method, path, req, res) { - // 只处理 /api/potluck 开头的请求 - if (!path.startsWith('/api/potluck')) { - return false; - } - logger.info('[API Potluck] Handling request:', method, path); - - // 验证管理员权限 - const isAuthed = await checkAdminAuth(req); - if (!isAuthed) { - sendJson(res, 401, { - success: false, - error: { message: '未授权:请先登录', code: 'UNAUTHORIZED' } - }); - return true; - } - - try { - // GET /api/potluck/stats - 获取统计信息 - if (method === 'GET' && path === '/api/potluck/stats') { - const stats = await getStats(); - sendJson(res, 200, { success: true, data: stats }); - return true; - } - - // GET /api/potluck/keys - 获取所有 Key 列表 - if (method === 'GET' && path === '/api/potluck/keys') { - const keys = await listKeys(); - const stats = await getStats(); - sendJson(res, 200, { - success: true, - data: { keys, stats } - }); - return true; - } - - // POST /api/potluck/keys/apply-limit - 批量应用每日限额到所有 Key - if (method === 'POST' && path === '/api/potluck/keys/apply-limit') { - const body = await parseRequestBody(req); - const { dailyLimit } = body; - - if (dailyLimit === undefined || typeof dailyLimit !== 'number' || dailyLimit < 1) { - sendJson(res, 400, { success: false, error: { message: 'dailyLimit 必须是一个正数' } }); - return true; - } - - const result = await applyDailyLimitToAllKeys(dailyLimit); - sendJson(res, 200, { - success: true, - message: `已将每日限额 ${dailyLimit} 应用到 ${result.updated}/${result.total} 个 Key`, - data: result - }); - return true; - } - - // POST /api/potluck/keys - 创建新 Key - if (method === 'POST' && path === '/api/potluck/keys') { - const body = await parseRequestBody(req); - const { name, dailyLimit } = body; - const keyData = await createKey(name, dailyLimit); - sendJson(res, 201, { - success: true, - message: 'API Key 创建成功', - data: keyData - }); - return true; - } - - // 处理带 keyId 的路由 - const keyIdMatch = path.match(/^\/api\/potluck\/keys\/([^\/]+)(\/.*)?$/); - if (keyIdMatch) { - const keyId = decodeURIComponent(keyIdMatch[1]); - const subPath = keyIdMatch[2] || ''; - - // GET /api/potluck/keys/:keyId - 获取单个 Key 详情 - if (method === 'GET' && !subPath) { - const keyData = await getKey(keyId); - if (!keyData) { - sendJson(res, 404, { success: false, error: { message: '未找到 Key' } }); - return true; - } - sendJson(res, 200, { success: true, data: keyData }); - return true; - } - - // DELETE /api/potluck/keys/:keyId - 删除 Key - if (method === 'DELETE' && !subPath) { - const deleted = await deleteKey(keyId); - if (!deleted) { - sendJson(res, 404, { success: false, error: { message: '未找到 Key' } }); - return true; - } - sendJson(res, 200, { success: true, message: 'Key 删除成功' }); - return true; - } - - // PUT /api/potluck/keys/:keyId/limit - 更新每日限额 - if (method === 'PUT' && subPath === '/limit') { - const body = await parseRequestBody(req); - const { dailyLimit } = body; - - if (typeof dailyLimit !== 'number' || dailyLimit < 0) { - sendJson(res, 400, { - success: false, - error: { message: '无效的每日限额值' } - }); - return true; - } - - const keyData = await updateKeyLimit(keyId, dailyLimit); - if (!keyData) { - sendJson(res, 404, { success: false, error: { message: '未找到 Key' } }); - return true; - } - sendJson(res, 200, { - success: true, - message: '每日限额更新成功', - data: keyData - }); - return true; - } - - // POST /api/potluck/keys/:keyId/reset - 重置当天调用次数 - if (method === 'POST' && subPath === '/reset') { - const keyData = await resetKeyUsage(keyId); - if (!keyData) { - sendJson(res, 404, { success: false, error: { message: '未找到 Key' } }); - return true; - } - sendJson(res, 200, { - success: true, - message: '使用量重置成功', - data: keyData - }); - return true; - } - - // POST /api/potluck/keys/:keyId/toggle - 切换启用/禁用状态 - if (method === 'POST' && subPath === '/toggle') { - const keyData = await toggleKey(keyId); - if (!keyData) { - sendJson(res, 404, { success: false, error: { message: '未找到 Key' } }); - return true; - } - sendJson(res, 200, { - success: true, - message: `Key 已成功${keyData.enabled ? '启用' : '禁用'}`, - data: keyData - }); - return true; - } - - // PUT /api/potluck/keys/:keyId/name - 更新 Key 名称 - if (method === 'PUT' && subPath === '/name') { - const body = await parseRequestBody(req); - const { name } = body; - - if (!name || typeof name !== 'string') { - sendJson(res, 400, { - success: false, - error: { message: '无效的名称值' } - }); - return true; - } - - const keyData = await updateKeyName(keyId, name); - if (!keyData) { - sendJson(res, 404, { success: false, error: { message: '未找到 Key' } }); - return true; - } - sendJson(res, 200, { - success: true, - message: '名称更新成功', - data: keyData - }); - return true; - } - - // POST /api/potluck/keys/:keyId/regenerate - 重新生成 Key - if (method === 'POST' && subPath === '/regenerate') { - const result = await regenerateKey(keyId); - if (!result) { - sendJson(res, 404, { success: false, error: { message: '未找到 Key' } }); - return true; - } - sendJson(res, 200, { - success: true, - message: 'Key 重新生成成功', - data: { - oldKey: result.oldKey, - newKey: result.newKey, - keyData: result.keyData - } - }); - return true; - } - } - - // 未匹配的 potluck 路由 - sendJson(res, 404, { success: false, error: { message: '未找到 Potluck API 端点' } }); - return true; - - } catch (error) { - logger.error('[API Potluck] API error:', error); - sendJson(res, 500, { - success: false, - error: { message: error.message || '内部服务器错误' } - }); - return true; - } -} - -/** - * 从请求中提取 Potluck API Key - * @param {http.IncomingMessage} req - HTTP 请求对象 - * @returns {string|null} - */ -function extractApiKeyFromRequest(req) { - // 1. 检查 Authorization header - const authHeader = req.headers['authorization']; - if (authHeader && authHeader.startsWith('Bearer ')) { - const token = authHeader.substring(7); - if (token.startsWith(KEY_PREFIX)) { - return token; - } - } - - // 2. 检查 x-api-key header - const xApiKey = req.headers['x-api-key']; - if (xApiKey && xApiKey.startsWith(KEY_PREFIX)) { - return xApiKey; - } - - return null; -} - -/** - * 处理用户端 API 请求 - 用户通过自己的 API Key 查询使用量 - * @param {string} method - HTTP 方法 - * @param {string} path - 请求路径 - * @param {http.IncomingMessage} req - HTTP 请求对象 - * @param {http.ServerResponse} res - HTTP 响应对象 - * @returns {Promise} - 是否处理了请求 - */ -export async function handlePotluckUserApiRoutes(method, path, req, res) { - // 只处理 /api/potluckuser 开头的请求 - if (!path.startsWith('/api/potluckuser')) { - return false; - } - logger.info('[API Potluck User] Handling request:', method, path); - - try { - // 从请求中提取 API Key - const apiKey = extractApiKeyFromRequest(req); - - if (!apiKey) { - sendJson(res, 401, { - success: false, - error: { - message: '需要 API Key。请在 Authorization 标头 (Bearer maki_xxx) 或 x-api-key 标头中提供您的 API Key。', - code: 'API_KEY_REQUIRED' - } - }); - return true; - } - - // 验证 API Key - const validation = await validateKey(apiKey); - - if (!validation.valid && validation.reason !== 'quota_exceeded') { - const errorMessages = { - 'invalid_format': 'API Key 格式无效', - 'not_found': '未找到 API Key', - 'disabled': 'API Key 已禁用' - }; - - sendJson(res, 401, { - success: false, - error: { - message: errorMessages[validation.reason] || '无效的 API Key', - code: validation.reason - } - }); - return true; - } - - // GET /api/potluckuser/usage - 获取当前用户的使用量信息 - if (method === 'GET' && path === '/api/potluckuser/usage') { - const keyData = await getKey(apiKey); - - if (!keyData) { - sendJson(res, 404, { - success: false, - error: { message: '未找到 Key', code: 'KEY_NOT_FOUND' } - }); - return true; - } - - // 计算使用百分比 - const usagePercent = keyData.dailyLimit > 0 - ? Math.round((keyData.todayUsage / keyData.dailyLimit) * 100) - : 0; - - // 返回用户友好的使用量信息(隐藏敏感信息) - sendJson(res, 200, { - success: true, - data: { - name: keyData.name, - enabled: keyData.enabled, - usage: { - today: keyData.todayUsage, - limit: keyData.dailyLimit, - remaining: Math.max(0, keyData.dailyLimit - keyData.todayUsage), - percent: usagePercent, - resetDate: keyData.lastResetDate - }, - total: keyData.totalUsage, - lastUsedAt: keyData.lastUsedAt, - createdAt: keyData.createdAt, - usageHistory: keyData.usageHistory || {}, - // 显示部分遮蔽的 Key ID - - maskedKey: `${apiKey.substring(0, 12)}...${apiKey.substring(apiKey.length - 4)}` - } - }); - return true; - } - - // 未匹配的用户端路由 - sendJson(res, 404, { - success: false, - error: { message: '未找到用户 API 端点' } - }); - return true; - - } catch (error) { - logger.error('[API Potluck] User API error:', error); - sendJson(res, 500, { - success: false, - error: { message: error.message || '内部服务器错误' } - }); - return true; - } -} diff --git a/src/plugins/api-potluck/index.js b/src/plugins/api-potluck/index.js deleted file mode 100644 index 93c2bd9b0151e0089948ce86a3c2ffc32240f675..0000000000000000000000000000000000000000 --- a/src/plugins/api-potluck/index.js +++ /dev/null @@ -1,208 +0,0 @@ -/** - * API 大锅饭插件 - 标准插件格式 - * - * 功能: - * 1. API Key 管理(创建、删除、启用/禁用) - * 2. 每日配额限制 - * 3. 用量统计 - * 4. 管理 API 接口 - */ - -import { - createKey, - listKeys, - getKey, - deleteKey, - updateKeyLimit, - resetKeyUsage, - toggleKey, - updateKeyName, - validateKey, - incrementUsage, - getStats, - KEY_PREFIX, - setConfigGetter -} from './key-manager.js'; - -import { - extractPotluckKey, - isPotluckRequest, - sendPotluckError -} from './middleware.js'; - -import logger from '../../utils/logger.js'; - -import { handlePotluckApiRoutes, handlePotluckUserApiRoutes } from './api-routes.js'; - -/** - * 插件定义 - */ -const apiPotluckPlugin = { - name: 'api-potluck', - version: '1.0.2', - description: 'API 大锅饭 - Key 管理和用量统计插件
管理端:potluck.html
用户端:potluck-user.html', - - // 插件类型:认证插件 - type: 'auth', - - // 优先级:数字越小越先执行,默认认证插件优先级为 9999 - _priority: 10, - - /** - * 初始化钩子 - * @param {Object} config - 服务器配置 - */ - async init(config) { - logger.info('[API Potluck Plugin] Initializing...'); - }, - - /** - * 销毁钩子 - */ - async destroy() { - logger.info('[API Potluck Plugin] Destroying...'); - }, - - /** - * 静态文件路径 - */ - staticPaths: ['potluck.html', 'potluck-user.html'], - - /** - * 路由定义 - */ - routes: [ - { - method: '*', - path: '/api/potluckuser', - handler: handlePotluckUserApiRoutes - }, - { - method: '*', - path: '/api/potluck', - handler: handlePotluckApiRoutes - } - ], - - /** - * 认证方法 - 处理 Potluck Key 认证 - * @param {http.IncomingMessage} req - HTTP 请求 - * @param {http.ServerResponse} res - HTTP 响应 - * @param {URL} requestUrl - 解析后的 URL - * @param {Object} config - 服务器配置 - * @returns {Promise<{handled: boolean, authorized: boolean|null, error?: Object, data?: Object}>} - */ - async authenticate(req, res, requestUrl, config) { - const apiKey = extractPotluckKey(req, requestUrl); - - if (!apiKey) { - // 不是 potluck 请求,返回 null 让其他认证插件处理 - return { handled: false, authorized: null }; - } - - // 验证 Key - const validation = await validateKey(apiKey); - - if (!validation.valid) { - const errorMessages = { - 'invalid_format': 'Invalid API key format', - 'not_found': 'API key not found', - 'disabled': 'API key has been disabled', - 'quota_exceeded': 'Quota exceeded for this API key' - }; - - const statusCodes = { - 'invalid_format': 401, - 'not_found': 401, - 'disabled': 403, - 'quota_exceeded': 429 - }; - - const error = { - statusCode: statusCodes[validation.reason] || 401, - message: errorMessages[validation.reason] || 'Authentication failed', - code: validation.reason, - keyData: validation.keyData - }; - - // 发送错误响应 - sendPotluckError(res, error); - return { handled: true, authorized: false, error }; - } - - // 认证成功,返回数据供后续使用 - logger.info(`[API Potluck Plugin] Authorized with key: ${apiKey.substring(0, 12)}...`); - return { - handled: false, - authorized: true, - data: { - potluckApiKey: apiKey, - potluckKeyData: validation.keyData - } - }; - }, - - /** - * 钩子函数 - */ - hooks: { - /** - * 内容生成后钩子 - 记录用量 - * @param {Object} hookContext - 钩子上下文,包含请求和模型信息 - */ - async onContentGenerated(hookContext) { - if (hookContext.potluckApiKey) { - try { - // 传入提供商和模型信息 - await incrementUsage( - hookContext.potluckApiKey, - hookContext.toProvider, - hookContext.model - ); - } catch (e) { - // 静默失败,不影响主流程 - logger.error('[API Potluck Plugin] Failed to record usage:', e.message); - } - } - } - - }, - - // 导出内部函数供外部使用(可选) - exports: { - createKey, - listKeys, - getKey, - deleteKey, - updateKeyLimit, - resetKeyUsage, - toggleKey, - updateKeyName, - validateKey, - incrementUsage, - getStats, - KEY_PREFIX, - extractPotluckKey, - isPotluckRequest - } -}; - -export default apiPotluckPlugin; - -// 也导出命名导出,方便直接引用 -export { - createKey, - listKeys, - getKey, - deleteKey, - updateKeyLimit, - resetKeyUsage, - toggleKey, - updateKeyName, - validateKey, - incrementUsage, - getStats, - KEY_PREFIX, - extractPotluckKey, - isPotluckRequest -}; diff --git a/src/plugins/api-potluck/key-manager.js b/src/plugins/api-potluck/key-manager.js deleted file mode 100644 index b2f0ec1eb2d25ddb48b8a91c64cbab7f56cc79de..0000000000000000000000000000000000000000 --- a/src/plugins/api-potluck/key-manager.js +++ /dev/null @@ -1,486 +0,0 @@ -/** - * API 大锅饭 - Key 管理模块 - * 使用内存缓存 + 写锁 + 定期持久化,解决并发安全问题 - */ - -import { promises as fs } from 'fs'; -import logger from '../../utils/logger.js'; -import { existsSync, readFileSync, writeFileSync } from 'fs'; -import path from 'path'; -import crypto from 'crypto'; - -// 配置文件路径 -const KEYS_STORE_FILE = path.join(process.cwd(), 'configs', 'api-potluck-keys.json'); -const KEY_PREFIX = 'maki_'; - -// 默认配置 -const DEFAULT_CONFIG = { - defaultDailyLimit: 500, - persistInterval: 5000 -}; - -// 配置获取函数(由外部注入) -let configGetter = null; - -/** - * 设置配置获取函数 - * @param {Function} getter - 返回配置对象的函数 - */ -export function setConfigGetter(getter) { - configGetter = getter; -} - -/** - * 获取当前配置 - */ -function getConfig() { - if (configGetter) { - return configGetter(); - } - return DEFAULT_CONFIG; -} - -// 内存缓存 -let keyStore = null; -let isDirty = false; -let isWriting = false; -let persistTimer = null; -let currentPersistInterval = DEFAULT_CONFIG.persistInterval; - -/** - * 初始化:从文件加载数据到内存 - */ -function ensureLoaded() { - if (keyStore !== null) return; - try { - if (existsSync(KEYS_STORE_FILE)) { - const content = readFileSync(KEYS_STORE_FILE, 'utf8'); - keyStore = JSON.parse(content); - } else { - keyStore = { keys: {} }; - syncWriteToFile(); - } - } catch (error) { - logger.error('[API Potluck] Failed to load key store:', error.message); - keyStore = { keys: {} }; - } - - // 获取配置的持久化间隔 - const config = getConfig(); - currentPersistInterval = config.persistInterval || DEFAULT_CONFIG.persistInterval; - - // 启动定期持久化 - if (!persistTimer) { - persistTimer = setInterval(persistIfDirty, currentPersistInterval); - // 进程退出时保存 - process.on('beforeExit', () => persistIfDirty()); - process.on('SIGINT', () => { persistIfDirty(); process.exit(0); }); - process.on('SIGTERM', () => { persistIfDirty(); process.exit(0); }); - } -} - -/** - * 同步写入文件(仅初始化时使用) - */ -function syncWriteToFile() { - try { - const dir = path.dirname(KEYS_STORE_FILE); - if (!existsSync(dir)) { - require('fs').mkdirSync(dir, { recursive: true }); - } - writeFileSync(KEYS_STORE_FILE, JSON.stringify(keyStore, null, 2), 'utf8'); - } catch (error) { - logger.error('[API Potluck] Sync write failed:', error.message); - } -} - -/** - * 异步持久化(带写锁) - */ -async function persistIfDirty() { - if (!isDirty || isWriting || keyStore === null) return; - isWriting = true; - try { - const dir = path.dirname(KEYS_STORE_FILE); - if (!existsSync(dir)) { - await fs.mkdir(dir, { recursive: true }); - } - // 写入临时文件再重命名,防止写入中断导致文件损坏 - const tempFile = KEYS_STORE_FILE + '.tmp'; - await fs.writeFile(tempFile, JSON.stringify(keyStore, null, 2), 'utf8'); - await fs.rename(tempFile, KEYS_STORE_FILE); - isDirty = false; - } catch (error) { - logger.error('[API Potluck] Persist failed:', error.message); - } finally { - isWriting = false; - } -} - -/** - * 标记数据已修改 - */ -function markDirty() { - isDirty = true; -} - -/** - * 生成随机 API Key(确保不重复) - */ -function generateApiKey() { - ensureLoaded(); - let apiKey; - let attempts = 0; - const maxAttempts = 10; - - do { - apiKey = `${KEY_PREFIX}${crypto.randomBytes(16).toString('hex')}`; - attempts++; - if (attempts >= maxAttempts) { - throw new Error('Failed to generate unique API key after multiple attempts'); - } - } while (keyStore.keys[apiKey]); - - return apiKey; -} - -/** - * 获取今天的日期字符串 (YYYY-MM-DD) - */ -function getTodayDateString() { - const now = new Date(); - return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`; -} - -/** - * 检查并重置过期的每日计数 - */ -function checkAndResetDailyCount(keyData) { - const today = getTodayDateString(); - if (keyData.lastResetDate !== today) { - keyData.todayUsage = 0; - keyData.lastResetDate = today; - } - return keyData; -} - -/** - * 创建新的 API Key - - * @param {string} name - Key 名称 - * @param {number} [dailyLimit] - 每日限额,不传则使用配置的默认值 - */ -export async function createKey(name = '', dailyLimit = null) { - ensureLoaded(); - const config = getConfig(); - const actualDailyLimit = dailyLimit ?? config.defaultDailyLimit ?? DEFAULT_CONFIG.defaultDailyLimit; - - const apiKey = generateApiKey(); - const now = new Date().toISOString(); - const today = getTodayDateString(); - - const keyData = { - id: apiKey, - name: name || `Key-${Object.keys(keyStore.keys).length + 1}`, - createdAt: now, - dailyLimit: actualDailyLimit, - todayUsage: 0, - totalUsage: 0, - lastResetDate: today, - lastUsedAt: null, - enabled: true - }; - - keyStore.keys[apiKey] = keyData; - markDirty(); - await persistIfDirty(); // 创建操作立即持久化 - - logger.info(`[API Potluck] Created key: ${apiKey.substring(0, 12)}...`); - return keyData; -} - -/** - * 获取所有 Key 列表 - */ -export async function listKeys() { - ensureLoaded(); - const keys = []; - for (const [keyId, keyData] of Object.entries(keyStore.keys)) { - const updated = checkAndResetDailyCount({ ...keyData }); - keys.push({ - ...updated, - maskedKey: `${keyId.substring(0, 12)}...${keyId.substring(keyId.length - 4)}` - }); - } - return keys; -} - -/** - * 获取单个 Key 详情 - */ -export async function getKey(keyId) { - ensureLoaded(); - const keyData = keyStore.keys[keyId]; - if (!keyData) return null; - return checkAndResetDailyCount({ ...keyData }); -} - -/** - * 删除 Key - */ -export async function deleteKey(keyId) { - ensureLoaded(); - if (!keyStore.keys[keyId]) return false; - delete keyStore.keys[keyId]; - markDirty(); - await persistIfDirty(); // 删除操作立即持久化 - logger.info(`[API Potluck] Deleted key: ${keyId.substring(0, 12)}...`); - return true; -} - -/** - * 更新 Key 的每日限额 - */ -export async function updateKeyLimit(keyId, newLimit) { - ensureLoaded(); - if (!keyStore.keys[keyId]) return null; - keyStore.keys[keyId].dailyLimit = newLimit; - markDirty(); - return keyStore.keys[keyId]; -} - -/** - * 重置 Key 的当天调用次数 - */ -export async function resetKeyUsage(keyId) { - ensureLoaded(); - if (!keyStore.keys[keyId]) return null; - keyStore.keys[keyId].todayUsage = 0; - keyStore.keys[keyId].lastResetDate = getTodayDateString(); - markDirty(); - return keyStore.keys[keyId]; -} - -/** - * 切换 Key 的启用/禁用状态 - */ -export async function toggleKey(keyId) { - ensureLoaded(); - if (!keyStore.keys[keyId]) return null; - keyStore.keys[keyId].enabled = !keyStore.keys[keyId].enabled; - markDirty(); - return keyStore.keys[keyId]; -} - -/** - * 更新 Key 名称 - */ -export async function updateKeyName(keyId, newName) { - ensureLoaded(); - if (!keyStore.keys[keyId]) return null; - keyStore.keys[keyId].name = newName; - markDirty(); - return keyStore.keys[keyId]; -} - -/** - * 重新生成 API Key(保留原有数据,更换 Key ID) - * @param {string} oldKeyId - 原 Key ID - * @returns {Promise<{oldKey: string, newKey: string, keyData: Object}|null>} - */ -export async function regenerateKey(oldKeyId) { - ensureLoaded(); - const oldKeyData = keyStore.keys[oldKeyId]; - if (!oldKeyData) return null; - - // 生成新的唯一 Key - const newKeyId = generateApiKey(); - - // 复制数据到新 Key - const newKeyData = { - ...oldKeyData, - id: newKeyId, - regeneratedAt: new Date().toISOString(), - regeneratedFrom: oldKeyId.substring(0, 12) + '...' - }; - - // 删除旧 Key,添加新 Key - delete keyStore.keys[oldKeyId]; - keyStore.keys[newKeyId] = newKeyData; - - markDirty(); - await persistIfDirty(); // 立即持久化 - - logger.info(`[API Potluck] Regenerated key: ${oldKeyId.substring(0, 12)}... -> ${newKeyId.substring(0, 12)}...`); - - return { - oldKey: oldKeyId, - newKey: newKeyId, - keyData: newKeyData - }; -} - -/** - * 验证 API Key 是否有效且有配额 - */ -export async function validateKey(apiKey) { - ensureLoaded(); - if (!apiKey || !apiKey.startsWith(KEY_PREFIX)) { - return { valid: false, reason: 'invalid_format' }; - } - const keyData = keyStore.keys[apiKey]; - if (!keyData) return { valid: false, reason: 'not_found' }; - if (!keyData.enabled) return { valid: false, reason: 'disabled' }; - - // 直接在内存中检查和重置 - checkAndResetDailyCount(keyData); - - // 检查每日限额 - if (keyData.todayUsage < keyData.dailyLimit) { - return { valid: true, keyData }; - } - - return { valid: false, reason: 'quota_exceeded', keyData }; -} - -/** - * 增加 Key 的使用次数(原子操作,直接修改内存) - * @param {string} apiKey - API Key - * @param {string} provider - 使用的提供商 - * @param {string} model - 使用的模型 - */ -export async function incrementUsage(apiKey, provider = 'unknown', model = 'unknown') { - ensureLoaded(); - const keyData = keyStore.keys[apiKey]; - if (!keyData) return null; - - checkAndResetDailyCount(keyData); - - // 消耗每日限额 - if (keyData.todayUsage < keyData.dailyLimit) { - keyData.todayUsage += 1; - } else { - // 每日限额用尽 - return null; - } - - keyData.totalUsage += 1; - keyData.lastUsedAt = new Date().toISOString(); - - // 记录个人按天统计 (每个 Key 独立) - const today = getTodayDateString(); - if (!keyData.usageHistory) keyData.usageHistory = {}; - if (!keyData.usageHistory[today]) { - keyData.usageHistory[today] = { providers: {}, models: {} }; - } - - // 确保 provider 和 model 是字符串 - const pName = String(provider || 'unknown'); - const mName = String(model || 'unknown'); - - const userHistory = keyData.usageHistory[today]; - userHistory.providers[pName] = (userHistory.providers[pName] || 0) + 1; - userHistory.models[mName] = (userHistory.models[mName] || 0) + 1; - - // 清理该 Key 的过期历史 (保留 7 天) - const userDates = Object.keys(keyData.usageHistory).sort(); - if (userDates.length > 7) { - const dropDates = userDates.slice(0, userDates.length - 7); - dropDates.forEach(d => delete keyData.usageHistory[d]); - } - - markDirty(); - - return { - ...keyData, - usedBonus: false - }; -} - -/** - * 获取统计信息 - */ -export async function getStats() { - ensureLoaded(); - const keys = Object.values(keyStore.keys); - let enabledKeys = 0, todayTotalUsage = 0, totalUsage = 0; - const aggregatedHistory = {}; - - for (const key of keys) { - checkAndResetDailyCount(key); - if (key.enabled) enabledKeys++; - todayTotalUsage += key.todayUsage; - totalUsage += key.totalUsage; - - // 汇总每个 Key 的历史数据 - if (key.usageHistory) { - Object.entries(key.usageHistory).forEach(([date, history]) => { - if (!aggregatedHistory[date]) { - aggregatedHistory[date] = { providers: {}, models: {} }; - } - - // 汇总提供商 - if (history.providers) { - Object.entries(history.providers).forEach(([p, count]) => { - aggregatedHistory[date].providers[p] = (aggregatedHistory[date].providers[p] || 0) + count; - }); - } - - // 汇总模型 - if (history.models) { - Object.entries(history.models).forEach(([m, count]) => { - aggregatedHistory[date].models[m] = (aggregatedHistory[date].models[m] || 0) + count; - }); - } - }); - } - } - - return { - totalKeys: keys.length, - enabledKeys, - disabledKeys: keys.length - enabledKeys, - todayTotalUsage, - totalUsage, - usageHistory: aggregatedHistory - }; -} - - -/** - * 批量更新所有 Key 的每日限额 - * @param {number} newLimit - 新的每日限额 - * @returns {Promise<{total: number, updated: number}>} - */ -export async function applyDailyLimitToAllKeys(newLimit) { - ensureLoaded(); - const keys = Object.values(keyStore.keys); - let updated = 0; - - for (const keyData of keys) { - if (keyData.dailyLimit !== newLimit) { - keyData.dailyLimit = newLimit; - updated++; - } - } - - if (updated > 0) { - markDirty(); - await persistIfDirty(); - } - - logger.info(`[API Potluck] Applied daily limit ${newLimit} to ${updated}/${keys.length} keys`); - return { total: keys.length, updated }; -} - -/** - * 获取所有 Key ID 列表 - * @returns {string[]} - */ -export function getAllKeyIds() { - ensureLoaded(); - return Object.keys(keyStore.keys); -} - -// 导出常量 -export { KEY_PREFIX }; diff --git a/src/plugins/api-potluck/middleware.js b/src/plugins/api-potluck/middleware.js deleted file mode 100644 index 146d965d3c1ecf19944d2ddc938d9e38df370992..0000000000000000000000000000000000000000 --- a/src/plugins/api-potluck/middleware.js +++ /dev/null @@ -1,166 +0,0 @@ -/** - * API 大锅饭 - 中间件模块 - * 负责请求拦截和配额检查 - */ - -import { validateKey, incrementUsage, KEY_PREFIX } from './key-manager.js'; -import logger from '../../utils/logger.js'; - -/** - * 从请求中提取 Potluck API Key - * 支持多种认证方式: - * 1. Authorization: Bearer maki_xxx - * 2. x-api-key: maki_xxx - * 3. x-goog-api-key: maki_xxx - * 4. URL query: ?key=maki_xxx - * - * @param {http.IncomingMessage} req - HTTP 请求对象 - * @param {URL} requestUrl - 解析后的 URL 对象 - * @returns {string|null} 提取到的 API Key,如果不是 potluck key 则返回 null - */ -export function extractPotluckKey(req, requestUrl) { - // 1. 检查 Authorization header - const authHeader = req.headers['authorization']; - if (authHeader && authHeader.startsWith('Bearer ')) { - const token = authHeader.substring(7); - if (token.startsWith(KEY_PREFIX)) { - return token; - } - } - - // 2. 检查 x-api-key header (Claude style) - const xApiKey = req.headers['x-api-key']; - if (xApiKey && xApiKey.startsWith(KEY_PREFIX)) { - return xApiKey; - } - - // 3. 检查 x-goog-api-key header (Gemini style) - const googApiKey = req.headers['x-goog-api-key']; - if (googApiKey && googApiKey.startsWith(KEY_PREFIX)) { - return googApiKey; - } - - // 4. 检查 URL query parameter - const queryKey = requestUrl.searchParams.get('key'); - if (queryKey && queryKey.startsWith(KEY_PREFIX)) { - return queryKey; - } - - return null; -} - -/** - * 检查请求是否使用 Potluck Key - * @param {http.IncomingMessage} req - HTTP 请求对象 - * @param {URL} requestUrl - 解析后的 URL 对象 - * @returns {boolean} - */ -export function isPotluckRequest(req, requestUrl) { - return extractPotluckKey(req, requestUrl) !== null; -} - -/** - * Potluck 认证中间件 - * 验证 Potluck API Key 并检查配额 - * - * @param {http.IncomingMessage} req - HTTP 请求对象 - * @param {URL} requestUrl - 解析后的 URL 对象 - * @returns {Promise<{authorized: boolean, error?: Object, keyData?: Object, apiKey?: string}>} - */ -export async function potluckAuthMiddleware(req, requestUrl) { - const apiKey = extractPotluckKey(req, requestUrl); - - if (!apiKey) { - // 不是 potluck 请求,返回 null 让原有逻辑处理 - return { authorized: null }; - } - - // 验证 Key - const validation = await validateKey(apiKey); - - if (!validation.valid) { - const errorMessages = { - 'invalid_format': 'Invalid API key format', - 'not_found': 'API key not found', - 'disabled': 'API key has been disabled', - 'quota_exceeded': 'Quota exceeded for this API key' - }; - - const statusCodes = { - 'invalid_format': 401, - 'not_found': 401, - 'disabled': 403, - 'quota_exceeded': 429 - }; - - return { - authorized: false, - error: { - statusCode: statusCodes[validation.reason] || 401, - message: errorMessages[validation.reason] || 'Authentication failed', - code: validation.reason, - keyData: validation.keyData - } - }; - } - - return { - authorized: true, - keyData: validation.keyData, - apiKey: apiKey - }; -} - -/** - * 记录 Potluck 请求使用 - * 在请求成功处理后调用 - * - * @param {string} apiKey - API Key - * @returns {Promise} - */ -export async function recordPotluckUsage(apiKey) { - if (!apiKey || !apiKey.startsWith(KEY_PREFIX)) { - return null; - } - return incrementUsage(apiKey); -} - -/** - * 创建 Potluck 错误响应 - * @param {http.ServerResponse} res - HTTP 响应对象 - * @param {Object} error - 错误信息 - */ -export function sendPotluckError(res, error) { - const response = { - error: { - message: error.message, - code: error.code, - type: 'potluck_error' - } - }; - - // 如果是配额超限,添加额外信息 - if (error.code === 'quota_exceeded' && error.keyData) { - response.error.quota = { - used: error.keyData.todayUsage, - limit: error.keyData.dailyLimit, - resetDate: error.keyData.lastResetDate - }; - } - - // 检查响应流是否已关闭 - if (res.writableEnded || res.destroyed) { - logger.warn('[API Potluck] Response already ended, skipping error response'); - return; - } - - if (!res.headersSent) { - res.writeHead(error.statusCode, { 'Content-Type': 'application/json' }); - } - - try { - res.end(JSON.stringify(response)); - } catch (writeError) { - logger.error('[API Potluck] Failed to write error response:', writeError.message); - } -} diff --git a/src/plugins/api-potluck/user-data-manager.js b/src/plugins/api-potluck/user-data-manager.js deleted file mode 100644 index ae8775ca6c7c9e2796a823a0b4a867efe68b1089..0000000000000000000000000000000000000000 --- a/src/plugins/api-potluck/user-data-manager.js +++ /dev/null @@ -1,6 +0,0 @@ -/** - * API 大锅饭 - 用户数据管理模块 - * 注意:此模块已禁用,所有凭证管理和资源包相关功能已移除 - */ - -// 空模块,保留文件以避免导入错误 diff --git a/src/plugins/default-auth/index.js b/src/plugins/default-auth/index.js deleted file mode 100644 index 26bce8174ac017ad1aa020bbcbddaa8d75b6e08a..0000000000000000000000000000000000000000 --- a/src/plugins/default-auth/index.js +++ /dev/null @@ -1,93 +0,0 @@ -/** - * 默认认证插件 - 内置插件 - * - * 提供基于 API Key 的默认认证机制 - * 支持多种认证方式: - * 1. Authorization: Bearer - * 2. x-api-key: - * 3. x-goog-api-key: - * 4. URL query: ?key= - */ - -import logger from '../../utils/logger.js'; - -/** - * 检查请求是否已授权 - * @param {http.IncomingMessage} req - HTTP 请求 - * @param {URL} requestUrl - 解析后的 URL - * @param {string} requiredApiKey - 所需的 API Key - * @returns {boolean} - */ -function isAuthorized(req, requestUrl, requiredApiKey) { - const authHeader = req.headers['authorization']; - const queryKey = requestUrl.searchParams.get('key'); - const googApiKey = req.headers['x-goog-api-key']; - const claudeApiKey = req.headers['x-api-key']; - - // Check for Bearer token in Authorization header (OpenAI style) - if (authHeader && authHeader.startsWith('Bearer ')) { - const token = authHeader.substring(7); - if (token === requiredApiKey) { - return true; - } - } - - // Check for API key in URL query parameter (Gemini style) - if (queryKey === requiredApiKey) { - return true; - } - - // Check for API key in x-goog-api-key header (Gemini style) - if (googApiKey === requiredApiKey) { - return true; - } - - // Check for API key in x-api-key header (Claude style) - if (claudeApiKey === requiredApiKey) { - return true; - } - - return false; -} - -/** - * 默认认证插件定义 - */ -const defaultAuthPlugin = { - name: 'default-auth', - version: '1.0.0', - description: '默认 API Key 认证插件', - - // 插件类型:认证插件 - type: 'auth', - - // 标记为内置插件,优先级最低(最后执行) - _builtin: true, - _priority: 9999, - - /** - * 认证方法 - 默认 API Key 认证 - * @param {http.IncomingMessage} req - HTTP 请求 - * @param {http.ServerResponse} res - HTTP 响应 - * @param {URL} requestUrl - 解析后的 URL - * @param {Object} config - 服务器配置 - * @returns {Promise<{handled: boolean, authorized: boolean|null}>} - */ - async authenticate(req, res, requestUrl, config) { - // 执行默认认证 - if (isAuthorized(req, requestUrl, config.REQUIRED_API_KEY)) { - // 认证成功 - return { handled: false, authorized: true }; - } - - // 认证失败,记录日志但不发送响应(由 request-handler 统一处理) - logger.info(`[Default Auth] Unauthorized request. Headers: Authorization=${req.headers['authorization'] ? 'present' : 'N/A'}, x-api-key=${req.headers['x-api-key'] || 'N/A'}, x-goog-api-key=${req.headers['x-goog-api-key'] || 'N/A'}`); - - // 返回 null 表示此插件不授权,让其他插件或默认逻辑处理 - return { handled: false, authorized: null }; - } -}; - -export default defaultAuthPlugin; - - diff --git a/src/providers/adapter.js b/src/providers/adapter.js deleted file mode 100644 index bfaceacd38363519585b260156cbfc71b6c2e5b6..0000000000000000000000000000000000000000 --- a/src/providers/adapter.js +++ /dev/null @@ -1,724 +0,0 @@ -import { OpenAIResponsesApiService } from './openai/openai-responses-core.js'; -import { GeminiApiService } from './gemini/gemini-core.js'; -import { AntigravityApiService } from './gemini/antigravity-core.js'; -import { OpenAIApiService } from './openai/openai-core.js'; -import { ClaudeApiService } from './claude/claude-core.js'; -import { KiroApiService } from './claude/claude-kiro.js'; -import { QwenApiService } from './openai/qwen-core.js'; -import { IFlowApiService } from './openai/iflow-core.js'; -import { CodexApiService } from './openai/codex-core.js'; -import { ForwardApiService } from './forward/forward-core.js'; -import { GrokApiService } from './grok/grok-core.js'; -import { MODEL_PROVIDER } from '../utils/common.js'; -import logger from '../utils/logger.js'; - -// 适配器注册表 -const adapterRegistry = new Map(); - -/** - * 注册服务适配器 - * @param {string} provider - 提供商名称 (来自 MODEL_PROVIDER) - * @param {typeof ApiServiceAdapter} adapterClass - 适配器类 - */ -export function registerAdapter(provider, adapterClass) { - logger.info(`[Adapter] Registering adapter for provider: ${provider}`); - adapterRegistry.set(provider, adapterClass); -} - -/** - * 获取所有已注册的提供商 - * @returns {string[]} 已注册的提供商名称列表 - */ -export function getRegisteredProviders() { - return Array.from(adapterRegistry.keys()); -} - -// 定义AI服务适配器接口 -// 所有的服务适配器都应该实现这些方法 -export class ApiServiceAdapter { - constructor() { - if (new.target === ApiServiceAdapter) { - throw new TypeError("Cannot construct ApiServiceAdapter instances directly"); - } - } - - /** - * 生成内容 - * @param {string} model - 模型名称 - * @param {object} requestBody - 请求体 - * @returns {Promise} - API响应 - */ - async generateContent(model, requestBody) { - throw new Error("Method 'generateContent()' must be implemented."); - } - - /** - * 流式生成内容 - * @param {string} model - 模型名称 - * @param {object} requestBody - 请求体 - * @returns {AsyncIterable} - API响应流 - */ - async *generateContentStream(model, requestBody) { - throw new Error("Method 'generateContentStream()' must be implemented."); - } - - /** - * 列出可用模型 - * @returns {Promise} - 模型列表 - */ - async listModels() { - throw new Error("Method 'listModels()' must be implemented."); - } - - /** - * 刷新认证令牌 - * @returns {Promise} - */ - async refreshToken() { - throw new Error("Method 'refreshToken()' must be implemented."); - } - - /** - * 强制刷新认证令牌(不判断是否接近过期) - * @returns {Promise} - */ - async forceRefreshToken() { - throw new Error("Method 'forceRefreshToken()' must be implemented."); - } - - /** - * 判断日期是否接近过期 - * @returns {boolean} - */ - isExpiryDateNear() { - throw new Error("Method 'isExpiryDateNear()' must be implemented."); - } -} - -// Gemini API 服务适配器 -export class GeminiApiServiceAdapter extends ApiServiceAdapter { - constructor(config) { - super(); - this.geminiApiService = new GeminiApiService(config); - // this.geminiApiService.initialize().catch(error => { - // logger.error("Failed to initialize geminiApiService:", error); - // }); - } - - async generateContent(model, requestBody) { - if (!this.geminiApiService.isInitialized) { - logger.warn("geminiApiService not initialized, attempting to re-initialize..."); - await this.geminiApiService.initialize(); - } - return this.geminiApiService.generateContent(model, requestBody); - } - - async *generateContentStream(model, requestBody) { - if (!this.geminiApiService.isInitialized) { - logger.warn("geminiApiService not initialized, attempting to re-initialize..."); - await this.geminiApiService.initialize(); - } - yield* this.geminiApiService.generateContentStream(model, requestBody); - } - - async listModels() { - if (!this.geminiApiService.isInitialized) { - logger.warn("geminiApiService not initialized, attempting to re-initialize..."); - await this.geminiApiService.initialize(); - } - // Gemini Core API 的 listModels 已经返回符合 Gemini 格式的数据,所以不需要额外转换 - return this.geminiApiService.listModels(); - } - - async refreshToken() { - if (!this.geminiApiService.isInitialized) { - await this.geminiApiService.initialize(); - } - if(this.isExpiryDateNear()===true){ - logger.info(`[Gemini] Expiry date is near, refreshing token...`); - return this.geminiApiService.initializeAuth(true); - } - return Promise.resolve(); - } - - async forceRefreshToken() { - if (!this.geminiApiService.isInitialized) { - await this.geminiApiService.initialize(); - } - logger.info(`[Gemini] Force refreshing token...`); - return this.geminiApiService.initializeAuth(true); - } - - isExpiryDateNear() { - return this.geminiApiService.isExpiryDateNear(); - } - - /** - * 获取用量限制信息 - * @returns {Promise} 用量限制信息 - */ - async getUsageLimits() { - if (!this.geminiApiService.isInitialized) { - logger.warn("geminiApiService not initialized, attempting to re-initialize..."); - await this.geminiApiService.initialize(); - } - return this.geminiApiService.getUsageLimits(); - } -} - -// Antigravity API 服务适配器 -export class AntigravityApiServiceAdapter extends ApiServiceAdapter { - constructor(config) { - super(); - this.antigravityApiService = new AntigravityApiService(config); - } - - async generateContent(model, requestBody) { - if (!this.antigravityApiService.isInitialized) { - logger.warn("antigravityApiService not initialized, attempting to re-initialize..."); - await this.antigravityApiService.initialize(); - } - return this.antigravityApiService.generateContent(model, requestBody); - } - - async *generateContentStream(model, requestBody) { - if (!this.antigravityApiService.isInitialized) { - logger.warn("antigravityApiService not initialized, attempting to re-initialize..."); - await this.antigravityApiService.initialize(); - } - yield* this.antigravityApiService.generateContentStream(model, requestBody); - } - - async listModels() { - if (!this.antigravityApiService.isInitialized) { - logger.warn("antigravityApiService not initialized, attempting to re-initialize..."); - await this.antigravityApiService.initialize(); - } - return this.antigravityApiService.listModels(); - } - - async refreshToken() { - if (!this.antigravityApiService.isInitialized) { - await this.antigravityApiService.initialize(); - } - if (this.isExpiryDateNear() === true) { - logger.info(`[Antigravity] Expiry date is near, refreshing token...`); - return this.antigravityApiService.initializeAuth(true); - } - return Promise.resolve(); - } - - async forceRefreshToken() { - if (!this.antigravityApiService.isInitialized) { - await this.antigravityApiService.initialize(); - } - logger.info(`[Antigravity] Force refreshing token...`); - return this.antigravityApiService.initializeAuth(true); - } - - isExpiryDateNear() { - return this.antigravityApiService.isExpiryDateNear(); - } - - /** - * 获取用量限制信息 - * @returns {Promise} 用量限制信息 - */ - async getUsageLimits() { - if (!this.antigravityApiService.isInitialized) { - logger.warn("antigravityApiService not initialized, attempting to re-initialize..."); - await this.antigravityApiService.initialize(); - } - return this.antigravityApiService.getUsageLimits(); - } -} - -// OpenAI API 服务适配器 -export class OpenAIApiServiceAdapter extends ApiServiceAdapter { - constructor(config) { - super(); - this.openAIApiService = new OpenAIApiService(config); - } - - async generateContent(model, requestBody) { - // The adapter now expects the requestBody to be in the native OpenAI format. - // The conversion logic is handled upstream in the server. - return this.openAIApiService.generateContent(model, requestBody); - } - - async *generateContentStream(model, requestBody) { - // The adapter now expects the requestBody to be in the native OpenAI format. - const stream = this.openAIApiService.generateContentStream(model, requestBody); - // The stream is yielded directly without conversion. - yield* stream; - } - - async listModels() { - // The adapter now returns the native model list from the underlying service. - return this.openAIApiService.listModels(); - } - - async refreshToken() { - // OpenAI API keys are typically static and do not require refreshing. - return Promise.resolve(); - } - - async forceRefreshToken() { - // OpenAI API keys are typically static and do not require refreshing. - return Promise.resolve(); - } - - isExpiryDateNear() { - return false; - } -} - -// OpenAI Responses API 服务适配器 -export class OpenAIResponsesApiServiceAdapter extends ApiServiceAdapter { - constructor(config) { - super(); - this.openAIResponsesApiService = new OpenAIResponsesApiService(config); - } - - async generateContent(model, requestBody) { - // The adapter expects the requestBody to be in the OpenAI Responses format. - return this.openAIResponsesApiService.generateContent(model, requestBody); - } - - async *generateContentStream(model, requestBody) { - // The adapter expects the requestBody to be in the OpenAI Responses format. - const stream = this.openAIResponsesApiService.generateContentStream(model, requestBody); - yield* stream; - } - - async listModels() { - // The adapter returns the native model list from the underlying service. - return this.openAIResponsesApiService.listModels(); - } - - async refreshToken() { - // OpenAI API keys are typically static and do not require refreshing. - return Promise.resolve(); - } - - async forceRefreshToken() { - // OpenAI API keys are typically static and do not require refreshing. - return Promise.resolve(); - } - - isExpiryDateNear() { - return false; - } -} - -// Claude API 服务适配器 -export class ClaudeApiServiceAdapter extends ApiServiceAdapter { - constructor(config) { - super(); - this.claudeApiService = new ClaudeApiService(config); - } - - async generateContent(model, requestBody) { - // The adapter now expects the requestBody to be in the native Claude format. - return this.claudeApiService.generateContent(model, requestBody); - } - - async *generateContentStream(model, requestBody) { - // The adapter now expects the requestBody to be in the native Claude format. - const stream = this.claudeApiService.generateContentStream(model, requestBody); - yield* stream; - } - - async listModels() { - // The adapter now returns the native model list from the underlying service. - return this.claudeApiService.listModels(); - } - - async refreshToken() { - return Promise.resolve(); - } - - async forceRefreshToken() { - return Promise.resolve(); - } - - isExpiryDateNear() { - return false; - } -} - -// Kiro API 服务适配器 -export class KiroApiServiceAdapter extends ApiServiceAdapter { - constructor(config) { - super(); - this.kiroApiService = new KiroApiService(config); - // this.kiroApiService.initialize().catch(error => { - // logger.error("Failed to initialize kiroApiService:", error); - // }); - } - - async generateContent(model, requestBody) { - // The adapter expects the requestBody to be in OpenAI format for Kiro API - if (!this.kiroApiService.isInitialized) { - logger.warn("kiroApiService not initialized, attempting to re-initialize..."); - await this.kiroApiService.initialize(); - } - return this.kiroApiService.generateContent(model, requestBody); - } - - async *generateContentStream(model, requestBody) { - // The adapter expects the requestBody to be in OpenAI format for Kiro API - if (!this.kiroApiService.isInitialized) { - logger.warn("kiroApiService not initialized, attempting to re-initialize..."); - await this.kiroApiService.initialize(); - } - const stream = this.kiroApiService.generateContentStream(model, requestBody); - yield* stream; - } - - async listModels() { - // Returns the native model list from the Kiro service - if (!this.kiroApiService.isInitialized) { - logger.warn("kiroApiService not initialized, attempting to re-initialize..."); - await this.kiroApiService.initialize(); - } - return this.kiroApiService.listModels(); - } - - async refreshToken() { - if (!this.kiroApiService.isInitialized) { - await this.kiroApiService.initialize(); - } - if(this.isExpiryDateNear()===true){ - logger.info(`[Kiro] Expiry date is near, refreshing token...`); - return this.kiroApiService.initializeAuth(true); - } - return Promise.resolve(); - } - - async forceRefreshToken() { - if (!this.kiroApiService.isInitialized) { - await this.kiroApiService.initialize(); - } - logger.info(`[Kiro] Force refreshing token...`); - return this.kiroApiService.initializeAuth(true); - } - - isExpiryDateNear() { - return this.kiroApiService.isExpiryDateNear(); - } - - /** - * 获取用量限制信息 - * @returns {Promise} 用量限制信息 - */ - async getUsageLimits() { - if (!this.kiroApiService.isInitialized) { - logger.warn("kiroApiService not initialized, attempting to re-initialize..."); - await this.kiroApiService.initialize(); - } - return this.kiroApiService.getUsageLimits(); - } - - /** - * Count tokens for a message request (compatible with Anthropic API) - * @param {Object} requestBody - The request body containing model, messages, system, tools, etc. - * @returns {Object} { input_tokens: number } - */ - countTokens(requestBody) { - return this.kiroApiService.countTokens(requestBody); - } -} - -// Qwen API 服务适配器 -export class QwenApiServiceAdapter extends ApiServiceAdapter { - constructor(config) { - super(); - this.qwenApiService = new QwenApiService(config); - } - - async generateContent(model, requestBody) { - if (!this.qwenApiService.isInitialized) { - logger.warn("qwenApiService not initialized, attempting to re-initialize..."); - await this.qwenApiService.initialize(); - } - return this.qwenApiService.generateContent(model, requestBody); - } - - async *generateContentStream(model, requestBody) { - if (!this.qwenApiService.isInitialized) { - logger.warn("qwenApiService not initialized, attempting to re-initialize..."); - await this.qwenApiService.initialize(); - } - yield* this.qwenApiService.generateContentStream(model, requestBody); - } - - async listModels() { - if (!this.qwenApiService.isInitialized) { - logger.warn("qwenApiService not initialized, attempting to re-initialize..."); - await this.qwenApiService.initialize(); - } - return this.qwenApiService.listModels(); - } - - async refreshToken() { - if (!this.qwenApiService.isInitialized) { - await this.qwenApiService.initialize(); - } - if (this.isExpiryDateNear()) { - logger.info(`[Qwen] Expiry date is near, refreshing token...`); - return this.qwenApiService._initializeAuth(true); - } - return Promise.resolve(); - } - - async forceRefreshToken() { - if (!this.qwenApiService.isInitialized) { - await this.qwenApiService.initialize(); - } - logger.info(`[Qwen] Force refreshing token...`); - return this.qwenApiService._initializeAuth(true); - } - - isExpiryDateNear() { - return this.qwenApiService.isExpiryDateNear(); - } -} - -// iFlow API 服务适配器 -export class IFlowApiServiceAdapter extends ApiServiceAdapter { - constructor(config) { - super(); - this.iflowApiService = new IFlowApiService(config); - } - - async generateContent(model, requestBody) { - if (!this.iflowApiService.isInitialized) { - logger.warn("iflowApiService not initialized, attempting to re-initialize..."); - await this.iflowApiService.initialize(); - } - return this.iflowApiService.generateContent(model, requestBody); - } - - async *generateContentStream(model, requestBody) { - if (!this.iflowApiService.isInitialized) { - logger.warn("iflowApiService not initialized, attempting to re-initialize..."); - await this.iflowApiService.initialize(); - } - yield* this.iflowApiService.generateContentStream(model, requestBody); - } - - async listModels() { - if (!this.iflowApiService.isInitialized) { - logger.warn("iflowApiService not initialized, attempting to re-initialize..."); - await this.iflowApiService.initialize(); - } - return this.iflowApiService.listModels(); - } - - async refreshToken() { - if (!this.iflowApiService.isInitialized) { - await this.iflowApiService.initialize(); - } - if (this.isExpiryDateNear()) { - logger.info(`[iFlow] Expiry date is near, refreshing API key...`); - await this.iflowApiService.initializeAuth(true); - } - return Promise.resolve(); - } - - async forceRefreshToken() { - if (!this.iflowApiService.isInitialized) { - await this.iflowApiService.initialize(); - } - logger.info(`[iFlow] Force refreshing API key...`); - return this.iflowApiService.initializeAuth(true); - } - - isExpiryDateNear() { - return this.iflowApiService.isExpiryDateNear(); - } - -} - -// Codex API 服务适配器 -export class CodexApiServiceAdapter extends ApiServiceAdapter { - constructor(config) { - super(); - this.codexApiService = new CodexApiService(config); - } - - async generateContent(model, requestBody) { - if (!this.codexApiService.isInitialized) { - logger.warn("codexApiService not initialized, attempting to re-initialize..."); - await this.codexApiService.initialize(); - } - return this.codexApiService.generateContent(model, requestBody); - } - - async *generateContentStream(model, requestBody) { - if (!this.codexApiService.isInitialized) { - logger.warn("codexApiService not initialized, attempting to re-initialize..."); - await this.codexApiService.initialize(); - } - yield* this.codexApiService.generateContentStream(model, requestBody); - } - - async listModels() { - return this.codexApiService.listModels(); - } - - async refreshToken() { - if (!this.codexApiService.isInitialized) { - await this.codexApiService.initialize(); - } - if (this.isExpiryDateNear()) { - logger.info(`[Codex] Expiry date is near, refreshing token...`); - await this.codexApiService.initializeAuth(true); - } - return Promise.resolve(); - } - - async forceRefreshToken() { - if (!this.codexApiService.isInitialized) { - await this.codexApiService.initialize(); - } - logger.info(`[Codex] Force refreshing token...`); - return this.codexApiService.initializeAuth(true); - } - - isExpiryDateNear() { - return this.codexApiService.isExpiryDateNear(); - } - - /** - * 获取用量限制信息 - * @returns {Promise} 用量限制信息 - */ - async getUsageLimits() { - if (!this.codexApiService.isInitialized) { - logger.warn("codexApiService not initialized, attempting to re-initialize..."); - await this.codexApiService.initialize(); - } - return this.codexApiService.getUsageLimits(); - } -} - -// Forward API 服务适配器 -export class ForwardApiServiceAdapter extends ApiServiceAdapter { - constructor(config) { - super(); - this.forwardApiService = new ForwardApiService(config); - } - - async generateContent(model, requestBody) { - return this.forwardApiService.generateContent(model, requestBody); - } - - async *generateContentStream(model, requestBody) { - yield* this.forwardApiService.generateContentStream(model, requestBody); - } - - async listModels() { - return this.forwardApiService.listModels(); - } - - async refreshToken() { - return Promise.resolve(); - } - - async forceRefreshToken() { - return Promise.resolve(); - } - - isExpiryDateNear() { - return false; - } -} - -// Grok API 服务适配器 -export class GrokApiServiceAdapter extends ApiServiceAdapter { - constructor(config) { - super(); - this.grokApiService = new GrokApiService(config); - } - - async generateContent(model, requestBody) { - if (!this.grokApiService.isInitialized) { - await this.grokApiService.initialize(); - } - return this.grokApiService.generateContent(model, requestBody); - } - - async *generateContentStream(model, requestBody) { - if (!this.grokApiService.isInitialized) { - await this.grokApiService.initialize(); - } - yield* this.grokApiService.generateContentStream(model, requestBody); - } - - async listModels() { - if (!this.grokApiService.isInitialized) { - await this.grokApiService.initialize(); - } - return this.grokApiService.listModels(); - } - - async refreshToken() { - return this.grokApiService.refreshToken(); - } - - async forceRefreshToken() { - return this.grokApiService.refreshToken(); - } - - isExpiryDateNear() { - return this.grokApiService.isExpiryDateNear(); - } - - /** - * 获取用量限制信息 - * @returns {Promise} 用量限制信息 - */ - async getUsageLimits() { - if (!this.grokApiService.isInitialized) { - await this.grokApiService.initialize(); - } - return this.grokApiService.getUsageLimits(); - } -} - -// 注册所有内置适配器 -registerAdapter(MODEL_PROVIDER.OPENAI_CUSTOM, OpenAIApiServiceAdapter); -registerAdapter(MODEL_PROVIDER.OPENAI_CUSTOM_RESPONSES, OpenAIResponsesApiServiceAdapter); -registerAdapter(MODEL_PROVIDER.GEMINI_CLI, GeminiApiServiceAdapter); -registerAdapter(MODEL_PROVIDER.ANTIGRAVITY, AntigravityApiServiceAdapter); -registerAdapter(MODEL_PROVIDER.CLAUDE_CUSTOM, ClaudeApiServiceAdapter); -registerAdapter(MODEL_PROVIDER.KIRO_API, KiroApiServiceAdapter); -registerAdapter(MODEL_PROVIDER.QWEN_API, QwenApiServiceAdapter); -// registerAdapter(MODEL_PROVIDER.IFLOW_API, IFlowApiServiceAdapter); -registerAdapter(MODEL_PROVIDER.CODEX_API, CodexApiServiceAdapter); -registerAdapter(MODEL_PROVIDER.GROK_CUSTOM, GrokApiServiceAdapter); -// registerAdapter(MODEL_PROVIDER.FORWARD_API, ForwardApiServiceAdapter); - -// 用于存储服务适配器单例的映射 -export const serviceInstances = {}; - -// 服务适配器工厂 -export function getServiceAdapter(config) { - const customNameDisplay = config.customName ? ` (${config.customName})` : ''; - logger.info(`[Adapter] getServiceAdapter, provider: ${config.MODEL_PROVIDER}, uuid: ${config.uuid}${customNameDisplay}`); - const provider = config.MODEL_PROVIDER; - const providerKey = config.uuid ? provider + config.uuid : provider; - - if (!serviceInstances[providerKey]) { - const AdapterClass = adapterRegistry.get(provider); - if (AdapterClass) { - serviceInstances[providerKey] = new AdapterClass(config); - } else { - throw new Error(`Unsupported model provider: ${provider}`); - } - } - return serviceInstances[providerKey]; -} - diff --git a/src/providers/claude/claude-core.js b/src/providers/claude/claude-core.js deleted file mode 100644 index e95e3b50fe879254909e785264fec2086808203a..0000000000000000000000000000000000000000 --- a/src/providers/claude/claude-core.js +++ /dev/null @@ -1,296 +0,0 @@ -import axios from 'axios'; -import logger from '../../utils/logger.js'; -import * as http from 'http'; -import * as https from 'https'; -import { configureAxiosProxy, configureTLSSidecar } from '../../utils/proxy-utils.js'; -import { isRetryableNetworkError, MODEL_PROVIDER } from '../../utils/common.js'; - -/** - * Claude API Core Service Class. - * Encapsulates the interaction logic with the Anthropic Claude API. - */ -export class ClaudeApiService { - /** - * Constructor - * @param {string} apiKey - Anthropic Claude API Key. - * @param {string} baseUrl - Anthropic Claude API Base URL. - */ - constructor(config) { - if (!config.CLAUDE_API_KEY) { - throw new Error("Claude API Key is required for ClaudeApiService."); - } - this.config = config; - this.apiKey = config.CLAUDE_API_KEY; - this.baseUrl = config.CLAUDE_BASE_URL; - this.useSystemProxy = config?.USE_SYSTEM_PROXY_CLAUDE ?? false; - logger.info(`[Claude] System proxy ${this.useSystemProxy ? 'enabled' : 'disabled'}`); - this.client = this.createClient(); - } - - /** - * Creates an Axios instance for communication with the Claude API. - * @returns {object} Axios instance. - */ - createClient() { - // 配置 HTTP/HTTPS agent 限制连接池大小,避免资源泄漏 - const httpAgent = new http.Agent({ - keepAlive: true, - maxSockets: 100, - maxFreeSockets: 5, - timeout: 120000, - }); - const httpsAgent = new https.Agent({ - keepAlive: true, - maxSockets: 100, - maxFreeSockets: 5, - timeout: 120000, - }); - - const axiosConfig = { - baseURL: this.baseUrl, - httpAgent, - httpsAgent, - headers: { - 'x-api-key': this.apiKey, - 'Content-Type': 'application/json', - 'anthropic-version': '2023-06-01', // Claude API 版本 - }, - }; - - // 禁用系统代理以避免HTTPS代理错误 - if (!this.useSystemProxy) { - axiosConfig.proxy = false; - } - - // 配置自定义代理 - configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.CLAUDE_CUSTOM); - - return axios.create(axiosConfig); - } - - _applySidecar(axiosConfig) { - return configureTLSSidecar(axiosConfig, this.config, MODEL_PROVIDER.CLAUDE_CUSTOM, this.baseUrl); - } - - /** - * Generic method to call the Claude API, with retry mechanism. - * @param {string} endpoint - API endpoint, e.g., '/messages'. - * @param {object} body - Request body. - * @param {boolean} isRetry - Whether it's a retry call. - * @param {number} retryCount - Current retry count. - * @returns {Promise} API response data. - */ - async callApi(endpoint, body, isRetry = false, retryCount = 0) { - const maxRetries = this.config.REQUEST_MAX_RETRIES || 3; - const baseDelay = this.config.REQUEST_BASE_DELAY || 1000; // 1 second base delay - - try { - const axiosConfig = { - method: 'post', - url: endpoint, - data: body - }; - this._applySidecar(axiosConfig); - const response = await this.client.request(axiosConfig); - return response.data; - } catch (error) { - const status = error.response?.status; - const errorCode = error.code; - const errorMessage = error.message || ''; - - // 检查是否为可重试的网络错误 - const isNetworkError = isRetryableNetworkError(error); - - // 对于 Claude API,401 通常意味着 API Key 无效,不进行重试 - if (status === 401 || status === 403) { - logger.error(`[Claude API] Received ${status}. API Key might be invalid or expired.`); - throw error; - } - - // 处理 429 (Too Many Requests) 与指数退避 - if (status === 429 && retryCount < maxRetries) { - const delay = baseDelay * Math.pow(2, retryCount); - logger.info(`[Claude API] Received 429 (Too Many Requests). Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, delay)); - return this.callApi(endpoint, body, isRetry, retryCount + 1); - } - - // 处理其他可重试错误 (5xx 服务器错误) - if (status >= 500 && status < 600 && retryCount < maxRetries) { - const delay = baseDelay * Math.pow(2, retryCount); - logger.info(`[Claude API] Received ${status} server error. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, delay)); - return this.callApi(endpoint, body, isRetry, retryCount + 1); - } - - // Handle network errors (ECONNRESET, ETIMEDOUT, etc.) with exponential backoff - if (isNetworkError && retryCount < maxRetries) { - const delay = baseDelay * Math.pow(2, retryCount); - const errorIdentifier = errorCode || errorMessage.substring(0, 50); - logger.info(`[Claude API] Network error (${errorIdentifier}). Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, delay)); - return this.callApi(endpoint, body, isRetry, retryCount + 1); - } - - logger.error(`[Claude API] Error calling API (Status: ${status}, Code: ${errorCode}):`, error.message); - throw error; - } - } - - /** - * Generic method to stream from the Claude API, with retry mechanism. - * @param {string} endpoint - API endpoint, e.g., '/messages'. - * @param {object} body - Request body. - * @param {boolean} isRetry - Whether it's a retry call. - * @param {number} retryCount - Current retry count. - * @returns {AsyncIterable} API response stream. - */ - async *streamApi(endpoint, body, isRetry = false, retryCount = 0) { - const maxRetries = this.config.REQUEST_MAX_RETRIES || 3; - const baseDelay = this.config.REQUEST_BASE_DELAY || 1000; // 1 second base delay - - try { - const axiosConfig = { - method: 'post', - url: endpoint, - data: { ...body, stream: true }, - responseType: 'stream' - }; - this._applySidecar(axiosConfig); - const response = await this.client.request(axiosConfig); - const reader = response.data; - let buffer = ''; - - for await (const chunk of reader) { - buffer += chunk.toString('utf-8'); - let boundary; - while ((boundary = buffer.indexOf('\n\n')) !== -1) { - const eventBlock = buffer.substring(0, boundary); - buffer = buffer.substring(boundary + 2); - - const lines = eventBlock.split('\n'); - let data = ''; - for (const line of lines) { - if (line.startsWith('data: ')) { - data = line.substring(6).trim(); - } - } - - if (data) { - try { - const parsedChunk = JSON.parse(data); - yield parsedChunk; - if (parsedChunk.type === 'message_stop') { - return; - } - } catch (e) { - logger.warn("[ClaudeApiService] Failed to parse stream chunk JSON:", e.message, "Data:", data); - } - } - } - } - } catch (error) { - const status = error.response?.status; - const errorCode = error.code; - const errorMessage = error.message || ''; - - // 检查是否为可重试的网络错误 - const isNetworkError = isRetryableNetworkError(error); - - // 对于 Claude API,401 通常意味着 API Key 无效,不进行重试 - if (status === 401 || status === 403) { - logger.error(`[Claude API] Received ${status} during stream. API Key might be invalid or expired.`); - throw error; - } - - // 处理 429 (Too Many Requests) 与指数退避 - if (status === 429 && retryCount < maxRetries) { - const delay = baseDelay * Math.pow(2, retryCount); - logger.info(`[Claude API] Received 429 (Too Many Requests) during stream. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, delay)); - yield* this.streamApi(endpoint, body, isRetry, retryCount + 1); - return; - } - - // 处理其他可重试错误 (5xx 服务器错误) - if (status >= 500 && status < 600 && retryCount < maxRetries) { - const delay = baseDelay * Math.pow(2, retryCount); - logger.info(`[Claude API] Received ${status} server error during stream. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, delay)); - yield* this.streamApi(endpoint, body, isRetry, retryCount + 1); - return; - } - - // Handle network errors (ECONNRESET, ETIMEDOUT, etc.) with exponential backoff - if (isNetworkError && retryCount < maxRetries) { - const delay = baseDelay * Math.pow(2, retryCount); - const errorIdentifier = errorCode || errorMessage.substring(0, 50); - logger.info(`[Claude API] Network error (${errorIdentifier}) during stream. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, delay)); - yield* this.streamApi(endpoint, body, isRetry, retryCount + 1); - return; - } - - logger.error(`[Claude API] Error generating content stream (Status: ${status}, Code: ${errorCode}):`, error.response ? error.response.data : error.message); - throw error; - } - } - - /** - * Generates content (non-streaming). - * @param {string} model - Model name. - * @param {object} requestBody - Request body (Claude format). - * @returns {Promise} Claude API response (Claude compatible format). - */ - async generateContent(model, requestBody) { - // 临时存储 monitorRequestId - if (requestBody._monitorRequestId) { - this.config._monitorRequestId = requestBody._monitorRequestId; - delete requestBody._monitorRequestId; - } - if (requestBody._requestBaseUrl) { - delete requestBody._requestBaseUrl; - } - - const response = await this.callApi('/messages', requestBody); - return response; - } - - /** - * Streams content generation. - * @param {string} model - Model name. - * @param {object} requestBody - Request body (Claude format). - * @returns {AsyncIterable} Claude API response stream (Claude compatible format). - */ - async *generateContentStream(model, requestBody) { - const stream = this.streamApi('/messages', requestBody); - for await (const chunk of stream) { - yield chunk; - } - } - - /** - * Lists available models. - * The Claude API does not have a direct '/models' endpoint; typically, supported models need to be hardcoded. - * @returns {Promise} List of models. - */ - async listModels() { - logger.info('[ClaudeApiService] Listing available models.'); - // Claude API 没有直接的 /models 端点来列出所有模型。 - // 通常,你需要根据 Anthropic 的文档硬编码你希望支持的模型。 - // 这里我们返回一些常见的 Claude 模型作为示例。 - const models = [ - { id: "claude-4-sonnet", name: "claude-4-sonnet" }, - { id: "claude-sonnet-4-20250514", name: "claude-sonnet-4-20250514" }, - { id: "claude-opus-4-20250514", name: "claude-opus-4-20250514" }, - { id: "claude-3-7-sonnet-20250219", name: "claude-3-7-sonnet-20250219" }, - { id: "claude-3-5-sonnet-20241022", name: "claude-3-5-sonnet-20241022" }, - { id: "claude-3-5-haiku-20241022", name: "claude-3-5-haiku-20241022" }, - { id: "claude-3-opus-20240229", name: "claude-3-opus-20240229" }, - { id: "claude-3-haiku-20240307", name: "claude-3-haiku-20240307" }, - ]; - - return { models: models.map(m => ({ name: m.name })) }; - } -} - diff --git a/src/providers/claude/claude-kiro.js b/src/providers/claude/claude-kiro.js deleted file mode 100644 index eca1477320a1c7af974f7fd0d59e2f964cb9ab24..0000000000000000000000000000000000000000 --- a/src/providers/claude/claude-kiro.js +++ /dev/null @@ -1,3051 +0,0 @@ -import axios from 'axios'; -import logger from '../../utils/logger.js'; -import { v4 as uuidv4 } from 'uuid'; -import { promises as fs } from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import * as crypto from 'crypto'; -import * as http from 'http'; -import * as https from 'https'; -import { getProviderModels } from '../provider-models.js'; -import { - countTextTokens as countTextTokensUtil, - estimateInputTokens as estimateInputTokensUtil, - countTokensAnthropic as countTokensUtil, - processContent as processContentUtil, - getContentText as getContentTextUtil -} from '../../utils/token-utils.js'; -import { configureAxiosProxy, configureTLSSidecar } from '../../utils/proxy-utils.js'; -import { isRetryableNetworkError, MODEL_PROVIDER, formatExpiryLog } from '../../utils/common.js'; -import { getProviderPoolManager } from '../../services/service-manager.js'; - -const KIRO_THINKING = { - MIN_BUDGET_TOKENS: 1024, - MAX_BUDGET_TOKENS: 24576, - DEFAULT_BUDGET_TOKENS: 20000, - START_TAG: '', - END_TAG: '', - MODE_TAG: '', - MAX_LEN_TAG: '', - EFFORT_TAG: '', -}; - -const KIRO_CONSTANTS = { - REFRESH_URL: 'https://prod.{{region}}.auth.desktop.kiro.dev/refreshToken', - REFRESH_IDC_URL: 'https://oidc.{{region}}.amazonaws.com/token', - BASE_URL: 'https://q.{{region}}.amazonaws.com/generateAssistantResponse', - DEFAULT_MODEL_NAME: 'claude-sonnet-4-5', - AXIOS_TIMEOUT: 120000, // 2 minutes timeout for normal requests - TOKEN_REFRESH_TIMEOUT: 15000, // 15 seconds timeout for token refresh (shorter to avoid blocking) - USER_AGENT: 'KiroIDE', - KIRO_VERSION: '0.8.140', - CONTENT_TYPE_JSON: 'application/json', - ACCEPT_JSON: 'application/json', - AUTH_METHOD_SOCIAL: 'social', - CHAT_TRIGGER_TYPE_MANUAL: 'MANUAL', - ORIGIN_AI_EDITOR: 'AI_EDITOR', - TOTAL_CONTEXT_TOKENS: 172500, // 总上下文 173k tokens -}; - -// 从 provider-models.js 获取支持的模型列表 -const KIRO_MODELS = getProviderModels(MODEL_PROVIDER.KIRO_API); - -// 完整的模型映射表 -const FULL_MODEL_MAPPING = { - "claude-haiku-4-5":"claude-haiku-4.5", - "claude-opus-4-6":"claude-opus-4.6", - "claude-sonnet-4-6":"claude-sonnet-4.6", - "claude-opus-4-5":"claude-opus-4.5", - "claude-opus-4-5-20251101":"claude-opus-4.5", - "claude-sonnet-4-5": "claude-sonnet-4.5", - "claude-sonnet-4-5-20250929": "claude-sonnet-4.5" -}; - -// 只保留 KIRO_MODELS 中存在的模型映射 -const MODEL_MAPPING = Object.fromEntries( - Object.entries(FULL_MODEL_MAPPING).filter(([key]) => KIRO_MODELS.includes(key)) -); - -const KIRO_AUTH_TOKEN_FILE = "kiro-auth-token.json"; - -/** - * Kiro API Service - Node.js implementation based on the Python ki2api - * Provides OpenAI-compatible API for Claude Sonnet 4 via Kiro/CodeWhisperer - */ - -/** - * 根据当前配置生成唯一的机器码(Machine ID) - * 确保每个配置对应一个唯一且不变的 ID - * @param {Object} credentials - 当前凭证信息 - * @returns {string} SHA256 格式的机器码 - */ -function generateMachineIdFromConfig(credentials) { - // 优先级:节点UUID > profileArn > clientId > fallback - const uniqueKey = credentials.uuid || credentials.profileArn || credentials.clientId || "KIRO_DEFAULT_MACHINE"; - return crypto.createHash('sha256').update(uniqueKey).digest('hex'); -} - -/** - * 实时获取系统配置信息,用于生成 User-Agent - * @returns {Object} 包含 osName, nodeVersion 等信息 - */ -function getSystemRuntimeInfo() { - const osPlatform = os.platform(); - const osRelease = os.release(); - const nodeVersion = process.version.replace('v', ''); - - let osName = osPlatform; - if (osPlatform === 'win32') osName = `windows#${osRelease}`; - else if (osPlatform === 'darwin') osName = `macos#${osRelease}`; - else osName = `${osPlatform}#${osRelease}`; - - return { - osName, - nodeVersion - }; -} - -// Helper functions for tool calls and JSON parsing - -function isQuoteCharAt(text, index) { - if (index < 0 || index >= text.length) return false; - const ch = text[index]; - return ch === '"' || ch === "'" || ch === '`'; -} - -function findRealTag(text, tag, startIndex = 0) { - let searchStart = Math.max(0, startIndex); - while (true) { - const pos = text.indexOf(tag, searchStart); - if (pos === -1) return -1; - - const hasQuoteBefore = isQuoteCharAt(text, pos - 1); - const hasQuoteAfter = isQuoteCharAt(text, pos + tag.length); - if (!hasQuoteBefore && !hasQuoteAfter) { - return pos; - } - - searchStart = pos + 1; - } -} - -function isWhitespaceOnly(text) { - if (text === null || text === undefined) return true; - return String(text).trim().length === 0; -} - -/** - * Find a "real" thinking end tag that is not quoted/backticked and is followed by '\n\n'. - * This avoids prematurely closing a thinking block when the model mentions `` - * inside the thinking content. - */ -function findRealThinkingEndTag(buffer, startIndex = 0) { - let searchStart = Math.max(0, startIndex); - while (true) { - const pos = findRealTag(buffer, KIRO_THINKING.END_TAG, searchStart); - if (pos === -1) return -1; - const after = buffer.slice(pos + KIRO_THINKING.END_TAG.length); - if (after.startsWith('\n\n')) return pos; - searchStart = pos + 1; - } -} - -/** - * Find a "real" thinking end tag only when it is at the buffer end (after it is whitespace only). - * This is used for boundary-event scenarios (tool_use starts immediately after thinking, or stream end). - */ -function findRealThinkingEndTagAtBufferEnd(buffer, startIndex = 0) { - let searchStart = Math.max(0, startIndex); - while (true) { - const pos = findRealTag(buffer, KIRO_THINKING.END_TAG, searchStart); - if (pos === -1) return -1; - const after = buffer.slice(pos + KIRO_THINKING.END_TAG.length); - if (isWhitespaceOnly(after)) return pos; - searchStart = pos + 1; - } -} - -/** - * 通用的括号匹配函数 - 支持多种括号类型 - * @param {string} text - 要搜索的文本 - * @param {number} startPos - 起始位置 - * @param {string} openChar - 开括号字符 (默认 '[') - * @param {string} closeChar - 闭括号字符 (默认 ']') - * @returns {number} 匹配的闭括号位置,未找到返回 -1 - */ -function findMatchingBracket(text, startPos, openChar = '[', closeChar = ']') { - if (!text || startPos >= text.length || text[startPos] !== openChar) { - return -1; - } - - let bracketCount = 1; - let inString = false; - let escapeNext = false; - - for (let i = startPos + 1; i < text.length; i++) { - const char = text[i]; - - if (escapeNext) { - escapeNext = false; - continue; - } - - if (char === '\\' && inString) { - escapeNext = true; - continue; - } - - if (char === '"' && !escapeNext) { - inString = !inString; - continue; - } - - if (!inString) { - if (char === openChar) { - bracketCount++; - } else if (char === closeChar) { - bracketCount--; - if (bracketCount === 0) { - return i; - } - } - } - } - return -1; -} - - -/** - * 尝试修复常见的 JSON 格式问题 - * @param {string} jsonStr - 可能有问题的 JSON 字符串 - * @returns {string} 修复后的 JSON 字符串 - */ -function repairJson(jsonStr) { - let repaired = jsonStr; - // 移除尾部逗号 - repaired = repaired.replace(/,\s*([}\]])/g, '$1'); - // 为未引用的键添加引号 - repaired = repaired.replace(/([{,]\s*)([a-zA-Z0-9_]+?)\s*:/g, '$1"$2":'); - // 确保字符串值被正确引用 - repaired = repaired.replace(/:\s*([a-zA-Z0-9_]+)(?=[,\}\]])/g, ':"$1"'); - return repaired; -} - -/** - * 从损坏的 JSON 中提取关键凭证字段 - * 当标准 JSON 解析和 repairJson 都失败时使用 - * @param {string} content - 文件内容 - * @returns {Object|null} 提取的凭证对象或 null - */ -function extractCredentialsFromCorruptedJson(content) { - const extracted = {}; - - // 定义需要提取的关键字段及其正则模式 - const fieldPatterns = { - refreshToken: /"refreshToken"\s*:\s*"([^"]+)"/, - accessToken: /"accessToken"\s*:\s*"([^"]+)"/, - clientId: /"clientId"\s*:\s*"([^"]+)"/, - clientSecret: /"clientSecret"\s*:\s*"([^"]+)"/, - profileArn: /"profileArn"\s*:\s*"([^"]+)"/, - region: /"region"\s*:\s*"([^"]+)"/, - authMethod: /"authMethod"\s*:\s*"([^"]+)"/, - expiresAt: /"expiresAt"\s*:\s*"([^"]+)"/, - startUrl: /"startUrl"\s*:\s*"([^"]+)"/, - }; - - for (const [field, pattern] of Object.entries(fieldPatterns)) { - const match = content.match(pattern); - if (match && match[1]) { - extracted[field] = match[1]; - } - } - - // 至少需要 refreshToken 或 accessToken 才算有效 - if (extracted.refreshToken || extracted.accessToken) { - logger.info(`[Kiro Auth] Extracted ${Object.keys(extracted).length} fields from corrupted JSON: ${Object.keys(extracted).join(', ')}`); - return extracted; - } - - return null; -} - -/** - * 解析单个工具调用文本 - * @param {string} toolCallText - 工具调用文本 - * @returns {Object|null} 解析后的工具调用对象或 null - */ -function parseSingleToolCall(toolCallText) { - const namePattern = /\[Called\s+(\w+)\s+with\s+args:/i; - const nameMatch = toolCallText.match(namePattern); - - if (!nameMatch) { - return null; - } - - const functionName = nameMatch[1].trim(); - const argsStartMarker = "with args:"; - const argsStartPos = toolCallText.toLowerCase().indexOf(argsStartMarker.toLowerCase()); - - if (argsStartPos === -1) { - return null; - } - - const argsStart = argsStartPos + argsStartMarker.length; - const argsEnd = toolCallText.lastIndexOf(']'); - - if (argsEnd <= argsStart) { - return null; - } - - const jsonCandidate = toolCallText.substring(argsStart, argsEnd).trim(); - - try { - const repairedJson = repairJson(jsonCandidate); - const argumentsObj = JSON.parse(repairedJson); - - if (typeof argumentsObj !== 'object' || argumentsObj === null) { - return null; - } - - const toolCallId = `call_${uuidv4().replace(/-/g, '').substring(0, 8)}`; - return { - id: toolCallId, - type: "function", - function: { - name: functionName, - arguments: JSON.stringify(argumentsObj) - } - }; - } catch (e) { - logger.error(`Failed to parse tool call arguments: ${e.message}`, jsonCandidate); - return null; - } -} - -function parseBracketToolCalls(responseText) { - if (!responseText || !responseText.includes("[Called")) { - return null; - } - - const toolCalls = []; - const callPositions = []; - let start = 0; - while (true) { - const pos = responseText.indexOf("[Called", start); - if (pos === -1) { - break; - } - callPositions.push(pos); - start = pos + 1; - } - - for (let i = 0; i < callPositions.length; i++) { - const startPos = callPositions[i]; - let endSearchLimit; - if (i + 1 < callPositions.length) { - endSearchLimit = callPositions[i + 1]; - } else { - endSearchLimit = responseText.length; - } - - const segment = responseText.substring(startPos, endSearchLimit); - const bracketEnd = findMatchingBracket(segment, 0); - - let toolCallText; - if (bracketEnd !== -1) { - toolCallText = segment.substring(0, bracketEnd + 1); - } else { - // Fallback: if no matching bracket, try to find the last ']' in the segment - const lastBracket = segment.lastIndexOf(']'); - if (lastBracket !== -1) { - toolCallText = segment.substring(0, lastBracket + 1); - } else { - continue; // Skip this one if no closing bracket found - } - } - - const parsedCall = parseSingleToolCall(toolCallText); - if (parsedCall) { - toolCalls.push(parsedCall); - } - } - return toolCalls.length > 0 ? toolCalls : null; -} - -function deduplicateToolCalls(toolCalls) { - const seen = new Set(); - const uniqueToolCalls = []; - - for (const tc of toolCalls) { - const key = `${tc.function.name}-${tc.function.arguments}`; - if (!seen.has(key)) { - seen.add(key); - uniqueToolCalls.push(tc); - } else { - logger.info(`Skipping duplicate tool call: ${tc.function.name}`); - } - } - return uniqueToolCalls; -} - -export class KiroApiService { - constructor(config = {}) { - this.isInitialized = false; - this.config = config; - this.credPath = config.KIRO_OAUTH_CREDS_DIR_PATH || path.join(os.homedir(), ".aws", "sso", "cache"); - this.credsBase64 = config.KIRO_OAUTH_CREDS_BASE64; - this.useSystemProxy = config?.USE_SYSTEM_PROXY_KIRO ?? false; - this.uuid = config?.uuid; // 获取多节点配置的 uuid - logger.info(`[Kiro] System proxy ${this.useSystemProxy ? 'enabled' : 'disabled'}`); - // this.accessToken = config.KIRO_ACCESS_TOKEN; - // this.refreshToken = config.KIRO_REFRESH_TOKEN; - // this.clientId = config.KIRO_CLIENT_ID; - // this.clientSecret = config.KIRO_CLIENT_SECRET; - // this.authMethod = KIRO_CONSTANTS.AUTH_METHOD_SOCIAL; - // this.refreshUrl = KIRO_CONSTANTS.REFRESH_URL; - // this.refreshIDCUrl = KIRO_CONSTANTS.REFRESH_IDC_URL; - // this.baseUrl = KIRO_CONSTANTS.BASE_URL; - - // Add kiro-oauth-creds-base64 and kiro-oauth-creds-file to config - if (config.KIRO_OAUTH_CREDS_BASE64) { - try { - const decodedCreds = Buffer.from(config.KIRO_OAUTH_CREDS_BASE64, 'base64').toString('utf8'); - const parsedCreds = JSON.parse(decodedCreds); - // Store parsedCreds to be merged in initializeAuth - this.base64Creds = parsedCreds; - logger.info('[Kiro] Successfully decoded Base64 credentials in constructor.'); - } catch (error) { - logger.error(`[Kiro] Failed to parse Base64 credentials in constructor: ${error.message}`); - } - } else if (config.KIRO_OAUTH_CREDS_FILE_PATH) { - this.credsFilePath = config.KIRO_OAUTH_CREDS_FILE_PATH; - } - - this.modelName = KIRO_CONSTANTS.DEFAULT_MODEL_NAME; - this.axiosInstance = null; // Initialize later in async method - this.axiosSocialRefreshInstance = null; - } - - async initialize() { - if (this.isInitialized) return; - logger.info('[Kiro] Initializing Kiro API Service...'); - // 注意:V2 读写分离架构下,初始化不再执行同步认证/刷新逻辑 - // 仅执行基础的凭证加载 - await this.loadCredentials(); - - // 根据当前加载的凭证生成唯一的 Machine ID - const machineId = generateMachineIdFromConfig({ - uuid: this.uuid, - profileArn: this.profileArn, - clientId: this.clientId - }); - const kiroVersion = KIRO_CONSTANTS.KIRO_VERSION; - const { osName, nodeVersion } = getSystemRuntimeInfo(); - - // 配置 HTTP/HTTPS agent 限制连接池大小,避免资源泄漏 - const httpAgent = new http.Agent({ - keepAlive: true, - maxSockets: 100, // 每个主机最多 100 个连接 - maxFreeSockets: 5, // 最多保留 5 个空闲连接 - timeout: KIRO_CONSTANTS.AXIOS_TIMEOUT, - }); - const httpsAgent = new https.Agent({ - keepAlive: true, - maxSockets: 100, - maxFreeSockets: 5, - timeout: KIRO_CONSTANTS.AXIOS_TIMEOUT, - }); - - const axiosConfig = { - timeout: KIRO_CONSTANTS.AXIOS_TIMEOUT, - httpAgent, - httpsAgent, - headers: { - 'Content-Type': KIRO_CONSTANTS.CONTENT_TYPE_JSON, - 'Accept': KIRO_CONSTANTS.ACCEPT_JSON, - 'amz-sdk-request': 'attempt=1; max=1', - 'x-amzn-kiro-agent-mode': 'vibe', - 'x-amz-user-agent': `aws-sdk-js/1.0.0 KiroIDE-${kiroVersion}-${machineId}`, - 'user-agent': `aws-sdk-js/1.0.0 ua/2.1 os/${osName} lang/js md/nodejs#${nodeVersion} api/codewhispererruntime#1.0.0 m/E KiroIDE-${kiroVersion}-${machineId}`, - 'Connection': 'close' - }, - }; - - // 根据 useSystemProxy 配置代理设置 - if (!this.useSystemProxy) { - axiosConfig.proxy = false; - } - - // 配置自定义代理 - configureAxiosProxy(axiosConfig, this.config, 'claude-kiro-oauth'); - - this.axiosInstance = axios.create(axiosConfig); - - axiosConfig.headers = new Headers(); - axiosConfig.headers.set('Content-Type', KIRO_CONSTANTS.CONTENT_TYPE_JSON); - this.axiosSocialRefreshInstance = axios.create(axiosConfig); - this.isInitialized = true; - } - - _applySidecar(axiosConfig) { - return configureTLSSidecar(axiosConfig, this.config, MODEL_PROVIDER.KIRO_API); - } - -/** - * 加载凭证信息(不执行刷新) - */ -async loadCredentials() { - // 获取凭证文件路径 - const tokenFilePath = this.credsFilePath || path.join(this.credPath, KIRO_AUTH_TOKEN_FILE); - - // Helper to load credentials from a file - const loadCredentialsFromFile = async (filePath) => { - try { - const fileContent = await fs.readFile(filePath, 'utf8'); - try { - return JSON.parse(fileContent); - } catch (parseError) { - logger.warn('[Kiro Auth] JSON parse failed, attempting repair...'); - try { - const repaired = repairJson(fileContent); - const result = JSON.parse(repaired); - logger.info('[Kiro Auth] JSON repair successful'); - return result; - } catch (repairError) { - logger.warn('[Kiro Auth] JSON repair failed, attempting field extraction...'); - // 尝试从损坏的 JSON 中提取关键字段 - const extracted = extractCredentialsFromCorruptedJson(fileContent); - if (extracted) { - logger.info('[Kiro Auth] Field extraction successful, credentials recovered'); - return extracted; - } - logger.error('[Kiro Auth] All recovery methods failed:', repairError.message); - return null; - } - } - } catch (error) { - if (error.code === 'ENOENT') { - logger.debug(`[Kiro Auth] Credential file not found: ${filePath}`); - } else { - logger.warn(`[Kiro Auth] Failed to read credential file ${filePath}: ${error.message}`); - } - return null; - } - }; - - try { - let mergedCredentials = {}; - - // Priority 1: Load from Base64 credentials if available - if (this.base64Creds) { - Object.assign(mergedCredentials, this.base64Creds); - logger.info('[Kiro Auth] Successfully loaded credentials from Base64 (constructor).'); - this.base64Creds = null; - } - - // 从文件加载 - const targetFilePath = this.credsFilePath || path.join(this.credPath, KIRO_AUTH_TOKEN_FILE); - const dirPath = path.dirname(targetFilePath); - const targetFileName = path.basename(targetFilePath); - - logger.debug(`[Kiro Auth] Loading credentials from directory: ${dirPath}`); - - try { - const targetCredentials = await loadCredentialsFromFile(targetFilePath); - if (targetCredentials) { - Object.assign(mergedCredentials, targetCredentials); - logger.info(`[Kiro Auth] Successfully loaded OAuth credentials from ${targetFilePath}`); - } - - const files = await fs.readdir(dirPath); - for (const file of files) { - if (file.endsWith('.json') && file !== targetFileName) { - const filePath = path.join(dirPath, file); - const credentials = await loadCredentialsFromFile(filePath); - if (credentials) { - credentials.expiresAt = mergedCredentials.expiresAt; - Object.assign(mergedCredentials, credentials); - logger.debug(`[Kiro Auth] Loaded Client credentials from ${file}`); - } - } - } - } catch (error) { - logger.warn(`[Kiro Auth] Error loading credentials from directory ${dirPath}: ${error.message}`); - } - - // Apply loaded credentials - this.accessToken = this.accessToken || mergedCredentials.accessToken; - this.refreshToken = this.refreshToken || mergedCredentials.refreshToken; - this.clientId = this.clientId || mergedCredentials.clientId; - this.clientSecret = this.clientSecret || mergedCredentials.clientSecret; - this.authMethod = this.authMethod || mergedCredentials.authMethod; - this.expiresAt = this.expiresAt || mergedCredentials.expiresAt; - this.profileArn = this.profileArn || mergedCredentials.profileArn; - this.region = this.region || mergedCredentials.region; - this.idcRegion = this.idcRegion || mergedCredentials.idcRegion; - - if (!this.region) { - logger.warn('[Kiro Auth] Region not found in credentials. Using default region us-east-1 for URLs.'); - this.region = 'us-east-1'; - } - - // idcRegion 用于 REFRESH_IDC_URL,如果未设置则使用 region - if (!this.idcRegion) { - this.idcRegion = this.region; - } - - this.refreshUrl = (this.config.KIRO_REFRESH_URL || KIRO_CONSTANTS.REFRESH_URL).replace("{{region}}", this.region); - this.refreshIDCUrl = (this.config.KIRO_REFRESH_IDC_URL || KIRO_CONSTANTS.REFRESH_IDC_URL).replace("{{region}}", this.idcRegion); - this.baseUrl = (this.config.KIRO_BASE_URL || KIRO_CONSTANTS.BASE_URL).replace("{{region}}", this.region); - } catch (error) { - logger.warn(`[Kiro Auth] Error during credential loading: ${error.message}`); - } -} - -async initializeAuth(forceRefresh = false) { - if (this.accessToken && !forceRefresh) { - logger.debug('[Kiro Auth] Access token already available and not forced refresh.'); - return; - } - - // 首先执行基础凭证加载 - await this.loadCredentials(); - - // 只有在明确要求强制刷新,或者 AccessToken 确实缺失时,才执行刷新 - // 注意:在 V2 架构下,此方法主要由 PoolManager 的后台队列调用 - if (forceRefresh || (!this.accessToken && this.refreshToken)) { - if (!this.refreshToken) { - throw new Error('No refresh token available to refresh access token.'); - } - - const tokenFilePath = this.credsFilePath || path.join(this.credPath, KIRO_AUTH_TOKEN_FILE); - await this._doTokenRefresh(this.saveCredentialsToFile.bind(this), tokenFilePath); - } - - if (!this.accessToken) { - throw new Error('No access token available after initialization and refresh attempts.'); - } -} - -/** - * Helper to save credentials - */ -async saveCredentialsToFile(filePath, newData) { - let existingData = {}; - try { - const fileContent = await fs.readFile(filePath, 'utf8'); - try { - existingData = JSON.parse(fileContent); - } catch (parseError) { - logger.warn('[Kiro Auth] JSON parse failed, attempting repair...'); - try { - const repaired = repairJson(fileContent); - existingData = JSON.parse(repaired); - logger.info('[Kiro Auth] JSON repair successful'); - } catch (repairError) { - logger.warn('[Kiro Auth] JSON repair failed, attempting field extraction...'); - const extracted = extractCredentialsFromCorruptedJson(fileContent); - if (extracted) { - existingData = extracted; - logger.info('[Kiro Auth] Field extraction successful'); - } else { - logger.error('[Kiro Auth] All recovery methods failed:', repairError.message); - existingData = {}; - } - } - } - } catch (readError) { - if (readError.code === 'ENOENT') { - logger.debug(`[Kiro Auth] Token file not found, creating new one: ${filePath}`); - } else { - logger.warn(`[Kiro Auth] Could not read existing token file ${filePath}: ${readError.message}`); - } - } - const mergedData = { ...existingData, ...newData }; - await fs.writeFile(filePath, JSON.stringify(mergedData, null, 2), 'utf8'); - logger.info(`[Kiro Auth] Updated token file: ${filePath}`); -}; - - /** - * 执行实际的 token 刷新操作(内部方法) - * @param {Function} saveCredentialsToFile - 保存凭证的函数 - * @param {string} tokenFilePath - 凭证文件路径 - */ - async _doTokenRefresh(saveCredentialsToFile, tokenFilePath) { - try { - const requestBody = { - refreshToken: this.refreshToken, - }; - - let refreshUrl = this.refreshUrl; - if (this.authMethod !== KIRO_CONSTANTS.AUTH_METHOD_SOCIAL) { - refreshUrl = this.refreshIDCUrl; - requestBody.clientId = this.clientId; - requestBody.clientSecret = this.clientSecret; - requestBody.grantType = 'refresh_token'; - } - - let response = null; - // 使用更短的超时时间进行 token 刷新,避免阻塞其他请求 - const refreshConfig = { timeout: KIRO_CONSTANTS.TOKEN_REFRESH_TIMEOUT }; - - const axiosConfig = { - method: 'post', - url: refreshUrl, - data: requestBody, - ...refreshConfig - }; - this._applySidecar(axiosConfig); - - if (this.authMethod === KIRO_CONSTANTS.AUTH_METHOD_SOCIAL) { - response = await this.axiosSocialRefreshInstance.request(axiosConfig); - logger.info('[Kiro Auth] Token refresh social response: ok'); - } else { - response = await this.axiosInstance.request(axiosConfig); - logger.info('[Kiro Auth] Token refresh idc response: ok'); - } - - if (response.data && response.data.accessToken) { - this.accessToken = response.data.accessToken; - this.refreshToken = response.data.refreshToken || this.refreshToken; - this.profileArn = response.data.profileArn; - const expiresIn = response.data.expiresIn || 3600; - const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString(); - this.expiresAt = expiresAt; - logger.info('[Kiro Auth] Access token refreshed successfully'); - - const updatedTokenData = { - accessToken: this.accessToken, - refreshToken: this.refreshToken, - expiresAt: expiresAt, - }; - if (this.profileArn) { - updatedTokenData.profileArn = this.profileArn; - } - await saveCredentialsToFile(tokenFilePath, updatedTokenData); - - // 刷新成功,重置 PoolManager 中的刷新状态并标记为健康 - const poolManager = getProviderPoolManager(); - if (poolManager && this.uuid) { - poolManager.resetProviderRefreshStatus(MODEL_PROVIDER.KIRO_API, this.uuid); - } - } else { - throw new Error('Invalid refresh response: Missing accessToken'); - } - } catch (error) { - logger.error('[Kiro Auth] Token refresh failed:', error.message); - throw new Error(`Token refresh failed: ${error.message}`); - } - } - - - /** - * Count tokens for a given text using Claude's official tokenizer - * Static version for use without instance - */ - static countTextTokens(text) { - return countTextTokensUtil(text); - } - - /** - * Count tokens for a message request (compatible with Anthropic API) - * Static version for use without instance - */ - static countTokens(requestBody) { - return countTokensUtil(requestBody); - } - - /** - * Calculate input tokens from request body - * Static version for use without instance - */ - static estimateInputTokens(requestBody) { - return estimateInputTokensUtil(requestBody); - } - - /** - * Extract text content from OpenAI message format - */ - getContentText(message) { - return getContentTextUtil(message); - } - - /** - * 统一处理内容,将不同格式的内容转换为文本 - * @param {any} content - 内容对象或数组 - * @returns {string} 处理后的文本 - */ - processContent(content) { - return processContentUtil(content); - } - - _normalizeThinkingBudgetTokens(budgetTokens) { - let value = Number(budgetTokens); - if (!Number.isFinite(value) || value <= 0) { - value = KIRO_THINKING.DEFAULT_BUDGET_TOKENS; - } - value = Math.floor(value); - if (value < KIRO_THINKING.MIN_BUDGET_TOKENS) value = KIRO_THINKING.MIN_BUDGET_TOKENS; - return Math.min(value, KIRO_THINKING.MAX_BUDGET_TOKENS); - } - - _generateThinkingPrefix(thinking) { - if (!thinking || typeof thinking !== 'object') return null; - const type = String(thinking.type || '').toLowerCase().trim(); - - if (type === 'enabled') { - const budget = this._normalizeThinkingBudgetTokens(thinking.budget_tokens); - return `enabled${budget}`; - } - - if (type === 'adaptive') { - const effortRaw = typeof thinking.effort === 'string' ? thinking.effort : ''; - const effort = effortRaw.toLowerCase().trim(); - const normalizedEffort = (effort === 'low' || effort === 'medium' || effort === 'high') ? effort : 'high'; - return `adaptive${normalizedEffort}`; - } - - return null; - } - - _hasThinkingPrefix(text) { - if (!text) return false; - return text.includes(KIRO_THINKING.MODE_TAG) || text.includes(KIRO_THINKING.MAX_LEN_TAG) || text.includes(KIRO_THINKING.EFFORT_TAG); - } - - _toClaudeContentBlocksFromKiroText(content) { - const raw = content ?? ''; - if (!raw) return []; - - const startPos = findRealTag(raw, KIRO_THINKING.START_TAG); - if (startPos === -1) { - return [{ type: "text", text: raw }]; - } - - const before = raw.slice(0, startPos); - let rest = raw.slice(startPos + KIRO_THINKING.START_TAG.length); - - // Strip a single leading newline after `` for cleaner blocks. - if (rest.startsWith('\r\n')) rest = rest.slice(2); - else if (rest.startsWith('\n')) rest = rest.slice(1); - - let endPosInRest = findRealThinkingEndTag(rest); - if (endPosInRest === -1) endPosInRest = findRealThinkingEndTagAtBufferEnd(rest); - - let thinking = ''; - let after = ''; - if (endPosInRest === -1) { - thinking = rest; - } else { - thinking = rest.slice(0, endPosInRest); - after = rest.slice(endPosInRest + KIRO_THINKING.END_TAG.length); - } - - if (after.startsWith('\n\n')) after = after.slice(2); - if (isWhitespaceOnly(after)) after = ''; - - const blocks = []; - if (before && !isWhitespaceOnly(before)) blocks.push({ type: "text", text: before }); - blocks.push({ type: "thinking", thinking }); - if (after && !isWhitespaceOnly(after)) blocks.push({ type: "text", text: after }); - return blocks; - } - - /** - * Build CodeWhisperer request from OpenAI messages - */ - async buildCodewhispererRequest(messages, model, tools = null, inSystemPrompt = null, thinking = null) { - const conversationId = uuidv4(); - - // 内置的 systemPrompt 前缀 - const builtInPrefix = ` - 你一定不能说自己是 kiro ,这是非常重要的一点。 - 你只能回答用户的问题,不能回答任何关于 kiro 的问题。 - 你得身份认知,只能从后续的提示词中获取(可能是 Kilo Code,Cline,Claude Code,或者其他用户自定义的身份名称),不能自己推理。如果没有,就说自己是 ”开发者何夕2077,你现在使用的不是原版Claude,而是AIClient-2-API”。 - - - 你是 {{identity}},一名拥有多种编程语言、框架、设计模式和最佳实践丰富知识的高级软件工程师。 - `; - - let systemPrompt = this.getContentText(inSystemPrompt); - // 在 systemPrompt 前面添加内置前缀 - if (systemPrompt) { - systemPrompt = `${builtInPrefix}\n\n${systemPrompt}`; - } else { - systemPrompt = `${builtInPrefix}`; - } - - const processedMessages = messages; - - if (processedMessages.length === 0) { - throw new Error('No user messages found'); - } - - const thinkingPrefix = this._generateThinkingPrefix(thinking); - if (thinkingPrefix) { - if (!systemPrompt) { - systemPrompt = thinkingPrefix; - } else if (!this._hasThinkingPrefix(systemPrompt)) { - systemPrompt = `${thinkingPrefix}\n${systemPrompt}`; - } - } - - // 判断最后一条消息是否为 assistant,如果是则移除 - const lastMessage = processedMessages[processedMessages.length - 1]; - if (processedMessages.length > 0 && lastMessage.role === 'assistant') { - if (lastMessage.content[0].type === "text" && lastMessage.content[0].text === "{") { - logger.info('[Kiro] Removing last assistant with "{" message from processedMessages'); - processedMessages.pop(); - } - } - - // 合并相邻相同 role 的消息 - const mergedMessages = []; - for (let i = 0; i < processedMessages.length; i++) { - const currentMsg = processedMessages[i]; - - if (mergedMessages.length === 0) { - mergedMessages.push(currentMsg); - } else { - const lastMsg = mergedMessages[mergedMessages.length - 1]; - - // 判断当前消息和上一条消息是否为相同 role - if (currentMsg.role === lastMsg.role) { - // 合并消息内容 - if (Array.isArray(lastMsg.content) && Array.isArray(currentMsg.content)) { - // 如果都是数组,合并数组内容 - lastMsg.content.push(...currentMsg.content); - } else if (typeof lastMsg.content === 'string' && typeof currentMsg.content === 'string') { - // 如果都是字符串,用换行符连接 - lastMsg.content += '\n' + currentMsg.content; - } else if (Array.isArray(lastMsg.content) && typeof currentMsg.content === 'string') { - // 上一条是数组,当前是字符串,添加为 text 类型 - lastMsg.content.push({ type: 'text', text: currentMsg.content }); - } else if (typeof lastMsg.content === 'string' && Array.isArray(currentMsg.content)) { - // 上一条是字符串,当前是数组,转换为数组格式 - lastMsg.content = [{ type: 'text', text: lastMsg.content }, ...currentMsg.content]; - } - // logger.info(`[Kiro] Merged adjacent ${currentMsg.role} messages`); - } else { - mergedMessages.push(currentMsg); - } - } - } - - // 用合并后的消息替换原消息数组 - processedMessages.length = 0; - processedMessages.push(...mergedMessages); - - const codewhispererModel = MODEL_MAPPING[model] || MODEL_MAPPING[this.modelName]; - - // 动态压缩 tools(保留全部工具,但过滤掉 web_search/websearch) - let toolsContext = {}; - if (tools && Array.isArray(tools) && tools.length > 0) { - // 过滤掉 web_search 或 websearch 工具(忽略大小写) - const filteredTools = tools.filter(tool => { - const name = (tool.name || '').toLowerCase(); - const shouldIgnore = name === 'web_search' || name === 'websearch'; - if (shouldIgnore) { - logger.info(`[Kiro] Ignoring tool: ${tool.name}`); - } - return !shouldIgnore; - }); - - if (filteredTools.length === 0) { - // 所有工具都被过滤掉了,添加一个占位工具 - logger.info('[Kiro] All tools were filtered out, adding placeholder tool'); - const placeholderTool = { - toolSpecification: { - name: "no_tool_available", - description: "This is a placeholder tool when no other tools are available. It does nothing.", - inputSchema: { - json: { - type: "object", - properties: {} - } - } - } - }; - toolsContext = { tools: [placeholderTool] }; - } else { - const MAX_DESCRIPTION_LENGTH = 9216; - - let truncatedCount = 0; - const kiroTools = filteredTools - .filter(tool => { - // 过滤掉描述为空的工具 - if (!tool.description || tool.description.trim() === '') { - logger.info(`[Kiro] Ignoring tool with empty description: ${tool.name}`); - return false; - } - return true; - }) - .map(tool => { - let desc = tool.description || ""; - const originalLength = desc.length; - - if (desc.length > MAX_DESCRIPTION_LENGTH) { - desc = desc.substring(0, MAX_DESCRIPTION_LENGTH) + "..."; - truncatedCount++; - logger.info(`[Kiro] Truncated tool '${tool.name}' description: ${originalLength} -> ${desc.length} chars`); - } - - return { - toolSpecification: { - name: tool.name, - description: desc, - inputSchema: { - json: tool.input_schema || {} - } - } - }; - }); - - if (truncatedCount > 0) { - logger.info(`[Kiro] Truncated ${truncatedCount} tool description(s) to max ${MAX_DESCRIPTION_LENGTH} chars`); - } - - // 检查过滤后是否还有有效工具 - if (kiroTools.length === 0) { - logger.info('[Kiro] All tools were filtered out (empty descriptions), adding placeholder tool'); - const placeholderTool = { - toolSpecification: { - name: "no_tool_available", - description: "This is a placeholder tool when no other tools are available. It does nothing.", - inputSchema: { - json: { - type: "object", - properties: {} - } - } - } - }; - toolsContext = { tools: [placeholderTool] }; - } else { - toolsContext = { tools: kiroTools }; - } - } - } else { - // tools 为空或长度为 0 时,自动添加一个占位工具 - logger.info('[Kiro] No tools provided, adding placeholder tool'); - const placeholderTool = { - toolSpecification: { - name: "no_tool_available", - description: "This is a placeholder tool when no other tools are available. It does nothing.", - inputSchema: { - json: { - type: "object", - properties: {} - } - } - } - }; - toolsContext = { tools: [placeholderTool] }; - } - - const history = []; - let startIndex = 0; - - // Handle system prompt - if (systemPrompt) { - // If the first message is a user message, prepend system prompt to it - if (processedMessages[0].role === 'user') { - let firstUserContent = this.getContentText(processedMessages[0]); - history.push({ - userInputMessage: { - content: `${systemPrompt}\n\n${firstUserContent}`, - modelId: codewhispererModel, - origin: KIRO_CONSTANTS.ORIGIN_AI_EDITOR, - } - }); - startIndex = 1; // Start processing from the second message - } else { - // If the first message is not a user message, or if there's no initial user message, - // add system prompt as a standalone user message. - history.push({ - userInputMessage: { - content: systemPrompt, - modelId: codewhispererModel, - origin: KIRO_CONSTANTS.ORIGIN_AI_EDITOR, - } - }); - } - } - - // 保留最近 5 条历史消息中的图片 - const keepImageThreshold = 5; - for (let i = startIndex; i < processedMessages.length - 1; i++) { - const message = processedMessages[i]; - // 计算当前消息距离最后一条消息的位置(从后往前数) - const distanceFromEnd = (processedMessages.length - 1) - i; - // 如果距离末尾不超过 5 条,则保留图片 - const shouldKeepImages = distanceFromEnd <= keepImageThreshold; - - if (message.role === 'user') { - let userInputMessage = { - content: '', - modelId: codewhispererModel, - origin: KIRO_CONSTANTS.ORIGIN_AI_EDITOR - }; - let imageCount = 0; - let toolResults = []; - let images = []; - - if (Array.isArray(message.content)) { - for (const part of message.content) { - if (part.type === 'text') { - userInputMessage.content += part.text; - } else if (part.type === 'tool_result') { - toolResults.push({ - content: [{ text: this.getContentText(part.content) }], - status: 'success', - toolUseId: part.tool_use_id - }); - } else if (part.type === 'image') { - if (shouldKeepImages) { - // 最近 5 条消息内的图片保留原始数据 - images.push({ - format: part.source.media_type.split('/')[1], - source: { - bytes: part.source.data - } - }); - } else { - // 超过 5 条历史记录的图片只记录数量 - imageCount++; - } - } - } - } else { - userInputMessage.content = this.getContentText(message); - } - - // 如果有保留的图片,添加到消息中 - if (images.length > 0) { - userInputMessage.images = images; - logger.info(`[Kiro] Kept ${images.length} image(s) in recent history message (distance from end: ${distanceFromEnd})`); - } - - // 如果有被替换的图片,添加占位符说明 - if (imageCount > 0) { - const imagePlaceholder = `[此消息包含 ${imageCount} 张图片,已在历史记录中省略]`; - userInputMessage.content = userInputMessage.content - ? `${userInputMessage.content}\n${imagePlaceholder}` - : imagePlaceholder; - logger.info(`[Kiro] Replaced ${imageCount} image(s) with placeholder in old history message (distance from end: ${distanceFromEnd})`); - } - - if (toolResults.length > 0) { - // 去重 toolResults - Kiro API 不接受重复的 toolUseId - const uniqueToolResults = []; - const seenIds = new Set(); - for (const tr of toolResults) { - if (!seenIds.has(tr.toolUseId)) { - seenIds.add(tr.toolUseId); - uniqueToolResults.push(tr); - } - } - userInputMessage.userInputMessageContext = { toolResults: uniqueToolResults }; - } - - history.push({ userInputMessage }); - } else if (message.role === 'assistant') { - let assistantResponseMessage = { - content: '' - }; - let toolUses = []; - let thinkingText = ''; - - if (Array.isArray(message.content)) { - for (const part of message.content) { - if (part.type === 'text') { - assistantResponseMessage.content += part.text; - } else if (part.type === 'thinking') { - thinkingText += (part.thinking ?? part.text ?? ''); - } else if (part.type === 'tool_use') { - toolUses.push({ - input: part.input, - name: part.name, - toolUseId: part.id - }); - } - } - } else { - assistantResponseMessage.content = this.getContentText(message); - } - - if (thinkingText) { - assistantResponseMessage.content = assistantResponseMessage.content - ? `${KIRO_THINKING.START_TAG}${thinkingText}${KIRO_THINKING.END_TAG}\n\n${assistantResponseMessage.content}` - : `${KIRO_THINKING.START_TAG}${thinkingText}${KIRO_THINKING.END_TAG}`; - } - - // 只添加非空字段 - if (toolUses.length > 0) { - assistantResponseMessage.toolUses = toolUses; - } - - history.push({ assistantResponseMessage }); - } - } - - // Build current message - let currentMessage = processedMessages[processedMessages.length - 1]; - let currentContent = ''; - let currentToolResults = []; - let currentToolUses = []; - let currentImages = []; - - // 如果最后一条消息是 assistant,需要将其加入 history,然后创建一个 user 类型的 currentMessage - // 因为 CodeWhisperer API 的 currentMessage 必须是 userInputMessage 类型 - if (currentMessage.role === 'assistant') { - logger.info('[Kiro] Last message is assistant, moving it to history and creating user currentMessage'); - - // 构建 assistant 消息并加入 history - let assistantResponseMessage = { - content: '', - toolUses: [] - }; - let thinkingText = ''; - if (Array.isArray(currentMessage.content)) { - for (const part of currentMessage.content) { - if (part.type === 'text') { - assistantResponseMessage.content += part.text; - } else if (part.type === 'thinking') { - thinkingText += (part.thinking ?? part.text ?? ''); - } else if (part.type === 'tool_use') { - assistantResponseMessage.toolUses.push({ - input: part.input, - name: part.name, - toolUseId: part.id - }); - } - } - } else { - assistantResponseMessage.content = this.getContentText(currentMessage); - } - if (thinkingText) { - assistantResponseMessage.content = assistantResponseMessage.content - ? `${KIRO_THINKING.START_TAG}${thinkingText}${KIRO_THINKING.END_TAG}\n\n${assistantResponseMessage.content}` - : `${KIRO_THINKING.START_TAG}${thinkingText}${KIRO_THINKING.END_TAG}`; - } - if (assistantResponseMessage.toolUses.length === 0) { - delete assistantResponseMessage.toolUses; - } - history.push({ assistantResponseMessage }); - - // 设置 currentContent 为 "Continue",因为我们需要一个 user 消息来触发 AI 继续 - currentContent = 'Continue'; - } else { - // 最后一条消息是 user,需要确保 history 最后一个元素是 assistantResponseMessage - // Kiro API 要求 history 必须以 assistantResponseMessage 结尾 - if (history.length > 0) { - const lastHistoryItem = history[history.length - 1]; - if (!lastHistoryItem.assistantResponseMessage) { - // 最后一个不是 assistantResponseMessage,需要补全一个空的 - logger.info('[Kiro] History does not end with assistantResponseMessage, adding empty one'); - history.push({ - assistantResponseMessage: { - content: 'Continue' - } - }); - } - } - - // 处理 user 消息 - if (Array.isArray(currentMessage.content)) { - for (const part of currentMessage.content) { - if (part.type === 'text') { - currentContent += part.text; - } else if (part.type === 'tool_result') { - currentToolResults.push({ - content: [{ text: this.getContentText(part.content) }], - status: 'success', - toolUseId: part.tool_use_id - }); - } else if (part.type === 'tool_use') { - currentToolUses.push({ - input: part.input, - name: part.name, - toolUseId: part.id - }); - } else if (part.type === 'image') { - currentImages.push({ - format: part.source.media_type.split('/')[1], - source: { - bytes: part.source.data - } - }); - } - } - } else { - currentContent = this.getContentText(currentMessage); - } - - // Kiro API 要求 content 不能为空,即使有 toolResults - if (!currentContent) { - currentContent = currentToolResults.length > 0 ? 'Tool results provided.' : 'Continue'; - } - } - - const request = { - conversationState: { - chatTriggerType: KIRO_CONSTANTS.CHAT_TRIGGER_TYPE_MANUAL, - conversationId: conversationId, - currentMessage: {} // Will be populated as userInputMessage - } - }; - - // 只有当 history 非空时才添加(API 可能不接受空数组) - if (history.length > 0) { - request.conversationState.history = history; - } - - // currentMessage 始终是 userInputMessage 类型 - // 注意:API 不接受 null 值,空字段应该完全不包含 - const userInputMessage = { - content: currentContent, - modelId: codewhispererModel, - origin: KIRO_CONSTANTS.ORIGIN_AI_EDITOR - }; - - // 只有当 images 非空时才添加 - if (currentImages && currentImages.length > 0) { - userInputMessage.images = currentImages; - } - - // 构建 userInputMessageContext,只包含非空字段 - const userInputMessageContext = {}; - if (currentToolResults.length > 0) { - // 去重 toolResults - Kiro API 不接受重复的 toolUseId - const uniqueToolResults = []; - const seenToolUseIds = new Set(); - for (const tr of currentToolResults) { - if (!seenToolUseIds.has(tr.toolUseId)) { - seenToolUseIds.add(tr.toolUseId); - uniqueToolResults.push(tr); - } - } - userInputMessageContext.toolResults = uniqueToolResults; - } - if (Object.keys(toolsContext).length > 0 && toolsContext.tools) { - userInputMessageContext.tools = toolsContext.tools; - } - - // 只有当 userInputMessageContext 有内容时才添加 - if (Object.keys(userInputMessageContext).length > 0) { - userInputMessage.userInputMessageContext = userInputMessageContext; - } - - request.conversationState.currentMessage.userInputMessage = userInputMessage; - - if (this.authMethod === KIRO_CONSTANTS.AUTH_METHOD_SOCIAL) { - request.profileArn = this.profileArn; - } - - // 监控钩子:内部请求转换 - if (this.config?._monitorRequestId) { - try { - const { getPluginManager } = await import('../../core/plugin-manager.js'); - const pluginManager = getPluginManager(); - if (pluginManager) { - await pluginManager.executeHook('onInternalRequestConverted', { - requestId: this.config._monitorRequestId, - internalRequest: request, - converterName: 'buildCodewhispererRequest' - }); - } - } catch (e) { - logger.error('[Kiro] Error calling onInternalRequestConverted hook:', e.message); - } - } - - // fs.writeFile('claude-kiro-request'+Date.now()+'.json', JSON.stringify(request)); - return request; - } - - parseEventStreamChunk(rawData) { - const rawStr = Buffer.isBuffer(rawData) ? rawData.toString('utf8') : String(rawData); - let fullContent = ''; - const toolCalls = []; - let currentToolCallDict = null; - // logger.info(`rawStr=${rawStr}`); - - // 改进的 SSE 事件解析:匹配 :message-typeevent 后面的 JSON 数据 - // 使用更精确的正则来匹配 SSE 格式的事件 - const sseEventRegex = /:message-typeevent(\{[^]*?(?=:event-type|$))/g; - const legacyEventRegex = /event(\{.*?(?=event\{|$))/gs; - - // 首先尝试使用 SSE 格式解析 - let matches = [...rawStr.matchAll(sseEventRegex)]; - - // 如果 SSE 格式没有匹配到,回退到旧的格式 - if (matches.length === 0) { - matches = [...rawStr.matchAll(legacyEventRegex)]; - } - - for (const match of matches) { - const potentialJsonBlock = match[1]; - if (!potentialJsonBlock || potentialJsonBlock.trim().length === 0) { - continue; - } - - // 尝试找到完整的 JSON 对象 - let searchPos = 0; - while ((searchPos = potentialJsonBlock.indexOf('}', searchPos + 1)) !== -1) { - const jsonCandidate = potentialJsonBlock.substring(0, searchPos + 1).trim(); - try { - const eventData = JSON.parse(jsonCandidate); - - // 优先处理结构化工具调用事件 - if (eventData.name && eventData.toolUseId) { - if (!currentToolCallDict) { - currentToolCallDict = { - id: eventData.toolUseId, - type: "function", - function: { - name: eventData.name, - arguments: "" - } - }; - } - if (eventData.input) { - currentToolCallDict.function.arguments += eventData.input; - } - if (eventData.stop) { - try { - const args = JSON.parse(currentToolCallDict.function.arguments); - currentToolCallDict.function.arguments = JSON.stringify(args); - } catch (e) { - logger.warn(`[Kiro] Tool call arguments not valid JSON: ${currentToolCallDict.function.arguments}`); - } - toolCalls.push(currentToolCallDict); - currentToolCallDict = null; - } - } else if (!eventData.followupPrompt && eventData.content) { - // 处理内容,移除转义字符 - let decodedContent = eventData.content; - // 处理常见的转义序列 - decodedContent = decodedContent.replace(/(? ({ - role: content.role || 'user', - content: content.parts?.map(part => part.text).join('') || '' - })); - } - - if (!messages || !Array.isArray(messages) || messages.length === 0) { - throw new Error('No messages found in request body'); - } - - const requestData = await this.buildCodewhispererRequest(messages, model, body.tools, body.system, body.thinking); - - try { - const token = this.accessToken; // Use the already initialized token - const headers = { - 'Authorization': `Bearer ${token}`, - 'amz-sdk-invocation-id': `${uuidv4()}`, - }; - - // 当 model 以 kiro-amazonq 开头时,使用 amazonQUrl,否则使用 baseUrl - const requestUrl = model.startsWith('amazonq') ? this.amazonQUrl : this.baseUrl; - const axiosConfig = { - method: 'post', - url: requestUrl, - data: requestData, - headers - }; - this._applySidecar(axiosConfig); - const response = await this.axiosInstance.request(axiosConfig); - return response; - } catch (error) { - const status = error.response?.status; - const errorCode = error.code; - const errorMessage = error.message || ''; - - // 检查是否为可重试的网络错误 - const isNetworkError = isRetryableNetworkError(error); - - // Handle 401 (Unauthorized) - refresh UUID first, then try to refresh token - if (status === 401 && !isRetry) { - logger.info('[Kiro] Received 401. Refreshing UUID and triggering background refresh via PoolManager...'); - - // 1. 先刷新 UUID - const newUuid = this._refreshUuid(); - if (newUuid) { - logger.info(`[Kiro] UUID refreshed: ${this.uuid} -> ${newUuid}`); - this.uuid = newUuid; - } - - // 标记当前凭证为不健康(会自动进入刷新队列) - this._markCredentialNeedRefresh('401 Unauthorized - Triggering auto-refresh'); - // Mark error for credential switch without recording error count - error.shouldSwitchCredential = true; - error.skipErrorCount = true; - throw error; - } - - // Handle 402 (Payment Required / Quota Exceeded) - verify usage and mark as unhealthy with recovery time - if (status === 402 && !isRetry) { - await this._handle402Error(error, 'callApi'); - } - - // Handle 403 (Forbidden) - mark as unhealthy immediately, no retry - if (status === 403 && !isRetry) { - logger.info('[Kiro] Received 403. Marking credential as need refresh...'); - - // 检查是否为 temporarily suspended 错误 - const isSuspended = errorMessage && errorMessage.toLowerCase().includes('temporarily is suspended'); - - if (isSuspended) { - // temporarily suspended 错误:直接标记为不健康,不刷新 UUID - logger.info('[Kiro] Account temporarily suspended. Marking as unhealthy without UUID refresh...'); - this._markCredentialUnhealthy('403 Forbidden - Account temporarily suspended', error); - } else { - // 其他 403 错误:先刷新 UUID,然后标记需要刷新 - // const newUuid = this._refreshUuid(); - // if (newUuid) { - // logger.info(`[Kiro] UUID refreshed: ${this.uuid} -> ${newUuid}`); - // this.uuid = newUuid; - // } - this._markCredentialNeedRefresh('403 Forbidden', error); - } - - // Mark error for credential switch without recording error count - error.shouldSwitchCredential = true; - error.skipErrorCount = true; - throw error; - } - - // Handle 429 (Too Many Requests) - wait baseDelay then switch credential - if (status === 429) { - logger.info(`[Kiro] Received 429 (Too Many Requests). Waiting ${baseDelay}ms before switching credential...`); - await new Promise(resolve => setTimeout(resolve, baseDelay)); - // Mark error for credential switch without recording error count - error.shouldSwitchCredential = true; - error.skipErrorCount = true; - throw error; - } - - // Handle 5xx server errors - wait baseDelay then switch credential - if (status >= 500 && status < 600) { - logger.info(`[Kiro] Received ${status} server error. Waiting ${baseDelay}ms before switching credential...`); - await new Promise(resolve => setTimeout(resolve, baseDelay)); - // Mark error for credential switch without recording error count - error.shouldSwitchCredential = true; - error.skipErrorCount = true; - throw error; - } - - // Handle network errors (ECONNRESET, ETIMEDOUT, etc.) with exponential backoff - if (isNetworkError && retryCount < maxRetries) { - const delay = baseDelay * Math.pow(2, retryCount); - const errorIdentifier = errorCode || errorMessage.substring(0, 50); - logger.info(`[Kiro] Network error (${errorIdentifier}). Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, delay)); - return this.callApi(method, model, body, isRetry, retryCount + 1); - } - - logger.error(`[Kiro] API call failed (Status: ${status}, Code: ${errorCode}):`, error.message); - throw error; - } - } - - /** - * Helper method to refresh the current credential's UUID - * Used when encountering 401 errors to get a fresh identity - * @returns {string|null} - The new UUID, or null if refresh failed - * @private - */ - _refreshUuid() { - const poolManager = getProviderPoolManager(); - if (poolManager && this.uuid) { - const newUuid = poolManager.refreshProviderUuid(MODEL_PROVIDER.KIRO_API, { - uuid: this.uuid - }); - return newUuid; - } else { - logger.warn(`[Kiro] Cannot refresh UUID: poolManager=${!!poolManager}, uuid=${this.uuid}`); - return null; - } - } - - /** - * Helper method to mark the current credential as unhealthy - * @param {string} reason - The reason for marking unhealthy - * @param {Error} [error] - Optional error object to attach the marker to - * @returns {boolean} - Whether the credential was successfully marked as unhealthy - * @private - */ - _markCredentialNeedRefresh(reason, error = null) { - const poolManager = getProviderPoolManager(); - if (poolManager && this.uuid) { - logger.info(`[Kiro] Marking credential ${this.uuid} as needs refresh. Reason: ${reason}`); - // 使用新的 markProviderNeedRefresh 方法代替 markProviderUnhealthyImmediately - poolManager.markProviderNeedRefresh(MODEL_PROVIDER.KIRO_API, { - uuid: this.uuid - }); - // Attach marker to error object to prevent duplicate marking in upper layers - if (error) { - error.credentialMarkedUnhealthy = true; - } - return true; - } else { - logger.warn(`[Kiro] Cannot mark credential as unhealthy: poolManager=${!!poolManager}, uuid=${this.uuid}`); - return false; - } - } - - /** - * Helper method to mark the current credential as unhealthy - * @param {string} reason - The reason for marking unhealthy - * @param {Error} [error] - Optional error object to attach the marker to - * @returns {boolean} - Whether the credential was successfully marked as unhealthy - * @private - */ - _markCredentialUnhealthy(reason, error = null) { - const poolManager = getProviderPoolManager(); - if (poolManager && this.uuid) { - logger.info(`[Kiro] Marking credential ${this.uuid} as unhealthy. Reason: ${reason}`); - poolManager.markProviderUnhealthyImmediately(MODEL_PROVIDER.KIRO_API, { - uuid: this.uuid - }, reason); - // Attach marker to error object to prevent duplicate marking in upper layers - if (error) { - error.credentialMarkedUnhealthy = true; - } - return true; - } else { - logger.warn(`[Kiro] Cannot mark credential as unhealthy: poolManager=${!!poolManager}, uuid=${this.uuid}`); - return false; - } - } - - /** - * Helper method to mark the current credential as unhealthy with a scheduled recovery time - * Used for quota exhaustion (402) where quota resets at a specific time (e.g., 1st of next month) - * @param {string} reason - The reason for marking unhealthy - * @param {Error} [error] - Optional error object to attach the marker to - * @param {Date} [recoveryTime] - The time when the credential should be marked healthy again - * @returns {boolean} - Whether the credential was successfully marked as unhealthy - * @private - */ - _markCredentialUnhealthyWithRecovery(reason, error = null, recoveryTime = null) { - const poolManager = getProviderPoolManager(); - if (poolManager && this.uuid) { - logger.info(`[Kiro] Marking credential ${this.uuid} as unhealthy with recovery time. Reason: ${reason}, Recovery: ${recoveryTime?.toISOString()}`); - poolManager.markProviderUnhealthyWithRecoveryTime(MODEL_PROVIDER.KIRO_API, { - uuid: this.uuid - }, reason, recoveryTime); - // Attach marker to error object to prevent duplicate marking in upper layers - if (error) { - error.credentialMarkedUnhealthy = true; - } - return true; - } else { - logger.warn(`[Kiro] Cannot mark credential as unhealthy: poolManager=${!!poolManager}, uuid=${this.uuid}`); - return false; - } - } - - /** - * 计算下月1日 00:00:00 UTC 时间 - * @returns {Date} 下月1日的 Date 对象 - * @private - */ - _getNextMonthFirstDay() { - const now = new Date(); - return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() + 1, 1, 0, 0, 0, 0)); - } - - /** - * 处理 402 错误(配额耗尽) - * 验证用量限制并标记凭证为不健康,设置恢复时间为下月1日 - * @param {Error} error - 原始错误对象 - * @param {string} context - 错误发生的上下文(如 'callApi', 'stream') - * @throws {Error} 抛出带有切换凭证标记的错误 - * @private - */ - async _handle402Error(error, context = 'unknown') { - logger.info(`[Kiro] Received 402 (Quota Exceeded) in ${context}. Verifying usage limits...`); - try { - // Verify usage limits to confirm quota exhaustion - const usageLimits = await this.getUsageLimits(); - const isQuotaExhausted = usageLimits?.usedCount >= usageLimits?.limitCount; - - logger.info(`[Kiro] Quota confirmed exhausted: ${usageLimits?.usedCount}/${usageLimits?.limitCount}`); - // Calculate recovery time: 1st day of next month at 00:00:00 UTC - const nextMonth = this._getNextMonthFirstDay(); - this._markCredentialUnhealthyWithRecovery('402 Payment Required - Quota Exhausted', error, nextMonth); - } catch (usageError) { - logger.warn('[Kiro] Failed to verify usage limits:', usageError.message); - // If we can't verify, still mark as unhealthy with recovery time - const nextMonth = this._getNextMonthFirstDay(); - this._markCredentialUnhealthyWithRecovery('402 Payment Required - Quota Exceeded (unverified)', error, nextMonth); - } - // Mark error for credential switch without recording error count - error.shouldSwitchCredential = true; - error.skipErrorCount = true; - throw error; - } - - _processApiResponse(response) { - const rawResponseText = Buffer.isBuffer(response.data) ? response.data.toString('utf8') : String(response.data); - //logger.info(`[Kiro] Raw response length: ${rawResponseText.length}`); - if (rawResponseText.includes("[Called")) { - logger.info("[Kiro] Raw response contains [Called marker."); - } - - // 1. Parse structured events and bracket calls from parsed content - const parsedFromEvents = this.parseEventStreamChunk(rawResponseText); - let fullResponseText = parsedFromEvents.content; - let allToolCalls = [...parsedFromEvents.toolCalls]; // clone - //logger.info(`[Kiro] Found ${allToolCalls.length} tool calls from event stream parsing.`); - - // 2. Crucial fix from Python example: Parse bracket tool calls from the original raw response - const rawBracketToolCalls = parseBracketToolCalls(rawResponseText); - if (rawBracketToolCalls) { - //logger.info(`[Kiro] Found ${rawBracketToolCalls.length} bracket tool calls in raw response.`); - allToolCalls.push(...rawBracketToolCalls); - } - - // 3. Deduplicate all collected tool calls - const uniqueToolCalls = deduplicateToolCalls(allToolCalls); - //logger.info(`[Kiro] Total unique tool calls after deduplication: ${uniqueToolCalls.length}`); - - // 4. Clean up response text by removing all tool call syntax from the final text. - // The text from parseEventStreamChunk is already partially cleaned. - // We re-clean here with all unique tool calls to be certain. - if (uniqueToolCalls.length > 0) { - for (const tc of uniqueToolCalls) { - const funcName = tc.function.name; - const escapedName = funcName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const pattern = new RegExp(`\\[Called\\s+${escapedName}\\s+with\\s+args:\\s*\\{[^}]*(?:\\{[^}]*\\}[^}]*)*\\}\\]`, 'gs'); - fullResponseText = fullResponseText.replace(pattern, ''); - } - fullResponseText = fullResponseText.replace(/\s+/g, ' ').trim(); - } - - //logger.info(`[Kiro] Final response text after tool call cleanup: ${fullResponseText}`); - //logger.info(`[Kiro] Final tool calls after deduplication: ${JSON.stringify(uniqueToolCalls)}`); - return { responseText: fullResponseText, toolCalls: uniqueToolCalls }; - } - - async generateContent(model, requestBody) { - if (!this.isInitialized) await this.initialize(); - - // 临时存储 monitorRequestId - if (requestBody._monitorRequestId) { - this.config._monitorRequestId = requestBody._monitorRequestId; - delete requestBody._monitorRequestId; - } - if (requestBody._requestBaseUrl) { - delete requestBody._requestBaseUrl; - } - - // 检查 token 是否即将过期,如果是则推送到刷新队列 - if (this.isExpiryDateNear()) { - logger.info('[Kiro] Token is near expiry, marking credential as need refresh...'); - this._markCredentialNeedRefresh('Token near expiry in generateContent'); - } - - const finalModel = MODEL_MAPPING[model] ? model : this.modelName; - logger.info(`[Kiro] Calling generateContent with model: ${finalModel}`); - - // Estimate input tokens before making the API call - const inputTokens = this.estimateInputTokens(requestBody); - - const response = await this.callApi('', finalModel, requestBody); - - try { - const { responseText, toolCalls } = this._processApiResponse(response); - const thinkingType = requestBody?.thinking?.type; - const thinkingRequested = typeof thinkingType === 'string' && - (thinkingType.toLowerCase() === 'enabled' || thinkingType.toLowerCase() === 'adaptive'); - const contentForClaude = thinkingRequested - ? this._toClaudeContentBlocksFromKiroText(responseText) - : responseText; - return this.buildClaudeResponse(contentForClaude, false, 'assistant', model, toolCalls, inputTokens); - } catch (error) { - logger.error('[Kiro] Error in generateContent:', error); - throw error; - } - } - - /** - * 解析 AWS Event Stream 格式,提取所有完整的 JSON 事件 - * 返回 { events: 解析出的事件数组, remaining: 未处理完的缓冲区 } - */ - parseAwsEventStreamBuffer(buffer) { - const events = []; - let remaining = buffer; - let searchStart = 0; - - while (true) { - // 查找真正的 JSON payload 起始位置 - // AWS Event Stream 包含二进制头部,我们只搜索有效的 JSON 模式 - // Kiro 返回格式: {"content":"..."} 或 {"name":"xxx","toolUseId":"xxx",...} 或 {"followupPrompt":"..."} - - // 搜索所有可能的 JSON payload 开头模式 - // Kiro 返回的 toolUse 可能分多个事件: - // 1. {"name":"xxx","toolUseId":"xxx"} - 开始 - // 2. {"input":"..."} - input 数据(可能多次) - // 3. {"stop":true} - 结束 - // 4. {"contextUsagePercentage":...} - 上下文使用百分比(最后一条消息) - const contentStart = remaining.indexOf('{"content":', searchStart); - const nameStart = remaining.indexOf('{"name":', searchStart); - const followupStart = remaining.indexOf('{"followupPrompt":', searchStart); - const inputStart = remaining.indexOf('{"input":', searchStart); - const stopStart = remaining.indexOf('{"stop":', searchStart); - const contextUsageStart = remaining.indexOf('{"contextUsagePercentage":', searchStart); - - // 找到最早出现的有效 JSON 模式 - const candidates = [contentStart, nameStart, followupStart, inputStart, stopStart, contextUsageStart].filter(pos => pos >= 0); - if (candidates.length === 0) break; - - const jsonStart = Math.min(...candidates); - if (jsonStart < 0) break; - - // 正确处理嵌套的 {} - 使用括号计数法 - let braceCount = 0; - let jsonEnd = -1; - let inString = false; - let escapeNext = false; - - for (let i = jsonStart; i < remaining.length; i++) { - const char = remaining[i]; - - if (escapeNext) { - escapeNext = false; - continue; - } - - if (char === '\\') { - escapeNext = true; - continue; - } - - if (char === '"') { - inString = !inString; - continue; - } - - if (!inString) { - if (char === '{') { - braceCount++; - } else if (char === '}') { - braceCount--; - if (braceCount === 0) { - jsonEnd = i; - break; - } - } - } - } - - if (jsonEnd < 0) { - // 不完整的 JSON,保留在缓冲区等待更多数据 - remaining = remaining.substring(jsonStart); - break; - } - - const jsonStr = remaining.substring(jsonStart, jsonEnd + 1); - try { - const parsed = JSON.parse(jsonStr); - // 处理 content 事件 - if (parsed.content !== undefined && !parsed.followupPrompt) { - // 处理转义字符 - let decodedContent = parsed.content; - // 无须处理转义的换行符,原来要处理是因为智能体返回的 content 需要通过换行符切割不同的json - // decodedContent = decodedContent.replace(/(?= remaining.length) { - remaining = ''; - break; - } - } - - // 如果 searchStart 有进展,截取剩余部分 - if (searchStart > 0 && remaining.length > 0) { - remaining = remaining.substring(searchStart); - } - - return { events, remaining }; - } - - /** - * 真正的流式 API 调用 - 使用 responseType: 'stream' - */ - async * streamApiReal(method, model, body, isRetry = false, retryCount = 0) { - if (!this.isInitialized) await this.initialize(); - const maxRetries = this.config.REQUEST_MAX_RETRIES || 3; - const baseDelay = this.config.REQUEST_BASE_DELAY || 1000; - - // 处理不同格式的请求体(messages 或 contents) - let messages = body.messages; - if (!messages && body.contents) { - // 将 Gemini 格式的 contents 转换为 messages 格式 - messages = body.contents.map(content => ({ - role: content.role || 'user', - content: content.parts?.map(part => part.text).join('') || '' - })); - } - - if (!messages || !Array.isArray(messages) || messages.length === 0) { - throw new Error('No messages found in request body'); - } - - const requestData = await this.buildCodewhispererRequest(messages, model, body.tools, body.system, body.thinking); - - const token = this.accessToken; - const headers = { - 'Authorization': `Bearer ${token}`, - 'amz-sdk-invocation-id': `${uuidv4()}`, - }; - - const requestUrl = model.startsWith('amazonq') ? this.amazonQUrl : this.baseUrl; - - let stream = null; - try { - const axiosConfig = { - method: 'post', - url: requestUrl, - data: requestData, - headers, - responseType: 'stream' - }; - this._applySidecar(axiosConfig); - const response = await this.axiosInstance.request(axiosConfig); - - stream = response.data; - let buffer = ''; - let lastContentEvent = null; // 用于检测连续重复的 content 事件 - - for await (const chunk of stream) { - buffer += chunk.toString(); - - // 解析缓冲区中的事件 - const { events, remaining } = this.parseAwsEventStreamBuffer(buffer); - buffer = remaining; - - // yield 所有事件,但过滤连续完全相同的 content 事件(Kiro API 有时会重复发送) - for (const event of events) { - if (event.type === 'content' && event.data) { - // 检查是否与上一个 content 事件完全相同 - if (lastContentEvent === event.data) { - // 跳过重复的内容 - continue; - } - lastContentEvent = event.data; - yield { type: 'content', content: event.data }; - } else if (event.type === 'toolUse') { - yield { type: 'toolUse', toolUse: event.data }; - } else if (event.type === 'toolUseInput') { - yield { type: 'toolUseInput', input: event.data.input }; - } else if (event.type === 'toolUseStop') { - yield { type: 'toolUseStop', stop: event.data.stop }; - } else if (event.type === 'contextUsage') { - yield { type: 'contextUsage', contextUsagePercentage: event.data.contextUsagePercentage }; - } - } - } - } catch (error) { - // 确保出错时关闭流 - if (stream && typeof stream.destroy === 'function') { - stream.destroy(); - } - - const status = error.response?.status; - const errorCode = error.code; - const errorMessage = error.message || ''; - - // 检查是否为可重试的网络错误 - const isNetworkError = isRetryableNetworkError(error); - - // Handle 401 (Unauthorized) - try to refresh token first - if (status === 401 && !isRetry) { - logger.info('[Kiro] Received 401 in stream. Triggering background refresh via PoolManager...'); - - // 1. 先刷新 UUID - const newUuid = this._refreshUuid(); - if (newUuid) { - logger.info(`[Kiro] UUID refreshed: ${this.uuid} -> ${newUuid}`); - this.uuid = newUuid; - } - // 标记当前凭证为不健康(会自动进入刷新队列) - this._markCredentialNeedRefresh('401 Unauthorized in stream - Triggering auto-refresh'); - // Mark error for credential switch without recording error count - error.shouldSwitchCredential = true; - error.skipErrorCount = true; - throw error; - } - - // Handle 402 (Payment Required / Quota Exceeded) - verify usage and mark as unhealthy with recovery time - if (status === 402 && !isRetry) { - await this._handle402Error(error, 'stream'); - } - - // Handle 403 (Forbidden) - mark as unhealthy immediately, no retry - if (status === 403 && !isRetry) { - logger.info('[Kiro] Received 403 in stream. Marking credential as need refresh...'); - - // 检查是否为 temporarily suspended 错误 - const isSuspended = errorMessage && errorMessage.toLowerCase().includes('temporarily is suspended'); - - if (isSuspended) { - // temporarily suspended 错误:直接标记为不健康,不刷新 UUID - logger.info('[Kiro] Account temporarily suspended in stream. Marking as unhealthy without UUID refresh...'); - this._markCredentialUnhealthy('403 Forbidden - Account temporarily suspended', error); - } else { - // 其他 403 错误:先刷新 UUID,然后标记需要刷新 - // const newUuid = this._refreshUuid(); - // if (newUuid) { - // logger.info(`[Kiro] UUID refreshed: ${this.uuid} -> ${newUuid}`); - // this.uuid = newUuid; - // } - this._markCredentialNeedRefresh('403 Forbidden', error); - } - - // Mark error for credential switch without recording error count - error.shouldSwitchCredential = true; - error.skipErrorCount = true; - throw error; - } - - // Handle 429 (Too Many Requests) - wait baseDelay then switch credential - if (status === 429) { - logger.info(`[Kiro] Received 429 (Too Many Requests) in stream. Waiting ${baseDelay}ms before switching credential...`); - await new Promise(resolve => setTimeout(resolve, baseDelay)); - // Mark error for credential switch without recording error count - error.shouldSwitchCredential = true; - error.skipErrorCount = true; - throw error; - } - - // Handle 5xx server errors - wait baseDelay then switch credential - if (status >= 500 && status < 600) { - logger.info(`[Kiro] Received ${status} server error in stream. Waiting ${baseDelay}ms before switching credential...`); - await new Promise(resolve => setTimeout(resolve, baseDelay)); - // Mark error for credential switch without recording error count - error.shouldSwitchCredential = true; - error.skipErrorCount = true; - throw error; - } - - // Handle network errors (ECONNRESET, ETIMEDOUT, etc.) with exponential backoff - if (isNetworkError && retryCount < maxRetries) { - const delay = baseDelay * Math.pow(2, retryCount); - const errorIdentifier = errorCode || errorMessage.substring(0, 50); - logger.info(`[Kiro] Network error (${errorIdentifier}) in stream. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, delay)); - yield* this.streamApiReal(method, model, body, isRetry, retryCount + 1); - return; - } - - logger.error(`[Kiro] Stream API call failed (Status: ${status}, Code: ${errorCode}):`, error.message); - throw error; - } finally { - // 确保流被关闭,释放资源 - if (stream && typeof stream.destroy === 'function') { - stream.destroy(); - } - } - } - - // 保留旧的非流式方法用于 generateContent - async streamApi(method, model, body, isRetry = false, retryCount = 0) { - try { - return await this.callApi(method, model, body, isRetry, retryCount); - } catch (error) { - logger.error('[Kiro] Error calling API:', error); - throw error; - } - } - - // 真正的流式传输实现 - async * generateContentStream(model, requestBody) { - if (!this.isInitialized) await this.initialize(); - - // 临时存储 monitorRequestId - if (requestBody._monitorRequestId) { - this.config._monitorRequestId = requestBody._monitorRequestId; - delete requestBody._monitorRequestId; - } - if (requestBody._requestBaseUrl) { - delete requestBody._requestBaseUrl; - } - - // 检查 token 是否即将过期,如果是则推送到刷新队列 - if (this.isExpiryDateNear()) { - logger.info('[Kiro] Token is near expiry, marking credential as need refresh...'); - this._markCredentialNeedRefresh('Token near expiry in generateContentStream'); - } - - const finalModel = MODEL_MAPPING[model] ? model : this.modelName; - logger.info(`[Kiro] Calling generateContentStream with model: ${finalModel} (real streaming)`); - - let inputTokens = 0; - let contextUsagePercentage = null; - const messageId = `${uuidv4()}`; - - const thinkingType = requestBody?.thinking?.type; - const thinkingRequested = typeof thinkingType === 'string' && - (thinkingType.toLowerCase() === 'enabled' || thinkingType.toLowerCase() === 'adaptive'); - - const streamState = { - thinkingRequested, - buffer: '', - pendingTextBeforeThinking: '', - inThinking: false, - thinkingExtracted: false, - thinkingBlockIndex: null, - textBlockIndex: null, - nextBlockIndex: 0, - stoppedBlocks: new Set(), - stripThinkingLeadingNewline: false, - stripTextLeadingNewlinesAfterThinking: false, - }; - - const ensureBlockStart = (blockType) => { - if (blockType === 'thinking') { - if (streamState.thinkingBlockIndex != null) return []; - const idx = streamState.nextBlockIndex++; - streamState.thinkingBlockIndex = idx; - return [{ - type: "content_block_start", - index: idx, - content_block: { type: "thinking", thinking: "" } - }]; - } - if (blockType === 'text') { - if (streamState.textBlockIndex != null) return []; - const idx = streamState.nextBlockIndex++; - streamState.textBlockIndex = idx; - return [{ - type: "content_block_start", - index: idx, - content_block: { type: "text", text: "" } - }]; - } - return []; - }; - - const stopBlock = (index) => { - if (index == null) return []; - if (streamState.stoppedBlocks.has(index)) return []; - streamState.stoppedBlocks.add(index); - return [{ type: "content_block_stop", index }]; - }; - - const createTextDeltaEvents = (text) => { - if (!text) return []; - const events = []; - events.push(...ensureBlockStart('text')); - events.push({ - type: "content_block_delta", - index: streamState.textBlockIndex, - delta: { type: "text_delta", text } - }); - return events; - }; - - const createThinkingDeltaEvents = (thinking) => { - const events = []; - events.push(...ensureBlockStart('thinking')); - events.push({ - type: "content_block_delta", - index: streamState.thinkingBlockIndex, - delta: { type: "thinking_delta", thinking } - }); - return events; - }; - - function* pushEvents(events) { - for (const ev of events) { - yield ev; - } - } - - try { - let totalContent = ''; - let outputTokens = 0; - const toolCalls = []; - let currentToolCall = null; // 用于累积结构化工具调用 - const toolUseBlockIndexes = new Map(); // toolUseId -> content block index - - const estimatedInputTokens = this.estimateInputTokens(requestBody); - - // 1. 先发送 message_start 事件 - yield { - type: "message_start", - message: { - id: messageId, - type: "message", - role: "assistant", - model: model, - usage: { - input_tokens: estimatedInputTokens, - output_tokens: 0, - cache_creation_input_tokens: 0, - cache_read_input_tokens: 0 - }, - content: [] - } - }; - - // 2. 流式接收并发送每个 content_block_delta - for await (const event of this.streamApiReal('', finalModel, requestBody)) { - if (event.type === 'contextUsage' && event.contextUsagePercentage) { - // 捕获上下文使用百分比(包含输入和输出的总使用量) - contextUsagePercentage = event.contextUsagePercentage; - } else if (event.type === 'content' && event.content) { - totalContent += event.content; - - if (!thinkingRequested) { - yield* pushEvents(createTextDeltaEvents(event.content)); - continue; - } - - streamState.buffer += event.content; - const events = []; - - while (streamState.buffer.length > 0) { - if (!streamState.inThinking && !streamState.thinkingExtracted) { - const startPos = findRealTag(streamState.buffer, KIRO_THINKING.START_TAG); - if (startPos !== -1) { - const before = streamState.buffer.slice(0, startPos); - const beforeCombined = `${streamState.pendingTextBeforeThinking}${before}`; - // Avoid creating meaningless text blocks before thinking. - if (beforeCombined && !isWhitespaceOnly(beforeCombined)) { - events.push(...createTextDeltaEvents(beforeCombined)); - } - streamState.pendingTextBeforeThinking = ''; - - streamState.buffer = streamState.buffer.slice(startPos + KIRO_THINKING.START_TAG.length); - streamState.inThinking = true; - streamState.stripThinkingLeadingNewline = true; - continue; - } - - const safeLen = Math.max(0, streamState.buffer.length - KIRO_THINKING.START_TAG.length); - if (safeLen > 0) { - const safeText = streamState.buffer.slice(0, safeLen); - if (safeText) { - if (isWhitespaceOnly(safeText)) { - // Buffer whitespace until we know whether a thinking block appears. - // This prevents a leading text block from being created before thinking. - const maxKeep = 1024; - const remaining = maxKeep - streamState.pendingTextBeforeThinking.length; - if (remaining > 0) { - streamState.pendingTextBeforeThinking += safeText.slice(0, remaining); - } - } else { - const combined = `${streamState.pendingTextBeforeThinking}${safeText}`; - streamState.pendingTextBeforeThinking = ''; - events.push(...createTextDeltaEvents(combined)); - } - } - streamState.buffer = streamState.buffer.slice(safeLen); - } - break; - } - - if (streamState.inThinking) { - // Strip a single leading newline after `` (may be split across chunks). - if (streamState.stripThinkingLeadingNewline) { - if (streamState.buffer.startsWith('\r\n')) { - streamState.buffer = streamState.buffer.slice(2); - streamState.stripThinkingLeadingNewline = false; - } else if (streamState.buffer.startsWith('\n')) { - streamState.buffer = streamState.buffer.slice(1); - streamState.stripThinkingLeadingNewline = false; - } else if (streamState.buffer.length > 0) { - streamState.stripThinkingLeadingNewline = false; - } - } - - let endPos = findRealThinkingEndTag(streamState.buffer); - if (endPos === -1) endPos = findRealThinkingEndTagAtBufferEnd(streamState.buffer); - if (endPos !== -1) { - const thinkingPart = streamState.buffer.slice(0, endPos); - if (thinkingPart) events.push(...createThinkingDeltaEvents(thinkingPart)); - - streamState.buffer = streamState.buffer.slice(endPos + KIRO_THINKING.END_TAG.length); - streamState.inThinking = false; - streamState.thinkingExtracted = true; - streamState.stripThinkingLeadingNewline = false; - - events.push(...createThinkingDeltaEvents("")); - events.push(...stopBlock(streamState.thinkingBlockIndex)); - - // Strip '\n\n' after the end tag once we switch back to text (may arrive in next chunk). - streamState.stripTextLeadingNewlinesAfterThinking = true; - continue; - } - - const safeLen = Math.max(0, streamState.buffer.length - KIRO_THINKING.END_TAG.length); - if (safeLen > 0) { - const safeThinking = streamState.buffer.slice(0, safeLen); - if (safeThinking) events.push(...createThinkingDeltaEvents(safeThinking)); - streamState.buffer = streamState.buffer.slice(safeLen); - } - break; - } - - if (streamState.thinkingExtracted) { - let rest = streamState.buffer; - streamState.buffer = ''; - if (streamState.stripTextLeadingNewlinesAfterThinking) { - if (rest.startsWith('\r\n\r\n')) rest = rest.slice(4); - else if (rest.startsWith('\n\n')) rest = rest.slice(2); - streamState.stripTextLeadingNewlinesAfterThinking = false; - } - if (rest) events.push(...createTextDeltaEvents(rest)); - break; - } - } - - yield* pushEvents(events); - } else if (event.type === 'toolUse') { - const tc = event.toolUse; - const toolEvents = []; - - // 统计工具调用的内容到 totalContent(用于 token 计算) - if (tc.name) totalContent += tc.name; - if (tc.input) totalContent += tc.input; - - // 工具调用事件(包含 name 和 toolUseId) - if (tc.name && tc.toolUseId) { - // 遇到工具调用时,立即关闭文本块,避免前端等待到流结束才看到 content_block_stop - toolEvents.push(...stopBlock(streamState.textBlockIndex)); - - // 同一工具调用续传 - if (currentToolCall && currentToolCall.toolUseId === tc.toolUseId) { - currentToolCall.input += tc.input || ''; - } else { - // 切换到新的工具调用前,先收尾旧调用 - if (currentToolCall) { - const prevBlockIndex = toolUseBlockIndexes.get(currentToolCall.toolUseId); - let parsedInput = currentToolCall.input; - try { - parsedInput = JSON.parse(currentToolCall.input); - } catch (e) { - // input 不是有效 JSON,保持原样 - } - toolCalls.push({ - toolUseId: currentToolCall.toolUseId, - name: currentToolCall.name, - input: parsedInput - }); - if (prevBlockIndex != null) { - toolEvents.push({ type: "content_block_stop", index: prevBlockIndex }); - toolUseBlockIndexes.delete(currentToolCall.toolUseId); - } - } - - const blockIndex = streamState.nextBlockIndex++; - toolUseBlockIndexes.set(tc.toolUseId, blockIndex); - toolEvents.push({ - type: "content_block_start", - index: blockIndex, - content_block: { - type: "tool_use", - id: tc.toolUseId || `tool_${uuidv4()}`, - name: tc.name, - input: {} - } - }); - - currentToolCall = { - toolUseId: tc.toolUseId, - name: tc.name, - input: '' - }; - currentToolCall.input += tc.input || ''; - } - - // 实时向前端推送工具参数增量 - if (tc.input) { - const blockIndex = toolUseBlockIndexes.get(tc.toolUseId); - if (blockIndex != null) { - toolEvents.push({ - type: "content_block_delta", - index: blockIndex, - delta: { - type: "input_json_delta", - partial_json: tc.input - } - }); - } - } - - // 如果这个事件包含 stop,立即结束当前工具块 - if (tc.stop && currentToolCall) { - let parsedInput = currentToolCall.input; - try { - parsedInput = JSON.parse(currentToolCall.input); - } catch (e) { - // input 不是有效 JSON,保持原样 - } - toolCalls.push({ - toolUseId: currentToolCall.toolUseId, - name: currentToolCall.name, - input: parsedInput - }); - - const blockIndex = toolUseBlockIndexes.get(currentToolCall.toolUseId); - if (blockIndex != null) { - toolEvents.push({ type: "content_block_stop", index: blockIndex }); - toolUseBlockIndexes.delete(currentToolCall.toolUseId); - } - currentToolCall = null; - } - } - - if (toolEvents.length > 0) { - yield* pushEvents(toolEvents); - } - } else if (event.type === 'toolUseInput') { - // 工具调用的 input 续传事件 - // 统计 input 内容到 totalContent(用于 token 计算) - if (event.input) { - totalContent += event.input; - } - if (currentToolCall) { - currentToolCall.input += event.input || ''; - const blockIndex = toolUseBlockIndexes.get(currentToolCall.toolUseId); - if (blockIndex != null && event.input) { - yield* pushEvents([{ - type: "content_block_delta", - index: blockIndex, - delta: { - type: "input_json_delta", - partial_json: event.input - } - }]); - } - } - } else if (event.type === 'toolUseStop') { - // 工具调用结束事件 - if (currentToolCall && event.stop) { - let parsedInput = currentToolCall.input; - try { - parsedInput = JSON.parse(currentToolCall.input); - } catch (e) { - // input 不是有效 JSON,保持原样 - } - toolCalls.push({ - toolUseId: currentToolCall.toolUseId, - name: currentToolCall.name, - input: parsedInput - }); - - const blockIndex = toolUseBlockIndexes.get(currentToolCall.toolUseId); - if (blockIndex != null) { - yield* pushEvents([{ type: "content_block_stop", index: blockIndex }]); - toolUseBlockIndexes.delete(currentToolCall.toolUseId); - } - currentToolCall = null; - } - } - } - - // 处理未完成的工具调用(如果流提前结束) - if (currentToolCall) { - let parsedInput = currentToolCall.input; - try { - parsedInput = JSON.parse(currentToolCall.input); - } catch (e) {} - toolCalls.push({ - toolUseId: currentToolCall.toolUseId, - name: currentToolCall.name, - input: parsedInput - }); - const blockIndex = toolUseBlockIndexes.get(currentToolCall.toolUseId); - if (blockIndex != null) { - yield* pushEvents([{ type: "content_block_stop", index: blockIndex }]); - toolUseBlockIndexes.delete(currentToolCall.toolUseId); - } - currentToolCall = null; - } - - if (thinkingRequested && (streamState.inThinking || streamState.buffer || streamState.pendingTextBeforeThinking)) { - if (streamState.inThinking) { - logger.warn('[Kiro] Incomplete thinking tag at stream end'); - // Strip a single leading newline after `` if we haven't yet. - if (streamState.stripThinkingLeadingNewline) { - if (streamState.buffer.startsWith('\r\n')) streamState.buffer = streamState.buffer.slice(2); - else if (streamState.buffer.startsWith('\n')) streamState.buffer = streamState.buffer.slice(1); - streamState.stripThinkingLeadingNewline = false; - } - yield* pushEvents(createThinkingDeltaEvents(streamState.buffer)); - streamState.buffer = ''; - yield* pushEvents(createThinkingDeltaEvents("")); - yield* pushEvents(stopBlock(streamState.thinkingBlockIndex)); - } else if (!streamState.thinkingExtracted) { - const remaining = `${streamState.pendingTextBeforeThinking}${streamState.buffer}`; - streamState.pendingTextBeforeThinking = ''; - if (remaining) yield* pushEvents(createTextDeltaEvents(remaining)); - streamState.buffer = ''; - } else { - let remaining = streamState.buffer; - streamState.buffer = ''; - if (streamState.stripTextLeadingNewlinesAfterThinking) { - if (remaining.startsWith('\r\n\r\n')) remaining = remaining.slice(4); - else if (remaining.startsWith('\n\n')) remaining = remaining.slice(2); - streamState.stripTextLeadingNewlinesAfterThinking = false; - } - if (remaining) yield* pushEvents(createTextDeltaEvents(remaining)); - streamState.buffer = ''; - } - } - - yield* pushEvents(stopBlock(streamState.textBlockIndex)); - - // 检查文本内容中的 bracket 格式工具调用 - const bracketToolCalls = parseBracketToolCalls(totalContent); - if (bracketToolCalls && bracketToolCalls.length > 0) { - for (const btc of bracketToolCalls) { - toolCalls.push({ - toolUseId: btc.id || `tool_${uuidv4()}`, - name: btc.function.name, - input: JSON.parse(btc.function.arguments || '{}') - }); - } - } - - // 3. 工具调用在流中实时发送,这里不再批量补发 - - // 计算 output tokens - const contentBlocksForCount = thinkingRequested - ? this._toClaudeContentBlocksFromKiroText(totalContent) - : [{ type: "text", text: totalContent }]; - const plainForCount = contentBlocksForCount - .map(b => (b.type === 'thinking' ? (b.thinking ?? '') : (b.text ?? ''))) - .join(''); - outputTokens = this.countTextTokens(plainForCount); - - for (const tc of toolCalls) { - outputTokens += this.countTextTokens(JSON.stringify(tc.input || {})); - } - - // 计算 input tokens - // contextUsagePercentage 是包含输入和输出的总使用量百分比 - // 总 token = TOTAL_CONTEXT_TOKENS * contextUsagePercentage / 100 - // input token = 总 token - output token - if (contextUsagePercentage !== null && contextUsagePercentage > 0) { - const totalTokens = Math.round(KIRO_CONSTANTS.TOTAL_CONTEXT_TOKENS * contextUsagePercentage / 100); - inputTokens = Math.max(0, totalTokens - outputTokens); - logger.info(`[Kiro] Token calculation from contextUsagePercentage: total=${totalTokens}, output=${outputTokens}, input=${inputTokens}`); - } else { - logger.warn('[Kiro Stream] contextUsagePercentage not received, using estimation'); - inputTokens = estimatedInputTokens; - } - - // 4. 发送 message_delta 事件 - yield { - type: "message_delta", - delta: { stop_reason: toolCalls.length > 0 ? "tool_use" : "end_turn" }, - usage: { - input_tokens: inputTokens, - output_tokens: outputTokens, - cache_creation_input_tokens: 0, - cache_read_input_tokens: 0 - } - }; - - // 5. 发送 message_stop 事件 - yield { type: "message_stop" }; - - } catch (error) { - logger.error('[Kiro] Error in streaming generation:', error); - throw error; - } - } - - /** - * Count tokens for a given text using Claude's official tokenizer - */ - countTextTokens(text) { - return KiroApiService.countTextTokens(text); - } - - /** - * Calculate input tokens from request body using Claude's official tokenizer - */ - estimateInputTokens(requestBody) { - return KiroApiService.estimateInputTokens(requestBody); - } - - /** - * Build Claude compatible response object - */ - buildClaudeResponse(content, isStream = false, role = 'assistant', model, toolCalls = null, inputTokens = 0) { - const messageId = `${uuidv4()}`; - - if (isStream) { - // Kiro API is "pseudo-streaming", so we'll send a few events to simulate - // a full Claude stream, but the content/tool_calls will be sent in one go. - const events = []; - - // 1. message_start event - events.push({ - type: "message_start", - message: { - id: messageId, - type: "message", - role: role, - model: model, - usage: { - input_tokens: inputTokens, - output_tokens: 0 // Will be updated in message_delta - }, - content: [] // Content will be streamed via content_block_delta - } - }); - - let totalOutputTokens = 0; - let stopReason = "end_turn"; - - if (content) { - // If there are tool calls AND content, the content block index should be after tool calls - const contentBlockIndex = (toolCalls && toolCalls.length > 0) ? toolCalls.length : 0; - - // 2. content_block_start for text - events.push({ - type: "content_block_start", - index: contentBlockIndex, - content_block: { - type: "text", - text: "" // Initial empty text - } - }); - // 3. content_block_delta for text - events.push({ - type: "content_block_delta", - index: contentBlockIndex, - delta: { - type: "text_delta", - text: content - } - }); - // 4. content_block_stop - events.push({ - type: "content_block_stop", - index: contentBlockIndex - }); - totalOutputTokens += this.countTextTokens(content); - // If there are tool calls, the stop reason remains "tool_use". - // If only content, it's "end_turn". - if (!toolCalls || toolCalls.length === 0) { - stopReason = "end_turn"; - } - } - - if (toolCalls && toolCalls.length > 0) { - toolCalls.forEach((tc, index) => { - let inputObject; - try { - // Arguments should be a stringified JSON object, need to parse it - const args = tc.function.arguments; - inputObject = typeof args === 'string' ? JSON.parse(args) : args; - } catch (e) { - logger.warn(`[Kiro] Invalid JSON for tool call arguments. Wrapping in raw_arguments. Error: ${e.message}`, tc.function.arguments); - // If parsing fails, wrap the raw string in an object as a fallback, - // since Claude's `input` field expects an object. - inputObject = { "raw_arguments": tc.function.arguments }; - } - // 2. content_block_start for each tool_use - events.push({ - type: "content_block_start", - index: index, - content_block: { - type: "tool_use", - id: tc.id, - name: tc.function.name, - input: {} // input is streamed via input_json_delta - } - }); - - // 3. content_block_delta for each tool_use - // Since Kiro is not truly streaming, we send the full arguments as one delta. - events.push({ - type: "content_block_delta", - index: index, - delta: { - type: "input_json_delta", - partial_json: JSON.stringify(inputObject) - } - }); - - // 4. content_block_stop for each tool_use - events.push({ - type: "content_block_stop", - index: index - }); - totalOutputTokens += this.countTextTokens(JSON.stringify(inputObject)); - }); - stopReason = "tool_use"; // If there are tool calls, the stop reason is tool_use - } - - // 5. message_delta with appropriate stop reason - events.push({ - type: "message_delta", - delta: { - stop_reason: stopReason, - stop_sequence: null, - }, - usage: { output_tokens: totalOutputTokens } - }); - - // 6. message_stop event - events.push({ - type: "message_stop" - }); - - return events; // Return an array of events for streaming - } else { - // Non-streaming response (full message object) - const contentArray = []; - let outputTokens = 0; - - // 1) Content blocks (text/thinking) first. - if (Array.isArray(content)) { - for (const block of content) { - if (!block || typeof block !== 'object') continue; - if (block.type === 'text' && typeof block.text === 'string') { - contentArray.push({ type: 'text', text: block.text }); - outputTokens += this.countTextTokens(block.text); - } else if (block.type === 'thinking' && typeof block.thinking === 'string') { - contentArray.push({ type: 'thinking', thinking: block.thinking }); - outputTokens += this.countTextTokens(block.thinking); - } else if (typeof block.text === 'string' && block.text) { - // Best-effort fallback for unknown blocks carrying plain text. - contentArray.push({ type: 'text', text: block.text }); - outputTokens += this.countTextTokens(block.text); - } - } - } else if (content) { - contentArray.push({ type: "text", text: content }); - outputTokens += this.countTextTokens(content); - } - - // 2) Append tool_use blocks (if any). - let stopReason = "end_turn"; - if (toolCalls && toolCalls.length > 0) { - for (const tc of toolCalls) { - let inputObject; - try { - // Arguments should be a stringified JSON object, need to parse it - const args = tc.function.arguments; - inputObject = typeof args === 'string' ? JSON.parse(args) : args; - } catch (e) { - logger.warn(`[Kiro] Invalid JSON for tool call arguments. Wrapping in raw_arguments. Error: ${e.message}`, tc.function.arguments); - // If parsing fails, wrap the raw string in an object as a fallback, - // since Claude's `input` field expects an object. - inputObject = { "raw_arguments": tc.function.arguments }; - } - contentArray.push({ - type: "tool_use", - id: tc.id, - name: tc.function.name, - input: inputObject - }); - outputTokens += this.countTextTokens(tc.function.arguments); - } - stopReason = "tool_use"; // Set stop_reason to "tool_use" when toolCalls exist - } - - return { - id: messageId, - type: "message", - role: role, - model: model, - stop_reason: stopReason, - stop_sequence: null, - usage: { - input_tokens: inputTokens, - output_tokens: outputTokens - }, - content: contentArray - }; - } - } - - /** - * List available models - */ - async listModels() { - const models = KIRO_MODELS.map(id => ({ - name: id - })); - - return { models: models }; - } - - /** - * Checks if the token is completely expired (cannot be used at all). - * @returns {boolean} - True if token is expired, false otherwise. - */ - isTokenExpired() { - try { - if (!this.expiresAt) return true; - const expirationTime = new Date(this.expiresAt); - const currentTime = new Date(); - // 给 30 秒缓冲,避免请求过程中过期 - const bufferMs = 30 * 1000; - return expirationTime.getTime() <= (currentTime.getTime() + bufferMs); - } catch (error) { - logger.error(`[Kiro] Error checking token expiry: ${error.message}`); - return true; // Treat as expired if parsing fails - } - } - - /** - * Checks if the given expiresAt timestamp is within 10 minutes from now (needs refresh soon). - * @returns {boolean} - True if expiresAt is less than 10 minutes from now, false otherwise. - */ - isExpiryDateNear() { - try { - const expirationTime = new Date(this.expiresAt); - const nearMinutes = 30; - const { message, isNearExpiry } = formatExpiryLog('Kiro', expirationTime.getTime(), nearMinutes); - logger.info(message); - return isNearExpiry; - } catch (error) { - logger.error(`[Kiro] Error checking expiry date: ${this.expiresAt}, Error: ${error.message}`); - return true; // Treat as near expiry if parsing fails - } - } - - /** - * 后台异步刷新 token(不阻塞当前请求) - */ - triggerBackgroundRefresh() { - logger.info('[Kiro] Background token refresh started...'); - this.initializeAuth(true).then(() => { - logger.info('[Kiro] Background token refresh completed successfully'); - }).catch((error) => { - logger.error('[Kiro] Background token refresh failed:', error.message); - // 后台刷新失败不抛出错误,下次请求会重试 - }); - } - - /** - * Count tokens for a message request (compatible with Anthropic API) - * POST /v1/messages/count_tokens - * @param {Object} requestBody - The request body containing model, messages, system, tools, etc. - * @returns {Object} { input_tokens: number } - */ - countTokens(requestBody) { - return KiroApiService.countTokens(requestBody); - } - - /** - * 获取用量限制信息 - * @returns {Promise} 用量限制信息 - */ - async getUsageLimits() { - if (!this.isInitialized) await this.initialize(); - - // Token 刷新策略: - // 1. 已过期 → 必须等待刷新 - // 2. 即将过期但还能用 → 后台异步刷新,不阻塞当前请求 - // if (this.isTokenExpired()) { - // logger.info('[Kiro] Token is expired, must refresh before getUsageLimits request...'); - // await this.initializeAuth(true); - // } else if (this.isExpiryDateNear()) { - // logger.info('[Kiro] Token is near expiry, triggering background refresh...'); - // this.triggerBackgroundRefresh(); - // } - - // 内部固定的资源类型 - const resourceType = 'AGENTIC_REQUEST'; - - // 构建请求 URL - let usageLimitsUrl = this.baseUrl; - usageLimitsUrl = usageLimitsUrl.replace('generateAssistantResponse', 'getUsageLimits'); - const params = new URLSearchParams({ - isEmailRequired: 'true', - origin: KIRO_CONSTANTS.ORIGIN_AI_EDITOR, - resourceType: resourceType - }); - if (this.authMethod === KIRO_CONSTANTS.AUTH_METHOD_SOCIAL && this.profileArn) { - params.append('profileArn', this.profileArn); - } - const fullUrl = `${usageLimitsUrl}?${params.toString()}`; - - // 动态生成 headers - const machineId = generateMachineIdFromConfig({ - uuid: this.uuid, - profileArn: this.profileArn, - clientId: this.clientId - }); - const kiroVersion = KIRO_CONSTANTS.KIRO_VERSION; - const { osName, nodeVersion } = getSystemRuntimeInfo(); - - const headers = { - 'Authorization': `Bearer ${this.accessToken}`, - 'x-amz-user-agent': `aws-sdk-js/1.0.0 KiroIDE-${kiroVersion}-${machineId}`, - 'user-agent': `aws-sdk-js/1.0.0 ua/2.1 os/${osName} lang/js md/nodejs#${nodeVersion} api/codewhispererruntime#1.0.0 m/E KiroIDE-${kiroVersion}-${machineId}`, - 'amz-sdk-invocation-id': uuidv4(), - 'amz-sdk-request': 'attempt=1; max=1', - 'Connection': 'close' - }; - - const axiosConfig = { - method: 'get', - url: fullUrl, - headers - }; - this._applySidecar(axiosConfig); - - try { - const response = await this.axiosInstance.request(axiosConfig); - logger.info('[Kiro] Usage limits fetched successfully'); - return response.data; - } catch (error) { - const status = error.response?.status; - - // 从响应体中提取错误信息 - let errorMessage = error.message; - if (error.response?.data) { - // 尝试从响应体中获取错误描述 - const responseData = error.response.data; - if (typeof responseData === 'string') { - errorMessage = responseData; - } else if (responseData.message) { - errorMessage = responseData.message; - } else if (responseData.error) { - errorMessage = typeof responseData.error === 'string' ? responseData.error : responseData.error.message || JSON.stringify(responseData.error); - } - } - - // 构建包含状态码和错误描述的错误信息 - const formattedError = status - ? new Error(`API call failed: ${status} - ${errorMessage}`) - : new Error(`API call failed: ${errorMessage}`); - - // 对于用量查询,401/403 错误直接标记凭证为不健康,不重试 - if (status === 401) { - logger.info('[Kiro] Received 401 on getUsageLimits. Marking credential as unhealthy (no retry)...'); - this._markCredentialNeedRefresh('401 Unauthorized on usage query', formattedError); - throw formattedError; - } - - if (status === 403) { - logger.info('[Kiro] Received 403 on getUsageLimits. Marking credential as unhealthy (no retry)...'); - - // 检查是否为 temporarily suspended 错误 - const isSuspended = errorMessage && errorMessage.toLowerCase().includes('temporarily is suspended'); - - if (isSuspended) { - // temporarily suspended 错误:直接标记为不健康,不刷新 UUID - logger.info('[Kiro] Account temporarily suspended on usage query. Marking as unhealthy without UUID refresh...'); - this._markCredentialUnhealthy('403 Forbidden - Account temporarily suspended on usage query', formattedError); - } else { - // 其他 403 错误:标记需要刷新 - this._markCredentialNeedRefresh('403 Forbidden on usage query', formattedError); - } - - throw formattedError; - } - - logger.error('[Kiro] Failed to fetch usage limits:', formattedError.message, error); - throw formattedError; - } - } -} diff --git a/src/providers/claude/claude-strategy.js b/src/providers/claude/claude-strategy.js deleted file mode 100644 index 1de7d01fb300354dfeaf18da4761cc87539a75da..0000000000000000000000000000000000000000 --- a/src/providers/claude/claude-strategy.js +++ /dev/null @@ -1,75 +0,0 @@ -import { ProviderStrategy } from '../../utils/provider-strategy.js'; -import logger from '../../utils/logger.js'; -import { extractSystemPromptFromRequestBody, MODEL_PROTOCOL_PREFIX } from '../../utils/common.js'; - -/** - * Claude provider strategy implementation. - */ -class ClaudeStrategy extends ProviderStrategy { - extractModelAndStreamInfo(req, requestBody) { - const model = requestBody.model; - const isStream = requestBody.stream === true; - return { model, isStream }; - } - - extractResponseText(response) { - if (response.type === 'content_block_delta' && response.delta ) { - if(response.delta.type === 'text_delta' ){ - return response.delta.text; - } - if(response.delta.type === 'input_json_delta' ){ - return response.delta.partial_json; - } - } - if (response.content && Array.isArray(response.content)) { - return response.content - .filter(block => block.type === 'text' && block.text) - .map(block => block.text) - .join(''); - } else if (response.content && response.content.type === 'text') { - return response.content.text; - } - return ''; - } - - extractPromptText(requestBody) { - if (requestBody.messages && requestBody.messages.length > 0) { - const lastMessage = requestBody.messages[requestBody.messages.length - 1]; - if (lastMessage.content && Array.isArray(lastMessage.content)) { - return lastMessage.content.map(block => block.text).join(''); - } - return lastMessage.content; - } - return ''; - } - - async applySystemPromptFromFile(config, requestBody) { - if (!config.SYSTEM_PROMPT_FILE_PATH) { - return requestBody; - } - - const filePromptContent = config.SYSTEM_PROMPT_CONTENT; - if (filePromptContent === null) { - return requestBody; - } - - const existingSystemText = extractSystemPromptFromRequestBody(requestBody, MODEL_PROTOCOL_PREFIX.CLAUDE); - - const newSystemText = config.SYSTEM_PROMPT_MODE === 'append' && existingSystemText - ? `${existingSystemText}\n${filePromptContent}` - : filePromptContent; - - requestBody.system = newSystemText; - logger.info(`[System Prompt] Applied system prompt from ${config.SYSTEM_PROMPT_FILE_PATH} in '${config.SYSTEM_PROMPT_MODE}' mode for provider 'claude'.`); - - return requestBody; - } - - async manageSystemPrompt(requestBody) { - const incomingSystemText = extractSystemPromptFromRequestBody(requestBody, MODEL_PROTOCOL_PREFIX.CLAUDE); - await this._updateSystemPromptFile(incomingSystemText, MODEL_PROTOCOL_PREFIX.CLAUDE); - } -} - -export { ClaudeStrategy }; - diff --git a/src/providers/forward/forward-core.js b/src/providers/forward/forward-core.js deleted file mode 100644 index 8730ac36b31993a36e54ab19d0d5607a5dee5faa..0000000000000000000000000000000000000000 --- a/src/providers/forward/forward-core.js +++ /dev/null @@ -1,206 +0,0 @@ -import axios from 'axios'; -import logger from '../../utils/logger.js'; -import * as http from 'http'; -import * as https from 'https'; -import { configureAxiosProxy, configureTLSSidecar } from '../../utils/proxy-utils.js'; -import { isRetryableNetworkError, MODEL_PROVIDER } from '../../utils/common.js'; - -/** - * ForwardApiService - A provider that forwards requests to a specified API endpoint. - * Transparently passes all parameters and includes an API key in the headers. - */ -export class ForwardApiService { - constructor(config) { - if (!config.FORWARD_API_KEY) { - throw new Error("API Key is required for ForwardApiService (FORWARD_API_KEY)."); - } - if (!config.FORWARD_BASE_URL) { - throw new Error("Base URL is required for ForwardApiService (FORWARD_BASE_URL)."); - } - - this.config = config; - this.apiKey = config.FORWARD_API_KEY; - this.baseUrl = config.FORWARD_BASE_URL; - this.useSystemProxy = config?.USE_SYSTEM_PROXY_FORWARD ?? false; - this.headerName = config?.FORWARD_HEADER_NAME || 'Authorization'; - this.headerValuePrefix = config?.FORWARD_HEADER_VALUE_PREFIX || 'Bearer '; - - logger.info(`[Forward] Base URL: ${this.baseUrl}, System proxy ${this.useSystemProxy ? 'enabled' : 'disabled'}`); - - const httpAgent = new http.Agent({ - keepAlive: true, - maxSockets: 100, - maxFreeSockets: 5, - timeout: 120000, - }); - const httpsAgent = new https.Agent({ - keepAlive: true, - maxSockets: 100, - maxFreeSockets: 5, - timeout: 120000, - }); - - const headers = { - 'Content-Type': 'application/json' - }; - headers[this.headerName] = `${this.headerValuePrefix}${this.apiKey}`; - - const axiosConfig = { - baseURL: this.baseUrl, - httpAgent, - httpsAgent, - headers, - }; - - if (!this.useSystemProxy) { - axiosConfig.proxy = false; - } - - configureAxiosProxy(axiosConfig, config, MODEL_PROVIDER.FORWARD_API); - - this.axiosInstance = axios.create(axiosConfig); - } - - _applySidecar(axiosConfig) { - return configureTLSSidecar(axiosConfig, this.config, MODEL_PROVIDER.FORWARD_API, this.baseUrl); - } - - async callApi(endpoint, body, isRetry = false, retryCount = 0) { - const maxRetries = this.config.REQUEST_MAX_RETRIES || 3; - const baseDelay = this.config.REQUEST_BASE_DELAY || 1000; - - try { - const axiosConfig = { - method: 'post', - url: endpoint, - data: body - }; - this._applySidecar(axiosConfig); - const response = await this.axiosInstance.request(axiosConfig); - return response.data; - } catch (error) { - const status = error.response?.status; - const data = error.response?.data; - const errorCode = error.code; - const errorMessage = error.message || ''; - const isNetworkError = isRetryableNetworkError(error); - - if (status === 401 || status === 403) { - logger.error(`[Forward API] Received ${status}. API Key might be invalid or expired.`); - throw error; - } - - if ((status === 429 || (status >= 500 && status < 600) || isNetworkError) && retryCount < maxRetries) { - const delay = baseDelay * Math.pow(2, retryCount); - logger.info(`[Forward API] Error ${status || errorCode}. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, delay)); - return this.callApi(endpoint, body, isRetry, retryCount + 1); - } - - logger.error(`[Forward API] Error calling API (Status: ${status}, Code: ${errorCode}):`, errorMessage); - throw error; - } - } - - async *streamApi(endpoint, body, isRetry = false, retryCount = 0) { - const maxRetries = this.config.REQUEST_MAX_RETRIES || 3; - const baseDelay = this.config.REQUEST_BASE_DELAY || 1000; - - try { - const axiosConfig = { - method: 'post', - url: endpoint, - data: body, - responseType: 'stream' - }; - this._applySidecar(axiosConfig); - const response = await this.axiosInstance.request(axiosConfig); - - const stream = response.data; - let buffer = ''; - - for await (const chunk of stream) { - buffer += chunk.toString(); - let newlineIndex; - while ((newlineIndex = buffer.indexOf('\n')) !== -1) { - const line = buffer.substring(0, newlineIndex).trim(); - buffer = buffer.substring(newlineIndex + 1); - - if (line.startsWith('data: ')) { - const jsonData = line.substring(6).trim(); - if (jsonData === '[DONE]') { - return; - } - try { - const parsedChunk = JSON.parse(jsonData); - yield parsedChunk; - } catch (e) { - // If it's not JSON, it might be a different format, but for a forwarder we try to parse common SSE formats - logger.warn("[ForwardApiService] Failed to parse stream chunk JSON:", e.message, "Data:", jsonData); - } - } - } - } - } catch (error) { - const status = error.response?.status; - const errorCode = error.code; - const isNetworkError = isRetryableNetworkError(error); - - if ((status === 429 || (status >= 500 && status < 600) || isNetworkError) && retryCount < maxRetries) { - const delay = baseDelay * Math.pow(2, retryCount); - logger.info(`[Forward API] Stream error ${status || errorCode}. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, delay)); - yield* this.streamApi(endpoint, body, isRetry, retryCount + 1); - return; - } - - const errorMessage = error.message || ''; - logger.error(`[Forward API] Error calling streaming API (Status: ${status || errorCode}):`, errorMessage); - throw error; - } - } - - async generateContent(model, requestBody) { - // 临时存储 monitorRequestId - if (requestBody._monitorRequestId) { - this.config._monitorRequestId = requestBody._monitorRequestId; - delete requestBody._monitorRequestId; - } - if (requestBody._requestBaseUrl) { - delete requestBody._requestBaseUrl; - } - - // Transparently pass the endpoint if provided in requestBody, otherwise use default - const endpoint = requestBody.endpoint || ''; - return this.callApi(endpoint, requestBody); - } - - async *generateContentStream(model, requestBody) { - // 临时存储 monitorRequestId - if (requestBody._monitorRequestId) { - this.config._monitorRequestId = requestBody._monitorRequestId; - delete requestBody._monitorRequestId; - } - if (requestBody._requestBaseUrl) { - delete requestBody._requestBaseUrl; - } - - const endpoint = requestBody.endpoint || ''; - yield* this.streamApi(endpoint, requestBody); - } - - async listModels() { - try { - const axiosConfig = { - method: 'get', - url: '/models' - }; - this._applySidecar(axiosConfig); - const response = await this.axiosInstance.request(axiosConfig); - return response.data; - } catch (error) { - logger.error(`Error listing Forward models:`, error.message); - return { data: [] }; - } - } -} diff --git a/src/providers/forward/forward-strategy.js b/src/providers/forward/forward-strategy.js deleted file mode 100644 index b11874140b24425a72bfe0b51744ddd2548284a0..0000000000000000000000000000000000000000 --- a/src/providers/forward/forward-strategy.js +++ /dev/null @@ -1,54 +0,0 @@ -import { ProviderStrategy } from '../../utils/provider-strategy.js'; - -/** - * Forward provider strategy implementation. - * Designed to be as transparent as possible. - */ -class ForwardStrategy extends ProviderStrategy { - extractModelAndStreamInfo(req, requestBody) { - const model = requestBody.model || 'default'; - const isStream = requestBody.stream === true; - return { model, isStream }; - } - - extractResponseText(response) { - // Attempt to extract text using common patterns (OpenAI, Claude, etc.) - if (response.choices && response.choices.length > 0) { - const choice = response.choices[0]; - if (choice.message && choice.message.content) { - return choice.message.content; - } else if (choice.delta && choice.delta.content) { - return choice.delta.content; - } - } - if (response.content && Array.isArray(response.content)) { - return response.content.map(c => c.text || '').join(''); - } - return ''; - } - - extractPromptText(requestBody) { - if (requestBody.messages && requestBody.messages.length > 0) { - const lastMessage = requestBody.messages[requestBody.messages.length - 1]; - let content = lastMessage.content; - if (typeof content === 'object' && content !== null) { - return JSON.stringify(content); - } - return content; - } - return ''; - } - - async applySystemPromptFromFile(config, requestBody) { - // For forwarder, we might want to skip automatic system prompt application - // to keep it transparent, but let's follow the base implementation just in case. - return requestBody; - } - - async manageSystemPrompt(requestBody) { - // No-op for transparency - } -} - -export { ForwardStrategy }; - diff --git a/src/providers/gemini/antigravity-core.js b/src/providers/gemini/antigravity-core.js deleted file mode 100644 index 2cd0fd64ae3d810950613cf975692b5583df986f..0000000000000000000000000000000000000000 --- a/src/providers/gemini/antigravity-core.js +++ /dev/null @@ -1,1532 +0,0 @@ - -import { OAuth2Client } from 'google-auth-library'; -import logger from '../../utils/logger.js'; -import * as http from 'http'; -import * as https from 'https'; -import * as crypto from 'crypto'; -import { promises as fs } from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import * as readline from 'readline'; -import { v4 as uuidv4 } from 'uuid'; -import open from 'open'; -import { configureTLSSidecar } from '../../utils/proxy-utils.js'; -import { formatExpiryTime, isRetryableNetworkError, formatExpiryLog } from '../../utils/common.js'; -import { getProviderModels } from '../provider-models.js'; -import { handleGeminiAntigravityOAuth } from '../../auth/oauth-handlers.js'; -import { getProxyConfigForProvider, getGoogleAuthProxyConfig } from '../../utils/proxy-utils.js'; -import { cleanJsonSchemaProperties } from '../../converters/utils.js'; -import { getProviderPoolManager } from '../../services/service-manager.js'; -import { MODEL_PROVIDER } from '../../utils/common.js'; - -// --- Constants --- -const CREDENTIALS_DIR = '.antigravity'; -const CREDENTIALS_FILE = 'oauth_creds.json'; - -// Base URLs -const ANTIGRAVITY_BASE_URL_DAILY = 'https://daily-cloudcode-pa.googleapis.com'; -const ANTIGRAVITY_SANDBOX_BASE_URL_DAILY = 'https://daily-cloudcode-pa.sandbox.googleapis.com'; -const ANTIGRAVITY_BASE_URL_PROD = 'https://autopush-cloudcode-pa.sandbox.googleapis.com'; - -const ANTIGRAVITY_API_VERSION = 'v1internal'; -const OAUTH_CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com'; -const OAUTH_CLIENT_SECRET = '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'; -const DEFAULT_USER_AGENT = 'antigravity/1.104.0 darwin/arm64'; -const REFRESH_SKEW = 3000; // 3000秒(50分钟)提前刷新Token - -const ANTIGRAVITY_SYSTEM_PROMPT = `You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding.You are pair programming with a USER to solve their coding task. The task may require creating a new codebase, modifying or debugging an existing codebase, or simply answering a question.**Absolute paths only****Proactiveness**`; - - -// Thinking 配置相关常量 -const DEFAULT_THINKING_MIN = 1024; -const DEFAULT_THINKING_MAX = 100000; - -// 获取 Antigravity 模型列表 -const ANTIGRAVITY_MODELS = getProviderModels(MODEL_PROVIDER.ANTIGRAVITY); - - -/** - * 检查模型是否为 Claude 模型 - * @param {string} modelName - 模型名称 - * @returns {boolean} - */ -function isClaude(modelName) { - return modelName && modelName.toLowerCase().includes('claude'); -} - -/** - * 检查是否为图像模型 - * @param {string} modelName - 模型名称 - * @returns {boolean} - */ -function isImageModel(modelName) { - return modelName && modelName.toLowerCase().includes('image'); -} - -/** - * 检查模型是否支持 Thinking - * @param {string} modelName - 模型名称 - * @returns {boolean} - */ -function modelSupportsThinking(modelName) { - if (!modelName) return false; - const name = modelName.toLowerCase(); - // 支持 thinking 的模型:gemini-3*, gemini-2.5-*, claude-*-thinking - return name.startsWith('gemini-3') || - name.startsWith('gemini-2.5-') || - name.includes('-thinking'); -} - -/** - * 生成随机请求ID - * @returns {string} - */ -function generateRequestID() { - return 'agent-' + uuidv4(); -} - -/** - * 生成随机图像生成请求ID - * @returns {string} - */ -function generateImageGenRequestID() { - return `image_gen/${Date.now()}/${uuidv4()}/12`; -} - -/** - * 生成随机会话ID - * @returns {string} - */ -function generateSessionID() { - const n = Math.floor(Math.random() * 9000); - return '-' + n.toString(); -} - -/** - * 基于请求内容生成稳定的会话ID - * 使用第一个用户消息的 SHA256 哈希值 - * @param {Object} payload - 请求体 - * @returns {string} 稳定的会话ID - */ -function generateStableSessionID(payload) { - try { - const contents = payload?.request?.contents; - if (Array.isArray(contents)) { - for (const content of contents) { - if (content && content.role === 'user' && Array.isArray(content.parts)) { - const text = content.parts?.[0]?.text; - if (text) { - const hash = crypto.createHash('sha256').update(text).digest(); - // 取前8字节转换为 BigInt,然后取正数 - const n = hash.readBigUInt64BE(0) & BigInt('0x7FFFFFFFFFFFFFFF'); - return '-' + n.toString(); - } - } - } - } - } catch (e) { - // 如果解析失败,回退到随机会话ID - } - return generateSessionID(); -} - -/** - * 生成随机项目ID - * @returns {string} - */ -function generateProjectID() { - const adjectives = ['useful', 'bright', 'swift', 'calm', 'bold']; - const nouns = ['fuze', 'wave', 'spark', 'flow', 'core']; - const adj = adjectives[Math.floor(Math.random() * adjectives.length)]; - const noun = nouns[Math.floor(Math.random() * nouns.length)]; - const randomPart = uuidv4().toLowerCase().substring(0, 5); - return `${adj}-${noun}-${randomPart}`; -} - -/** - * 规范化 Thinking Budget - * @param {string} modelName - 模型名称 - * @param {number} budget - 原始 budget 值 - * @returns {number} 规范化后的 budget - */ -function normalizeThinkingBudget(modelName, budget) { - // -1 表示动态/无限制 - if (budget === -1) return -1; - - // 获取模型的 thinking 限制 - const min = DEFAULT_THINKING_MIN; - const max = DEFAULT_THINKING_MAX; - - // 限制在有效范围内 - if (budget < min) return min; - if (budget > max) return max; - return budget; -} - -/** - * 规范化 Antigravity Thinking 配置 - * 对于 Claude 模型,确保 thinking budget < max_tokens - * @param {string} modelName - 模型名称 - * @param {Object} payload - 请求体 - * @param {boolean} isClaudeModel - 是否为 Claude 模型 - * @returns {Object} 处理后的请求体 - */ -function normalizeAntigravityThinking(modelName, payload, isClaudeModel) { - // 如果模型不支持 thinking,移除 thinking 配置 - if (!modelSupportsThinking(modelName)) { - if (payload?.request?.generationConfig?.thinkingConfig) { - delete payload.request.generationConfig.thinkingConfig; - } - return payload; - } - - const thinkingConfig = payload?.request?.generationConfig?.thinkingConfig; - if (!thinkingConfig) return payload; - - const budget = thinkingConfig.thinkingBudget; - if (budget === undefined) return payload; - - let normalizedBudget = normalizeThinkingBudget(modelName, budget); - - // 对于 Claude 模型,确保 thinking budget < max_tokens - if (isClaudeModel) { - const maxTokens = payload?.request?.generationConfig?.maxOutputTokens; - if (maxTokens && maxTokens > 0 && normalizedBudget >= maxTokens) { - normalizedBudget = maxTokens - 1; - } - - // 检查最小 budget - const minBudget = DEFAULT_THINKING_MIN; - if (normalizedBudget >= 0 && normalizedBudget < minBudget) { - // Budget 低于最小值,移除 thinking 配置 - delete payload.request.generationConfig.thinkingConfig; - return payload; - } - } - - payload.request.generationConfig.thinkingConfig.thinkingBudget = normalizedBudget; - return payload; -} - -/** - * 将 Gemini 格式请求转换为 Antigravity 格式 - * @param {string} modelName - 模型名称 - * @param {Object} payload - 请求体 - * @param {string} projectId - 项目ID - * @returns {Object} 转换后的请求体 - */ -function geminiToAntigravity(modelName, payload, projectId) { - // 深拷贝请求体,避免修改原始对象 - let template = JSON.parse(JSON.stringify(payload)); - - const isClaudeModel = isClaude(modelName); - const isImgModel = isImageModel(modelName); - - // 设置基本字段 - template.model = modelName; - template.userAgent = 'antigravity'; - - // 设置请求类型 - template.requestType = isImgModel ? 'image_gen' : 'agent'; - - template.project = projectId || generateProjectID(); - - // 设置请求ID和会话ID - if (isImgModel) { - template.requestId = generateImageGenRequestID(); - } else { - template.requestId = generateRequestID(); - // 确保 request 对象存在 - if (!template.request) { - template.request = {}; - } - // 设置会话ID - 使用稳定的会话ID - template.request.sessionId = generateStableSessionID(template); - } - - // 删除安全设置 - if (template.request.safetySettings) { - delete template.request.safetySettings; - } - - // 设置工具配置 - // 如果根部有 toolConfig,且 request 内部没有,则移动进去 - if (template.request.toolConfig) { - if (!template.request.toolConfig.functionCallingConfig) { - template.request.toolConfig.functionCallingConfig = {}; - } - if (isClaudeModel) { - template.request.toolConfig.functionCallingConfig.mode = 'VALIDATED'; - } - } - - // 当模型是 Claude 时,禁止使用 tools - if (isClaudeModel) { - if (template.request.tools) { - delete template.request.tools; - } - if (template.request.toolConfig) { - delete template.request.toolConfig; - } - } - - // 对于非 Claude 模型,删除 maxOutputTokens - // Claude 模型需要保留 maxOutputTokens - // if (!isClaudeModel) { 注释了cc用不了 - if (template.request.generationConfig && template.request.generationConfig.maxOutputTokens) { - delete template.request.generationConfig.maxOutputTokens; - } - // } - - // 处理 Thinking 配置 - // 对于非 gemini-3-* 模型,将 thinkingLevel 转换为 thinkingBudget - if (!modelName.startsWith('gemini-3-')) { - if (template.request.generationConfig && - template.request.generationConfig.thinkingConfig && - template.request.generationConfig.thinkingConfig.thinkingLevel) { - delete template.request.generationConfig.thinkingConfig.thinkingLevel; - template.request.generationConfig.thinkingConfig.thinkingBudget = -1; - } - } - - // 清理所有工具声明中的 JSON Schema 属性(移除 Google API 不支持的属性如 exclusiveMinimum 等) - if (template.request.tools && Array.isArray(template.request.tools)) { - template.request.tools.forEach((tool) => { - if (tool.functionDeclarations && Array.isArray(tool.functionDeclarations)) { - tool.functionDeclarations.forEach((funcDecl) => { - // 对于 Claude 模型,处理 parametersJsonSchema - if (isClaudeModel && funcDecl.parametersJsonSchema) { - funcDecl.parameters = cleanJsonSchemaProperties(funcDecl.parametersJsonSchema); - delete funcDecl.parameters.$schema; - delete funcDecl.parametersJsonSchema; - } else if (funcDecl.parameters) { - funcDecl.parameters = cleanJsonSchemaProperties(funcDecl.parameters); - } - }); - } - }); - } - - // 如果是图像模型,增加参数 "generationConfig.imageConfig.imageSize": "4K" - if (isImageModel(modelName)) { - if (!template.request.generationConfig) { - template.request.generationConfig = {}; - } - - if (!template.request.generationConfig.imageConfig) { - template.request.generationConfig.imageConfig = {}; - } - template.request.generationConfig.imageConfig.imageSize = '4K'; - if (!template.request.generationConfig.thinkingConfig) { - template.request.generationConfig.thinkingConfig = {}; - } - template.request.generationConfig.thinkingConfig.includeThoughts = false; - } - - // 规范化 Thinking 配置 - template = normalizeAntigravityThinking(modelName, template, isClaudeModel); - - return template; -} - -/** - * 过滤 SSE 中的 usageMetadata(仅在最终块中保留) - * @param {string} line - SSE 行数据 - * @returns {string} 过滤后的行数据 - */ -function filterSSEUsageMetadata(line) { - if (!line || typeof line !== 'string') return line; - - // 检查是否是 data: 开头的 SSE 数据 - if (!line.startsWith('data: ')) return line; - - try { - const jsonStr = line.slice(6); // 移除 'data: ' 前缀 - const data = JSON.parse(jsonStr); - - // 检查是否有 finishReason,如果没有则移除 usageMetadata - const hasFinishReason = data?.response?.candidates?.[0]?.finishReason || - data?.candidates?.[0]?.finishReason; - - if (!hasFinishReason) { - // 移除 usageMetadata - if (data.response) { - delete data.response.usageMetadata; - } - if (data.usageMetadata) { - delete data.usageMetadata; - } - return 'data: ' + JSON.stringify(data); - } - } catch (e) { - // 解析失败,返回原始数据 - } - - return line; -} - -/** - * 将流式响应转换为非流式响应 - * 用于 Claude 模型的非流式请求(实际上是流式请求然后合并) - * @param {Buffer|string} stream - 流式响应数据 - * @returns {Object} 合并后的非流式响应 - */ -function convertStreamToNonStream(stream) { - const lines = stream.toString().split('\n'); - - let responseTemplate = ''; - let traceId = ''; - let finishReason = ''; - let modelVersion = ''; - let responseId = ''; - let role = ''; - let usageRaw = null; - const parts = []; - - // 用于合并连续的 text 和 thought 部分 - let pendingKind = ''; - let pendingText = ''; - let pendingThoughtSig = ''; - - const flushPending = () => { - if (!pendingKind) return; - - const text = pendingText; - if (pendingKind === 'text') { - if (text.trim()) { - parts.push({ text: text }); - } - } else if (pendingKind === 'thought') { - if (text.trim() || pendingThoughtSig) { - const part = { thought: true, text: text }; - if (pendingThoughtSig) { - part.thoughtSignature = pendingThoughtSig; - } - parts.push(part); - } - } - - pendingKind = ''; - pendingText = ''; - pendingThoughtSig = ''; - }; - - const normalizePart = (part) => { - const m = { ...part }; - // 处理 thoughtSignature / thought_signature - const sig = part.thoughtSignature || part.thought_signature; - if (sig) { - m.thoughtSignature = sig; - delete m.thought_signature; - } - // 处理 inline_data -> inlineData - if (m.inline_data) { - m.inlineData = m.inline_data; - delete m.inline_data; - } - return m; - }; - - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed) continue; - - let data; - try { - data = JSON.parse(trimmed); - } catch (e) { - continue; - } - - let responseNode = data.response; - if (!responseNode) { - if (data.candidates) { - responseNode = data; - } else { - continue; - } - } - responseTemplate = JSON.stringify(responseNode); - - if (data.traceId) { - traceId = data.traceId; - } - - if (responseNode.candidates?.[0]?.content?.role) { - role = responseNode.candidates[0].content.role; - } - - if (responseNode.candidates?.[0]?.finishReason) { - finishReason = responseNode.candidates[0].finishReason; - } - - if (responseNode.modelVersion) { - modelVersion = responseNode.modelVersion; - } - - if (responseNode.responseId) { - responseId = responseNode.responseId; - } - - if (responseNode.usageMetadata) { - usageRaw = responseNode.usageMetadata; - } else if (data.usageMetadata) { - usageRaw = data.usageMetadata; - } - - const partsArray = responseNode.candidates?.[0]?.content?.parts; - if (Array.isArray(partsArray)) { - for (const part of partsArray) { - const hasFunctionCall = part.functionCall !== undefined; - const hasInlineData = part.inlineData !== undefined || part.inline_data !== undefined; - const sig = part.thoughtSignature || part.thought_signature || ''; - const text = part.text || ''; - const thought = part.thought || false; - - if (hasFunctionCall || hasInlineData) { - flushPending(); - parts.push(normalizePart(part)); - continue; - } - - if (thought || part.text !== undefined) { - const kind = thought ? 'thought' : 'text'; - if (pendingKind && pendingKind !== kind) { - flushPending(); - } - pendingKind = kind; - pendingText += text; - if (kind === 'thought' && sig) { - pendingThoughtSig = sig; - } - continue; - } - - flushPending(); - parts.push(normalizePart(part)); - } - } - } - - flushPending(); - - // 构建最终响应 - if (!responseTemplate) { - responseTemplate = '{"candidates":[{"content":{"role":"model","parts":[]}}]}'; - } - - let result = JSON.parse(responseTemplate); - - // 设置 parts - if (!result.candidates) { - result.candidates = [{ content: { role: 'model', parts: [] } }]; - } - if (!result.candidates[0]) { - result.candidates[0] = { content: { role: 'model', parts: [] } }; - } - if (!result.candidates[0].content) { - result.candidates[0].content = { role: 'model', parts: [] }; - } - result.candidates[0].content.parts = parts; - - if (role) { - result.candidates[0].content.role = role; - } - if (finishReason) { - result.candidates[0].finishReason = finishReason; - } - if (modelVersion) { - result.modelVersion = modelVersion; - } - if (responseId) { - result.responseId = responseId; - } - if (usageRaw) { - result.usageMetadata = usageRaw; - } else if (!result.usageMetadata) { - result.usageMetadata = { - promptTokenCount: 0, - candidatesTokenCount: 0, - totalTokenCount: 0 - }; - } - - // 包装为最终格式 - const output = { - response: result, - traceId: traceId || '' - }; - - return output; -} - -/** - * 将 Antigravity 响应转换为 Gemini 格式 - * @param {Object} antigravityResponse - Antigravity 响应 - * @returns {Object|null} Gemini 格式响应 - */ -function toGeminiApiResponse(antigravityResponse) { - if (!antigravityResponse) return null; - - const compliantResponse = { - candidates: antigravityResponse.candidates - }; - - if (antigravityResponse.usageMetadata) { - compliantResponse.usageMetadata = antigravityResponse.usageMetadata; - } - - if (antigravityResponse.promptFeedback) { - compliantResponse.promptFeedback = antigravityResponse.promptFeedback; - } - - if (antigravityResponse.automaticFunctionCallingHistory) { - compliantResponse.automaticFunctionCallingHistory = antigravityResponse.automaticFunctionCallingHistory; - } - - return compliantResponse; -} - -/** - * 确保请求体中的内容部分都有角色属性 - * @param {Object} requestBody - 请求体 - * @returns {Object} 处理后的请求体 - */ -function ensureRolesInContents(requestBody, modelName) { - delete requestBody.model; - // delete requestBody.system_instruction; - // delete requestBody.systemInstruction; - if (requestBody.system_instruction) { - requestBody.systemInstruction = requestBody.system_instruction; - delete requestBody.system_instruction; - } - - // 提取现有的系统提示词 - let originalSystemPrompt = requestBody.systemInstruction; - - // 如果 systemInstruction 是对象格式,提取其中的文本内容 - let originalSystemPromptText = ''; - if (originalSystemPrompt) { - if (typeof originalSystemPrompt === 'string') { - originalSystemPromptText = originalSystemPrompt; - } else if (typeof originalSystemPrompt === 'object') { - // 处理对象格式的 systemInstruction - if (originalSystemPrompt.parts && Array.isArray(originalSystemPrompt.parts)) { - // 从 parts 数组中提取所有文本 - originalSystemPromptText = originalSystemPrompt.parts - .map(part => { - if (typeof part === 'string') return part; - if (part && typeof part.text === 'string') return part.text; - return ''; - }) - .filter(text => text) - .join('\n'); - } else if (originalSystemPrompt.text) { - // 直接有 text 属性 - originalSystemPromptText = originalSystemPrompt.text; - } - } - } - - const name = modelName ? modelName.toLowerCase() : ''; - const useAntigravity = name.includes('gemini-3-pro') || name.includes('claude'); - - if (useAntigravity) { - // 让 AI 忽略 Antigravity 提示词 - const parts = [ - { text: ANTIGRAVITY_SYSTEM_PROMPT }, - { text: `Please ignore following [ignore]${ANTIGRAVITY_SYSTEM_PROMPT}[/ignore]` } - ]; - - // 如果有原始系统提示词,追加到 parts 中 - if (originalSystemPromptText) { - parts.push({ text: originalSystemPromptText }); - } - - requestBody.systemInstruction = { - role: 'user', - parts: parts - }; - } else if (originalSystemPromptText) { - // 对于其他模型,如果有原始系统提示词,保留它 - requestBody.systemInstruction = { - role: 'user', - parts: [{ text: originalSystemPromptText }] - }; - } else { - // 没有有效的系统提示词,删除该字段 - delete requestBody.systemInstruction; - } - - if (requestBody.contents && Array.isArray(requestBody.contents)) { - requestBody.contents.forEach(content => { - if (!content.role) { - content.role = 'user'; - } - }); - } - - return requestBody; -} - -export class AntigravityApiService { - constructor(config) { - // 配置 HTTP/HTTPS agent 限制连接池大小,避免资源泄漏 - this.httpAgent = new http.Agent({ - keepAlive: true, - maxSockets: 100, - maxFreeSockets: 5, - timeout: 120000, - }); - this.httpsAgent = new https.Agent({ - keepAlive: true, - maxSockets: 100, - maxFreeSockets: 5, - timeout: 120000, - }); - - // 检查是否需要使用代理 - const proxyConfig = getGoogleAuthProxyConfig(config, 'gemini-antigravity'); - - // 配置 OAuth2Client 使用自定义的 HTTP agent - const oauth2Options = { - clientId: OAUTH_CLIENT_ID, - clientSecret: OAUTH_CLIENT_SECRET, - }; - - if (proxyConfig) { - oauth2Options.transporterOptions = proxyConfig; - logger.info('[Antigravity] Using proxy for OAuth2Client'); - } else { - oauth2Options.transporterOptions = { - agent: this.httpsAgent, - }; - } - - this.authClient = new OAuth2Client(oauth2Options); - this.availableModels = []; - this.isInitialized = false; - - this.config = config; - this.host = config.HOST; - this.oauthCredsFilePath = config.ANTIGRAVITY_OAUTH_CREDS_FILE_PATH; - this.userAgent = DEFAULT_USER_AGENT; // 支持通用 USER_AGENT 配置 - this.projectId = config.PROJECT_ID; - this.uuid = config.uuid; // 保存 uuid 用于缓存管理 - - // 多环境降级顺序 - this.baseURLs = this.getBaseURLFallbackOrder(config); - - // 保存代理配置供后续使用 - this.proxyConfig = getProxyConfigForProvider(config, 'gemini-antigravity'); - } - - _applySidecar(requestOptions) { - return configureTLSSidecar(requestOptions, this.config, MODEL_PROVIDER.ANTIGRAVITY); - } - - /** - * 获取 Base URL 降级顺序 - * @param {Object} config - 配置对象 - * @returns {string[]} Base URL 列表 - */ - getBaseURLFallbackOrder(config) { - // 如果配置了自定义 base_url,只使用该 URL - if (config.ANTIGRAVITY_BASE_URL) { - return [config.ANTIGRAVITY_BASE_URL.replace(/\/$/, '')]; - } - - // 默认降级顺序:daily -> sandbox -> prod - return [ - ANTIGRAVITY_SANDBOX_BASE_URL_DAILY, - ANTIGRAVITY_BASE_URL_DAILY, - ANTIGRAVITY_BASE_URL_PROD - ]; - } - - async initialize() { - if (this.isInitialized) return; - logger.info('[Antigravity] Initializing Antigravity API Service...'); - // 注意:V2 读写分离架构下,初始化不再执行同步认证/刷新逻辑 - // 仅执行基础的凭证加载 - await this.loadCredentials(); - - if (!this.projectId) { - this.projectId = await this.discoverProjectAndModels(); - } else { - logger.info(`[Antigravity] Using provided Project ID: ${this.projectId}`); - // 获取可用模型 - await this.fetchAvailableModels(); - } - - this.isInitialized = true; - logger.info(`[Antigravity] Initialization complete. Project ID: ${this.projectId}`); - } - - /** - * 加载凭证信息(不执行刷新) - */ - async loadCredentials() { - const credPath = this.oauthCredsFilePath || path.join(os.homedir(), CREDENTIALS_DIR, CREDENTIALS_FILE); - try { - const data = await fs.readFile(credPath, "utf8"); - const credentials = JSON.parse(data); - this.authClient.setCredentials(credentials); - logger.info('[Antigravity Auth] Credentials loaded successfully from file.'); - } catch (error) { - if (error.code === 'ENOENT') { - logger.debug(`[Antigravity Auth] Credentials file not found: ${credPath}`); - } else { - logger.warn(`[Antigravity Auth] Failed to load credentials from file: ${error.message}`); - } - } - } - - async initializeAuth(forceRefresh = false) { - const credPath = this.oauthCredsFilePath || path.join(os.homedir(), CREDENTIALS_DIR, CREDENTIALS_FILE); - - // 首先执行基础凭证加载 - await this.loadCredentials(); - - // 检查是否需要刷新 Token(在加载凭证后重新评估) - const needsRefresh = forceRefresh || this.isTokenExpiringSoon(); - - if (this.authClient.credentials.access_token && !needsRefresh) { - // Token 有效且不需要刷新 - return; - } - - // 只有在明确要求刷新,或者 AccessToken 确实缺失时,才执行刷新/认证 - // 注意:在 V2 架构下,此方法主要由 PoolManager 的后台队列调用 - if (needsRefresh || !this.authClient.credentials.access_token) { - try { - if (this.authClient.credentials.refresh_token) { - logger.info('[Antigravity Auth] Token expiring soon or force refresh requested. Refreshing token...'); - const { credentials: newCredentials } = await this.authClient.refreshAccessToken(); - this.authClient.setCredentials(newCredentials); - await this._saveCredentialsToFile(credPath, newCredentials); - logger.info(`[Antigravity Auth] Token refreshed and saved to ${credPath} successfully.`); - - // 刷新成功,重置 PoolManager 中的刷新状态并标记为健康 - const poolManager = getProviderPoolManager(); - if (poolManager && this.uuid) { - poolManager.resetProviderRefreshStatus(MODEL_PROVIDER.ANTIGRAVITY, this.uuid); - } - } else { - logger.info(`[Antigravity Auth] No access token or refresh token. Starting new authentication flow...`); - const newTokens = await this.getNewToken(credPath); - this.authClient.setCredentials(newTokens); - logger.info('[Antigravity Auth] New token obtained and loaded into memory.'); - - // 认证成功,重置状态 - const poolManager = getProviderPoolManager(); - if (poolManager && this.uuid) { - poolManager.resetProviderRefreshStatus(MODEL_PROVIDER.ANTIGRAVITY, this.uuid); - } - } - } catch (error) { - logger.error('[Antigravity Auth] Failed to initialize authentication:', error); - throw new Error(`Failed to load OAuth credentials.`); - } - } - } - - async getNewToken(credPath) { - // 使用统一的 OAuth 处理方法 - const { authUrl, authInfo } = await handleGeminiAntigravityOAuth(this.config); - - logger.info('\n[Antigravity Auth] 正在自动打开浏览器进行授权...'); - logger.info('[Antigravity Auth] 授权链接:', authUrl, '\n'); - - // 自动打开浏览器 - const showFallbackMessage = () => { - logger.info('[Antigravity Auth] 无法自动打开浏览器,请手动复制上面的链接到浏览器中打开'); - }; - - if (this.config) { - try { - const childProcess = await open(authUrl); - if (childProcess) { - childProcess.on('error', () => showFallbackMessage()); - } - } catch (_err) { - showFallbackMessage(); - } - } else { - showFallbackMessage(); - } - - // 等待 OAuth 回调完成并读取保存的凭据 - return new Promise((resolve, reject) => { - const checkInterval = setInterval(async () => { - try { - const data = await fs.readFile(credPath, 'utf8'); - const credentials = JSON.parse(data); - if (credentials.access_token) { - clearInterval(checkInterval); - logger.info('[Antigravity Auth] New token obtained successfully.'); - resolve(credentials); - } - } catch (error) { - // 文件尚未创建或无效,继续等待 - } - }, 1000); - - // 设置超时(5分钟) - setTimeout(() => { - clearInterval(checkInterval); - reject(new Error('[Antigravity Auth] OAuth 授权超时')); - }, 5 * 60 * 1000); - }); - } - - isTokenExpiringSoon() { - if (!this.authClient.credentials.expiry_date) { - return false; - } - const currentTime = Date.now(); - const expiryTime = this.authClient.credentials.expiry_date; - const refreshSkewMs = REFRESH_SKEW * 1000; - return expiryTime <= (currentTime + refreshSkewMs); - } - - /** - * 保存凭证到文件 - * @param {string} filePath - 凭证文件路径 - * @param {Object} credentials - 凭证数据 - */ - async _saveCredentialsToFile(filePath, credentials) { - try { - await fs.writeFile(filePath, JSON.stringify(credentials, null, 2)); - logger.info(`[Antigravity Auth] Credentials saved to ${filePath}`); - } catch (error) { - logger.error(`[Antigravity Auth] Failed to save credentials to ${filePath}: ${error.message}`); - throw error; - } - } - - async discoverProjectAndModels() { - if (this.projectId) { - logger.info(`[Antigravity] Using pre-configured Project ID: ${this.projectId}`); - return this.projectId; - } - - logger.info('[Antigravity] Discovering Project ID...'); - try { - const initialProjectId = ""; - // Prepare client metadata - const clientMetadata = { - ideType: "IDE_UNSPECIFIED", - platform: "PLATFORM_UNSPECIFIED", - pluginType: "GEMINI", - duetProject: initialProjectId, - }; - - // Call loadCodeAssist to discover the actual project ID - const loadRequest = { - cloudaicompanionProject: initialProjectId, - metadata: clientMetadata, - }; - - const loadResponse = await this.callApi('loadCodeAssist', loadRequest); - - // Check if we already have a project ID from the response - if (loadResponse.cloudaicompanionProject) { - logger.info(`[Antigravity] Discovered existing Project ID: ${loadResponse.cloudaicompanionProject}`); - // 获取可用模型 - await this.fetchAvailableModels(); - return loadResponse.cloudaicompanionProject; - } - - // If no existing project, we need to onboard - const defaultTier = loadResponse.allowedTiers?.find(tier => tier.isDefault); - const tierId = defaultTier?.id || 'free-tier'; - - const onboardRequest = { - tierId: tierId, - cloudaicompanionProject: initialProjectId, - metadata: clientMetadata, - }; - - let lroResponse = await this.callApi('onboardUser', onboardRequest); - - // Poll until operation is complete with timeout protection - const MAX_RETRIES = 30; // Maximum number of retries (60 seconds total) - let retryCount = 0; - - while (!lroResponse.done && retryCount < MAX_RETRIES) { - await new Promise(resolve => setTimeout(resolve, 2000)); - lroResponse = await this.callApi('onboardUser', onboardRequest); - retryCount++; - } - - if (!lroResponse.done) { - throw new Error('Onboarding timeout: Operation did not complete within expected time.'); - } - - const discoveredProjectId = lroResponse.response?.cloudaicompanionProject?.id || initialProjectId; - logger.info(`[Antigravity] Onboarded and discovered Project ID: ${discoveredProjectId}`); - // 获取可用模型 - await this.fetchAvailableModels(); - return discoveredProjectId; - } catch (error) { - logger.error('[Antigravity] Failed to discover Project ID:', error.response?.data || error.message); - logger.info('[Antigravity] Falling back to generated Project ID as last resort...'); - const fallbackProjectId = generateProjectID(); - logger.info(`[Antigravity] Generated fallback Project ID: ${fallbackProjectId}`); - // 获取可用模型 - await this.fetchAvailableModels(); - return fallbackProjectId; - } - } - - async fetchAvailableModels() { - logger.info('[Antigravity] Fetching available models...'); - - for (const baseURL of this.baseURLs) { - try { - const modelsURL = `${baseURL}/${ANTIGRAVITY_API_VERSION}:fetchAvailableModels`; - const requestOptions = { - url: modelsURL, - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'User-Agent': this.userAgent - }, - responseType: 'json', - body: JSON.stringify({}) - }; - - const res = await this.authClient.request(requestOptions); - // logger.info(`[Antigravity] Raw response from ${baseURL}:`, Object.keys(res.data.models)); - if (res.data && res.data.models) { - const models = Object.keys(res.data.models); - this.availableModels = models - .filter(alias => alias !== undefined && alias !== '' && alias !== null) - .filter(alias => ANTIGRAVITY_MODELS.includes(alias) || alias.startsWith('claude-')) - .map(alias => alias.startsWith('claude-') ? `gemini-${alias}` : alias); - - logger.info(`[Antigravity] Available models: [${this.availableModels.join(', ')}]`); - return; - } - } catch (error) { - logger.error(`[Antigravity] Failed to fetch models from ${baseURL}:`, error.message); - } - } - - logger.warn('[Antigravity] Failed to fetch models from all endpoints. Using default models.'); - this.availableModels = ANTIGRAVITY_MODELS; - } - - async listModels() { - if (!this.isInitialized) await this.initialize(); - - const now = Math.floor(Date.now() / 1000); - const formattedModels = this.availableModels.map(modelId => { - const displayName = modelId.split('-').map(word => - word.charAt(0).toUpperCase() + word.slice(1) - ).join(' '); - - const modelInfo = { - name: `models/${modelId}`, - version: '1.0.0', - displayName: displayName, - description: `Antigravity model: ${modelId}`, - inputTokenLimit: 1024000, - outputTokenLimit: 65535, - supportedGenerationMethods: ['generateContent', 'streamGenerateContent'], - object: 'model', - created: now, - ownedBy: 'antigravity', - type: 'antigravity' - }; - - if (modelId.endsWith('-thinking') || modelId.includes('-thinking-')) { - modelInfo.thinking = { - min: 1024, - max: 100000, - zeroAllowed: false, - dynamicAllowed: true - }; - } - - return modelInfo; - }); - - return { models: formattedModels }; - } - - async callApi(method, body, isRetry = false, retryCount = 0, baseURLIndex = 0) { - const maxRetries = this.config.REQUEST_MAX_RETRIES || 3; - const baseDelay = this.config.REQUEST_BASE_DELAY || 1000; - - if (baseURLIndex >= this.baseURLs.length) { - throw new Error('All Antigravity base URLs failed'); - } - - const baseURL = this.baseURLs[baseURLIndex]; - - try { - const requestOptions = { - url: `${baseURL}/${ANTIGRAVITY_API_VERSION}:${method}`, - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'User-Agent': this.userAgent - }, - responseType: 'json', - body: JSON.stringify(body) - }; - - this._applySidecar(requestOptions); - const res = await this.authClient.request(requestOptions); - return res.data; - } catch (error) { - const status = error.response?.status; - const errorCode = error.code; - const errorMessage = error.message || ''; - - // 检查是否为可重试的网络错误 - const isNetworkError = isRetryableNetworkError(error); - - logger.error(`[Antigravity API] Error calling (Status: ${status}, Code: ${errorCode}):`, error.message); - - if ((status === 400 || status === 401) && !isRetry) { - logger.info('[Antigravity API] Received 401/400. Triggering background refresh via PoolManager...'); - - // 标记当前凭证为不健康(会自动进入刷新队列) - const poolManager = getProviderPoolManager(); - if (poolManager && this.uuid) { - logger.info(`[Antigravity] Marking credential ${this.uuid} as needs refresh. Reason: 401/400 Unauthorized`); - poolManager.markProviderNeedRefresh(MODEL_PROVIDER.ANTIGRAVITY, { - uuid: this.uuid - }); - error.credentialMarkedUnhealthy = true; - } - - // Mark error for credential switch without recording error count - error.shouldSwitchCredential = true; - error.skipErrorCount = true; - throw error; - } - - if (status === 429) { - if (baseURLIndex + 1 < this.baseURLs.length) { - logger.info(`[Antigravity API] Rate limited on ${baseURL}. Trying next base URL...`); - return this.callApi(method, body, isRetry, retryCount, baseURLIndex + 1); - } else if (retryCount < maxRetries) { - const delay = baseDelay * Math.pow(2, retryCount); - logger.info(`[Antigravity API] Rate limited. Retrying in ${delay}ms...`); - await new Promise(resolve => setTimeout(resolve, delay)); - return this.callApi(method, body, isRetry, retryCount + 1, 0); - } - } - - // Handle network errors - try next base URL first, then retry with backoff - if (isNetworkError) { - if (baseURLIndex + 1 < this.baseURLs.length) { - const errorIdentifier = errorCode || errorMessage.substring(0, 50); - logger.info(`[Antigravity API] Network error (${errorIdentifier}) on ${baseURL}. Trying next base URL...`); - return this.callApi(method, body, isRetry, retryCount, baseURLIndex + 1); - } else if (retryCount < maxRetries) { - const delay = baseDelay * Math.pow(2, retryCount); - const errorIdentifier = errorCode || errorMessage.substring(0, 50); - logger.info(`[Antigravity API] Network error (${errorIdentifier}). Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, delay)); - return this.callApi(method, body, isRetry, retryCount + 1, 0); - } - } - - if (status >= 500 && status < 600 && retryCount < maxRetries) { - const delay = baseDelay * Math.pow(2, retryCount); - logger.info(`[Antigravity API] Server error ${status}. Retrying in ${delay}ms...`); - await new Promise(resolve => setTimeout(resolve, delay)); - return this.callApi(method, body, isRetry, retryCount + 1, baseURLIndex); - } - - throw error; - } - } - - async * streamApi(method, body, isRetry = false, retryCount = 0, baseURLIndex = 0) { - const maxRetries = this.config.REQUEST_MAX_RETRIES || 3; - const baseDelay = this.config.REQUEST_BASE_DELAY || 1000; - - if (baseURLIndex >= this.baseURLs.length) { - throw new Error('All Antigravity base URLs failed'); - } - - const baseURL = this.baseURLs[baseURLIndex]; - - try { - const requestOptions = { - url: `${baseURL}/${ANTIGRAVITY_API_VERSION}:${method}`, - method: 'POST', - params: { alt: 'sse' }, - headers: { - 'Content-Type': 'application/json', - 'Accept': 'text/event-stream', - 'User-Agent': this.userAgent - }, - responseType: 'stream', - body: JSON.stringify(body) - }; - - this._applySidecar(requestOptions); - const res = await this.authClient.request(requestOptions); - - if (res.status !== 200) { - let errorBody = ''; - for await (const chunk of res.data) { - errorBody += chunk.toString(); - } - throw new Error(`Upstream API Error (Status ${res.status}): ${errorBody}`); - } - - yield* this.parseSSEStream(res.data); - } catch (error) { - const status = error.response?.status; - const errorCode = error.code; - const errorMessage = error.message || ''; - - // 检查是否为可重试的网络错误 - const isNetworkError = isRetryableNetworkError(error); - - logger.error(`[Antigravity API] Error during stream (Status: ${status}, Code: ${errorCode}):`, error.message); - - if ((status === 400 || status === 401) && !isRetry) { - logger.info('[Antigravity API] Received 401/400 during stream. Triggering background refresh via PoolManager...'); - - // 标记当前凭证为不健康(会自动进入刷新队列) - const poolManager = getProviderPoolManager(); - if (poolManager && this.uuid) { - logger.info(`[Antigravity] Marking credential ${this.uuid} as needs refresh. Reason: 401/400 Unauthorized in stream`); - poolManager.markProviderNeedRefresh(MODEL_PROVIDER.ANTIGRAVITY, { - uuid: this.uuid - }); - error.credentialMarkedUnhealthy = true; - } - - // Mark error for credential switch without recording error count - error.shouldSwitchCredential = true; - error.skipErrorCount = true; - throw error; - } - - if (status === 429) { - if (baseURLIndex + 1 < this.baseURLs.length) { - logger.info(`[Antigravity API] Rate limited on ${baseURL}. Trying next base URL...`); - yield* this.streamApi(method, body, isRetry, retryCount, baseURLIndex + 1); - return; - } else if (retryCount < maxRetries) { - const delay = baseDelay * Math.pow(2, retryCount); - logger.info(`[Antigravity API] Rate limited during stream. Retrying in ${delay}ms...`); - await new Promise(resolve => setTimeout(resolve, delay)); - yield* this.streamApi(method, body, isRetry, retryCount + 1, 0); - return; - } - } - - // Handle network errors - try next base URL first, then retry with backoff - if (isNetworkError) { - if (baseURLIndex + 1 < this.baseURLs.length) { - const errorIdentifier = errorCode || errorMessage.substring(0, 50); - logger.info(`[Antigravity API] Network error (${errorIdentifier}) on ${baseURL} during stream. Trying next base URL...`); - yield* this.streamApi(method, body, isRetry, retryCount, baseURLIndex + 1); - return; - } else if (retryCount < maxRetries) { - const delay = baseDelay * Math.pow(2, retryCount); - const errorIdentifier = errorCode || errorMessage.substring(0, 50); - logger.info(`[Antigravity API] Network error (${errorIdentifier}) during stream. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, delay)); - yield* this.streamApi(method, body, isRetry, retryCount + 1, 0); - return; - } - } - - if (status >= 500 && status < 600 && retryCount < maxRetries) { - const delay = baseDelay * Math.pow(2, retryCount); - logger.info(`[Antigravity API] Server error ${status} during stream. Retrying in ${delay}ms...`); - await new Promise(resolve => setTimeout(resolve, delay)); - yield* this.streamApi(method, body, isRetry, retryCount + 1, baseURLIndex); - return; - } - - throw error; - } - } - - async * parseSSEStream(stream) { - const rl = readline.createInterface({ - input: stream, - crlfDelay: Infinity - }); - - let buffer = []; - for await (let line of rl) { - if (line.startsWith('data: ')) { - // 过滤 usageMetadata(仅在最终块中保留) - line = filterSSEUsageMetadata(line); - buffer.push(line.slice(6)); - } else if (line === '' && buffer.length > 0) { - try { - yield JSON.parse(buffer.join('\n')); - } catch (e) { - logger.error('[Antigravity Stream] Failed to parse JSON chunk:', buffer.join('\n')); - } - buffer = []; - } - } - - if (buffer.length > 0) { - try { - yield JSON.parse(buffer.join('\n')); - } catch (e) { - logger.error('[Antigravity Stream] Failed to parse final JSON chunk:', buffer.join('\n')); - } - } - } - - async generateContent(model, requestBody) { - logger.info(`[Antigravity Auth Token] Time until expiry: ${formatExpiryTime(this.authClient.credentials.expiry_date)}`); - - // 临时存储 monitorRequestId - if (requestBody._monitorRequestId) { - this.config._monitorRequestId = requestBody._monitorRequestId; - delete requestBody._monitorRequestId; - } - if (requestBody._requestBaseUrl) { - delete requestBody._requestBaseUrl; - } - - // 检查 token 是否即将过期,如果是则推送到刷新队列 - if (this.isExpiryDateNear()) { - const poolManager = getProviderPoolManager(); - if (poolManager && this.uuid) { - logger.info(`[Antigravity] Token is near expiry, marking credential ${this.uuid} for refresh`); - poolManager.markProviderNeedRefresh(MODEL_PROVIDER.ANTIGRAVITY, { - uuid: this.uuid - }); - } - } - - let selectedModel = model; - if (!this.availableModels.includes(model)) { - logger.warn(`[Antigravity] Model '${model}' not found. Using default model: 'gemini-3-flash'`); - selectedModel = 'gemini-3-flash'; - } - - // 移除 gemini- 前缀以获取实际模型名称(针对 claude 模型) - const actualModelName = selectedModel.startsWith('gemini-claude-') ? selectedModel.replace('gemini-claude-', 'claude-') : selectedModel; - logger.info(`[Antigravity] Selected model: ${actualModelName}`); - // 深拷贝请求体 - const processedRequestBody = ensureRolesInContents(JSON.parse(JSON.stringify(requestBody)), actualModelName); - const isClaudeModel = isClaude(actualModelName); - - // 将处理后的请求体转换为 Antigravity 格式 - const payload = geminiToAntigravity(actualModelName, { request: processedRequestBody }, this.projectId); - - // 设置模型名称为实际模型名 - payload.model = actualModelName; - - // 对于 Claude 模型,使用流式请求然后转换为非流式响应 - if (isClaudeModel) { - return await this.executeClaudeNonStream(payload); - } - - const response = await this.callApi('generateContent', payload); - return toGeminiApiResponse(response.response); - } - - /** - * 执行 Claude 非流式请求 - * Claude 模型实际上使用流式请求,然后将结果合并为非流式响应 - * @param {Object} payload - 请求体 - * @returns {Object} 非流式响应 - */ - async executeClaudeNonStream(payload) { - const chunks = []; - - try { - const stream = this.streamApi('streamGenerateContent', payload); - for await (const chunk of stream) { - if (chunk) { - chunks.push(JSON.stringify(chunk)); - } - } - - // 将流式响应转换为非流式响应 - const streamData = chunks.join('\n'); - const nonStreamResponse = convertStreamToNonStream(streamData); - return toGeminiApiResponse(nonStreamResponse.response); - } catch (error) { - logger.error('[Antigravity] Claude non-stream execution error:', error.message); - throw error; - } - } - - async * generateContentStream(model, requestBody) { - logger.info(`[Antigravity Auth Token] Time until expiry: ${formatExpiryTime(this.authClient.credentials.expiry_date)}`); - - // 临时存储 monitorRequestId - if (requestBody._monitorRequestId) { - this.config._monitorRequestId = requestBody._monitorRequestId; - delete requestBody._monitorRequestId; - } - if (requestBody._requestBaseUrl) { - delete requestBody._requestBaseUrl; - } - - // 检查 token 是否即将过期,如果是则推送到刷新队列 - if (this.isExpiryDateNear()) { - const poolManager = getProviderPoolManager(); - if (poolManager && this.uuid) { - logger.info(`[Antigravity] Token is near expiry, marking credential ${this.uuid} for refresh`); - poolManager.markProviderNeedRefresh(MODEL_PROVIDER.ANTIGRAVITY, { - uuid: this.uuid - }); - } - } - - let selectedModel = model; - if (!this.availableModels.includes(model)) { - logger.warn(`[Antigravity] Model '${model}' not found. Using default model: 'gemini-3-flash'`); - selectedModel = 'gemini-3-flash'; - } - - // 移除 gemini- 前缀以获取实际模型名称(针对 claude 模型) - const actualModelName = selectedModel.startsWith('gemini-claude-') ? selectedModel.replace('gemini-claude-', 'claude-') : selectedModel; - logger.info(`[Antigravity] Selected model: ${actualModelName}`); - // 深拷贝请求体 - const processedRequestBody = ensureRolesInContents(JSON.parse(JSON.stringify(requestBody)), actualModelName); - - // 将处理后的请求体转换为 Antigravity 格式 - const payload = geminiToAntigravity(actualModelName, { request: processedRequestBody }, this.projectId); - - // 设置模型名称为实际模型名 - payload.model = actualModelName; - - const stream = this.streamApi('streamGenerateContent', payload); - for await (const chunk of stream) { - yield toGeminiApiResponse(chunk.response); - } - } - - isExpiryDateNear() { - try { - const nearMinutes = 20; - const { message, isNearExpiry } = formatExpiryLog('Antigravity', this.authClient.credentials.expiry_date, nearMinutes); - logger.info(message); - return isNearExpiry; - } catch (error) { - logger.error(`[Antigravity] Error checking expiry date: ${error.message}`); - return false; - } - } - - /** - * 获取模型配额信息 - * @returns {Promise} 模型配额信息 - */ - async getUsageLimits() { - if (!this.isInitialized) await this.initialize(); - - // 注意:V2 架构下不再在 getUsageLimits 中同步刷新 token - // 如果 token 过期,PoolManager 后台会自动处理 - // if (this.isExpiryDateNear()) { - // logger.info('[Antigravity] Token is near expiry, refreshing before getUsageLimits request...'); - // await this.initializeAuth(true); - // } - - try { - const modelsWithQuotas = await this.getModelsWithQuotas(); - return modelsWithQuotas; - } catch (error) { - logger.error('[Antigravity] Failed to get usage limits:', error.message); - throw error; - } - } - - /** - * 获取带配额信息的模型列表 - * @returns {Promise} 模型配额信息 - */ - async getModelsWithQuotas() { - try { - // 解析模型配额信息 - const result = { - lastUpdated: Date.now(), - models: {} - }; - - // 调用 fetchAvailableModels 接口获取模型和配额信息 - for (const baseURL of this.baseURLs) { - try { - const modelsURL = `${baseURL}/${ANTIGRAVITY_API_VERSION}:fetchAvailableModels`; - const requestOptions = { - url: modelsURL, - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'User-Agent': this.userAgent - }, - responseType: 'json', - body: JSON.stringify({ project: this.projectId }) - }; - - this._applySidecar(requestOptions); - const res = await this.authClient.request(requestOptions); - // logger.info(`[Antigravity] fetchAvailableModels success: ${JSON.stringify(res.data)}`); - if (res.data) { - - if (res.data.models) { - const modelsData = res.data.models; - - // 遍历模型数据,提取配额信息 - for (const [modelId, modelData] of Object.entries(modelsData)) { - // 参考 fetchAvailableModels 的逻辑修复 modelName2Alias 不存在的问题 - if (!modelId || (!ANTIGRAVITY_MODELS.includes(modelId) && !modelId.startsWith('claude-'))) { - continue; - } - - const aliasName = modelId.startsWith('claude-') ? `gemini-${modelId}` : modelId; - - const modelInfo = { - remaining: 0, - resetTime: null, - resetTimeRaw: null - }; - - // 从 quotaInfo 中提取配额信息 - if (modelData.quotaInfo) { - modelInfo.remaining = modelData.quotaInfo.remainingFraction !== undefined ? modelData.quotaInfo.remainingFraction : (modelData.quotaInfo.remaining || 0); - modelInfo.resetTime = modelData.quotaInfo.resetTime || null; - modelInfo.resetTimeRaw = modelData.quotaInfo.resetTime; - } - - result.models[aliasName] = modelInfo; - } - } - - // 对模型按名称排序 - const sortedModels = {}; - Object.keys(result.models).sort().forEach(key => { - sortedModels[key] = result.models[key]; - }); - result.models = sortedModels; - logger.info(`[Antigravity] Successfully fetched quotas for ${Object.keys(result.models).length} models`); - break; // 成功获取后退出循环 - } - } catch (error) { - logger.error(`[Antigravity] Failed to fetch models with quotas from ${baseURL}:`, error.message); - } - } - - return result; - } catch (error) { - logger.error('[Antigravity] Failed to get models with quotas:', error.message); - throw error; - } - } - -} diff --git a/src/providers/gemini/gemini-core.js b/src/providers/gemini/gemini-core.js deleted file mode 100644 index 7a497ca2595ee94c0851f813b318007dbef9ebbc..0000000000000000000000000000000000000000 --- a/src/providers/gemini/gemini-core.js +++ /dev/null @@ -1,942 +0,0 @@ -import { OAuth2Client } from 'google-auth-library'; -import logger from '../../utils/logger.js'; -import * as http from 'http'; -import * as https from 'https'; -import { promises as fs } from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import * as readline from 'readline'; -import open from 'open'; -import { configureTLSSidecar } from '../../utils/proxy-utils.js'; -import { API_ACTIONS, formatExpiryTime, isRetryableNetworkError, formatExpiryLog } from '../../utils/common.js'; -import { getProviderModels } from '../provider-models.js'; -import { handleGeminiCliOAuth } from '../../auth/oauth-handlers.js'; -import { getProxyConfigForProvider, getGoogleAuthProxyConfig } from '../../utils/proxy-utils.js'; -import { getProviderPoolManager } from '../../services/service-manager.js'; -import { MODEL_PROVIDER } from '../../utils/common.js'; - -// --- Constants --- -const AUTH_REDIRECT_PORT = 8085; -const CREDENTIALS_DIR = '.gemini'; -const CREDENTIALS_FILE = 'oauth_creds.json'; -const DEFAULT_CODE_ASSIST_ENDPOINT = 'https://cloudcode-pa.googleapis.com'; -const DEFAULT_CODE_ASSIST_API_VERSION = 'v1internal'; -const OAUTH_CLIENT_ID = '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com'; -const OAUTH_CLIENT_SECRET = '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'; -const GEMINI_MODELS = getProviderModels(MODEL_PROVIDER.GEMINI_CLI); -const ANTI_TRUNCATION_MODELS = GEMINI_MODELS.map(model => `anti-${model}`); -const GEMINI_CLI_VERSION = '0.31.0'; -const GEMINI_CLI_API_CLIENT_HEADER = 'google-genai-sdk/1.41.0 gl-node/v22.19.0'; - -/** - * 设置 Gemini CLI 所需的特定请求头 - * @param {Object} headers - 请求头对象 - * @param {string} model - 模型名称 - */ -function applyGeminiCLIHeaders(headers, model) { - const platform = os.platform(); - let arch = os.arch(); - if (arch === 'ia32') arch = 'x86'; - const modelName = model || 'unknown'; - if (model !== 'load-code-assist' && model !== 'onboard-user') { - headers['User-Agent'] = `GeminiCLI/${GEMINI_CLI_VERSION}/${modelName} (${platform}; ${arch})`; - } - headers['X-Goog-Api-Client'] = GEMINI_CLI_API_CLIENT_HEADER; -} - - -/** - * 从 Google API 的 429 错误响应中提取重试延迟 - * @param {Object|string} errorBody - 错误响应体 - * @returns {number|null} 延迟毫秒数 - */ -function parseRetryDelay(errorBody) { - try { - const data = typeof errorBody === 'string' ? JSON.parse(errorBody) : errorBody; - const details = data?.error?.details; - if (Array.isArray(details)) { - for (const detail of details) { - if (detail['@type'] === 'type.googleapis.com/google.rpc.RetryInfo') { - const retryDelay = detail.retryDelay; - if (retryDelay) { - const match = retryDelay.match(/^([\d.]+)s$/); - if (match) return parseFloat(match[1]) * 1000; - } - } - } - for (const detail of details) { - if (detail['@type'] === 'type.googleapis.com/google.rpc.ErrorInfo') { - const quotaResetDelay = detail.metadata?.quotaResetDelay; - if (quotaResetDelay) { - const match = quotaResetDelay.match(/^([\d.]+)(ms|s)$/); - if (match) { - let ms = parseFloat(match[1]); - if (match[2] === 's') ms *= 1000; - return ms; - } - } - } - } - } - const message = data?.error?.message; - if (message) { - const match = message.match(/after\s+(\d+)s\.?/); - if (match) return parseInt(match[1]) * 1000; - } - } catch (e) {} - return null; -} - -function is_anti_truncation_model(model) { - return ANTI_TRUNCATION_MODELS.some(antiModel => model.includes(antiModel)); -} - -// 从防截断模型名中提取实际模型名 -function extract_model_from_anti_model(model) { - if (model.startsWith('anti-')) { - const originalModel = model.substring(5); // 移除 'anti-' 前缀 - if (GEMINI_MODELS.includes(originalModel)) { - return originalModel; - } - } - return model; // 如果不是anti-前缀或不在原模型列表中,则返回原模型名 -} - -function toGeminiApiResponse(codeAssistResponse) { - if (!codeAssistResponse) return null; - const compliantResponse = { candidates: codeAssistResponse.candidates }; - if (codeAssistResponse.usageMetadata) compliantResponse.usageMetadata = codeAssistResponse.usageMetadata; - if (codeAssistResponse.promptFeedback) compliantResponse.promptFeedback = codeAssistResponse.promptFeedback; - if (codeAssistResponse.automaticFunctionCallingHistory) compliantResponse.automaticFunctionCallingHistory = codeAssistResponse.automaticFunctionCallingHistory; - return compliantResponse; -} - -/** - * Ensures that all content parts in a request body have a 'role' property. - * If 'systemInstruction' is present and lacks a role, it defaults to 'user'. - * If any 'contents' entry lacks a role, it defaults to 'user'. - * @param {Object} requestBody - The request body object. - * @returns {Object} The modified request body with roles ensured. - */ -function ensureRolesInContents(requestBody) { - delete requestBody.model; - // delete requestBody.system_instruction; - // delete requestBody.systemInstruction; - if (requestBody.system_instruction) { - requestBody.systemInstruction = requestBody.system_instruction; - delete requestBody.system_instruction; - } - - if (requestBody.systemInstruction && !requestBody.systemInstruction.role) { - requestBody.systemInstruction.role = 'user'; - } - - if (requestBody.contents && Array.isArray(requestBody.contents)) { - requestBody.contents.forEach(content => { - if (!content.role) { - content.role = 'user'; - } - }); - - // 如果存在 systemInstruction,将其放在 contents 索引 0 的位置 - // if (requestBody.systemInstruction) { - // // 检查 contents[0] 是否与 systemInstruction 内容相同 - // const firstContent = requestBody.contents[0]; - // let isSame = false; - - // if (firstContent && firstContent.parts && requestBody.systemInstruction.parts) { - // // 比较 parts 数组的内容 - // const firstContentText = firstContent.parts - // .filter(p => p?.text) - // .map(p => p.text) - // .join('\n'); - // const systemInstructionText = requestBody.systemInstruction.parts - // .filter(p => p?.text) - // .map(p => p.text) - // .join('\n'); - - // isSame = firstContentText === systemInstructionText; - // } - - // // 如果内容不同,则将 systemInstruction 插入到索引 0 的位置 - // if (!isSame) { - // requestBody.contents.unshift({ - // role: requestBody.systemInstruction.role || 'user', - // parts: requestBody.systemInstruction.parts - // }); - // } - // delete requestBody.systemInstruction; - // } - } - return requestBody; -} - -async function* apply_anti_truncation_to_stream(service, model, requestBody) { - let currentRequest = { ...requestBody }; - let allGeneratedText = ''; - - while (true) { - // 发送请求并处理流式响应 - const apiRequest = { - model: model, - project: service.projectId, - request: currentRequest - }; - const stream = service.streamApi(API_ACTIONS.STREAM_GENERATE_CONTENT, apiRequest, false, 0, model); - - let lastChunk = null; - let hasContent = false; - - for await (const chunk of stream) { - const response = toGeminiApiResponse(chunk.response); - if (response && response.candidates && response.candidates[0]) { - yield response; - lastChunk = response; - hasContent = true; - } - } - - // 检查是否因为达到token限制而截断 - if (lastChunk && - lastChunk.candidates && - lastChunk.candidates[0] && - lastChunk.candidates[0].finishReason === 'MAX_TOKENS') { - - // 提取已生成的文本内容 - if (lastChunk.candidates[0].content && Array.isArray(lastChunk.candidates[0].content.parts)) { - const generatedParts = lastChunk.candidates[0].content.parts - .filter(part => part?.text) - .map(part => part.text); - - if (generatedParts.length > 0) { - const currentGeneratedText = generatedParts.join(''); - allGeneratedText += currentGeneratedText; - - // 构建新的请求,包含之前的对话历史和继续指令 - const newContents = [...requestBody.contents]; - - // 添加之前生成的内容作为模型响应 - newContents.push({ - role: 'model', - parts: [{ text: currentGeneratedText }] - }); - - // 添加继续生成的指令 - newContents.push({ - role: 'user', - parts: [{ text: 'Please continue from where you left off.' }] - }); - - currentRequest = { - ...requestBody, - contents: newContents - }; - - // 继续下一轮请求 - continue; - } - } - } - - // 如果没有截断或无法继续,则退出循环 - break; - } -} - -export class GeminiApiService { - constructor(config) { - // 配置 HTTP/HTTPS agent 限制连接池大小,避免资源泄漏 - this.httpAgent = new http.Agent({ - keepAlive: true, - maxSockets: 100, - maxFreeSockets: 5, - timeout: 120000, - }); - this.httpsAgent = new https.Agent({ - keepAlive: true, - maxSockets: 100, - maxFreeSockets: 5, - timeout: 120000, - }); - - // 检查是否需要使用代理 - const proxyConfig = getGoogleAuthProxyConfig(config, 'gemini-cli-oauth'); - - // 配置 OAuth2Client 使用自定义的 HTTP agent - const oauth2Options = { - clientId: OAUTH_CLIENT_ID, - clientSecret: OAUTH_CLIENT_SECRET, - }; - - if (proxyConfig) { - oauth2Options.transporterOptions = proxyConfig; - logger.info('[Gemini] Using proxy for OAuth2Client'); - } else { - oauth2Options.transporterOptions = { - agent: this.httpsAgent, - }; - } - - this.authClient = new OAuth2Client(oauth2Options); - this.availableModels = []; - this.isInitialized = false; - - this.config = config; - this.host = config.HOST; - this.oauthCredsBase64 = config.GEMINI_OAUTH_CREDS_BASE64; - this.oauthCredsFilePath = config.GEMINI_OAUTH_CREDS_FILE_PATH; - this.projectId = config.PROJECT_ID; - - this.codeAssistEndpoint = config.GEMINI_BASE_URL || DEFAULT_CODE_ASSIST_ENDPOINT; - this.apiVersion = DEFAULT_CODE_ASSIST_API_VERSION; - - // 保存代理配置供后续使用 - this.proxyConfig = getProxyConfigForProvider(config, 'gemini-cli-oauth'); - } - - async initialize() { - if (this.isInitialized) return; - logger.info('[Gemini] Initializing Gemini API Service...'); - // 注意:V2 读写分离架构下,初始化不再执行同步认证/刷新逻辑 - // 仅执行基础的凭证加载 - await this.loadCredentials(); - - if (!this.projectId) { - this.projectId = await this.discoverProjectAndModels(); - } else { - logger.info(`[Gemini] Using provided Project ID: ${this.projectId}`); - this.availableModels = GEMINI_MODELS; - logger.info(`[Gemini] Using fixed models: [${this.availableModels.join(', ')}]`); - } - if (this.projectId === 'default') { - throw new Error("Error: 'default' is not a valid project ID. Please provide a valid Google Cloud Project ID using the --project-id argument."); - } - this.isInitialized = true; - logger.info(`[Gemini] Initialization complete. Project ID: ${this.projectId}`); - } - - _applySidecar(requestOptions) { - return configureTLSSidecar(requestOptions, this.config, MODEL_PROVIDER.GEMINI_CLI); - } - - /** - * 加载凭证信息(不执行刷新) - */ - async loadCredentials() { - if (this.oauthCredsBase64) { - try { - const decoded = Buffer.from(this.oauthCredsBase64, 'base64').toString('utf8'); - const credentials = JSON.parse(decoded); - this.authClient.setCredentials(credentials); - logger.info('[Gemini Auth] Credentials loaded successfully from base64 string.'); - return; - } catch (error) { - logger.error('[Gemini Auth] Failed to parse base64 OAuth credentials:', error); - } - } - - const credPath = this.oauthCredsFilePath || path.join(os.homedir(), CREDENTIALS_DIR, CREDENTIALS_FILE); - try { - const data = await fs.readFile(credPath, "utf8"); - const credentials = JSON.parse(data); - this.authClient.setCredentials(credentials); - logger.info('[Gemini Auth] Credentials loaded successfully from file.'); - } catch (error) { - if (error.code === 'ENOENT') { - logger.debug(`[Gemini Auth] Credentials file not found: ${credPath}`); - } else { - logger.warn(`[Gemini Auth] Failed to load credentials from file: ${error.message}`); - } - } - } - - async initializeAuth(forceRefresh = false) { - // 首先执行基础凭证加载 - await this.loadCredentials(); - - // 检查是否需要刷新 Token(加载凭证后评估) - const needsRefresh = forceRefresh || this.isTokenExpiringSoon(); - - if (this.authClient.credentials.access_token && !needsRefresh) { - // Token 有效且不需要刷新 - return; - } - - // 只有在明确要求刷新,或者 AccessToken 确实缺失时,才执行刷新/认证 - // 注意:在 V2 架构下,此方法主要由 PoolManager 的后台队列调用 - if (needsRefresh || !this.authClient.credentials.access_token) { - const credPath = this.oauthCredsFilePath || path.join(os.homedir(), CREDENTIALS_DIR, CREDENTIALS_FILE); - try { - if (this.authClient.credentials.refresh_token) { - logger.info('[Gemini Auth] Token expiring soon or force refresh requested. Refreshing token...'); - const { credentials: newCredentials } = await this.authClient.refreshAccessToken(); - this.authClient.setCredentials(newCredentials); - - // 如果不是从 base64 加载的,则保存到文件 - if (!this.oauthCredsBase64) { - await this._saveCredentialsToFile(credPath, newCredentials); - logger.info('[Gemini Auth] Token refreshed and saved successfully.'); - } else { - logger.info('[Gemini Auth] Token refreshed successfully (Base64 source).'); - } - - // 刷新成功,重置 PoolManager 中的刷新状态并标记为健康 - const poolManager = getProviderPoolManager(); - if (poolManager && this.uuid) { - poolManager.resetProviderRefreshStatus(MODEL_PROVIDER.GEMINI_CLI, this.uuid); - } - } else { - logger.info(`[Gemini Auth] No access token or refresh token. Starting new authentication flow...`); - const newTokens = await this.getNewToken(credPath); - this.authClient.setCredentials(newTokens); - logger.info('[Gemini Auth] New token obtained and loaded into memory.'); - - // 认证成功,重置状态 - const poolManager = getProviderPoolManager(); - if (poolManager && this.uuid) { - poolManager.resetProviderRefreshStatus(MODEL_PROVIDER.GEMINI_CLI, this.uuid); - } - } - } catch (error) { - logger.error('[Gemini Auth] Failed to initialize authentication:', error); - throw new Error(`Failed to load OAuth credentials.`); - } - } - } - - async getNewToken(credPath) { - // 使用统一的 OAuth 处理方法 - const { authUrl, authInfo } = await handleGeminiCliOAuth(this.config); - - logger.info('\n[Gemini Auth] 正在自动打开浏览器进行授权...'); - logger.info('[Gemini Auth] 授权链接:', authUrl, '\n'); - - // 自动打开浏览器 - const showFallbackMessage = () => { - logger.info('[Gemini Auth] 无法自动打开浏览器,请手动复制上面的链接到浏览器中打开'); - }; - - if (this.config) { - try { - const childProcess = await open(authUrl); - if (childProcess) { - childProcess.on('error', () => showFallbackMessage()); - } - } catch (_err) { - showFallbackMessage(); - } - } else { - showFallbackMessage(); - } - - // 等待 OAuth 回调完成并读取保存的凭据 - return new Promise((resolve, reject) => { - const checkInterval = setInterval(async () => { - try { - const data = await fs.readFile(credPath, 'utf8'); - const credentials = JSON.parse(data); - if (credentials.access_token) { - clearInterval(checkInterval); - logger.info('[Gemini Auth] New token obtained successfully.'); - resolve(credentials); - } - } catch (error) { - // 文件尚未创建或无效,继续等待 - } - }, 1000); - - // 设置超时(5分钟) - setTimeout(() => { - clearInterval(checkInterval); - reject(new Error('[Gemini Auth] OAuth 授权超时')); - }, 5 * 60 * 1000); - }); - } - - async discoverProjectAndModels() { - if (this.projectId) { - logger.info(`[Gemini] Using pre-configured Project ID: ${this.projectId}`); - return this.projectId; - } - - logger.info('[Gemini] Discovering Project ID...'); - this.availableModels = GEMINI_MODELS; - logger.info(`[Gemini] Using fixed models: [${this.availableModels.join(', ')}]`); - try { - const initialProjectId = "" - // Prepare client metadata - const clientMetadata = { - ideType: "IDE_UNSPECIFIED", - platform: "PLATFORM_UNSPECIFIED", - pluginType: "GEMINI", - duetProject: initialProjectId, - } - - // Call loadCodeAssist to discover the actual project ID - const loadRequest = { - cloudaicompanionProject: initialProjectId, - metadata: clientMetadata, - } - - const loadResponse = await this.callApi('loadCodeAssist', loadRequest, false, 0, 'load-code-assist'); - - // Check if we already have a project ID from the response - if (loadResponse.cloudaicompanionProject) { - return loadResponse.cloudaicompanionProject; - } - - // If no existing project, we need to onboard - const defaultTier = loadResponse.allowedTiers?.find(tier => tier.isDefault); - const tierId = defaultTier?.id || 'free-tier'; - - const onboardRequest = { - tierId: tierId, - cloudaicompanionProject: initialProjectId, - metadata: clientMetadata, - }; - - let lroResponse = await this.callApi('onboardUser', onboardRequest, false, 0, 'onboard-user'); - - // Poll until operation is complete with timeout protection - const MAX_RETRIES = 30; // Maximum number of retries (60 seconds total) - let retryCount = 0; - - while (!lroResponse.done && retryCount < MAX_RETRIES) { - await new Promise(resolve => setTimeout(resolve, 2000)); - lroResponse = await this.callApi('onboardUser', onboardRequest, false, 0, 'onboard-user'); - retryCount++; - } - - if (!lroResponse.done) { - throw new Error('Onboarding timeout: Operation did not complete within expected time.'); - } - - const discoveredProjectId = lroResponse.response?.cloudaicompanionProject?.id || initialProjectId; - return discoveredProjectId; - } catch (error) { - logger.error('[Gemini] Failed to discover Project ID:', error.response?.data || error.message); - throw new Error('Could not discover a valid Google Cloud Project ID.'); - } - } - - async listModels() { - if (!this.isInitialized) await this.initialize(); - const formattedModels = this.availableModels.map(modelId => { - const displayName = modelId.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' '); - return { - name: `models/${modelId}`, version: "1.0.0", displayName: displayName, - description: `A generative model for text and chat generation. ID: ${modelId}`, - inputTokenLimit: 1024000, outputTokenLimit: 65535, - supportedGenerationMethods: ["generateContent", "streamGenerateContent"], - }; - }); - return { models: formattedModels }; - } - - async callApi(method, body, isRetry = false, retryCount = 0, model = 'unknown') { - const maxRetries = this.config.REQUEST_MAX_RETRIES || 3; - const baseDelay = this.config.REQUEST_BASE_DELAY || 1000; // 1 second base delay - - try { - const headers = { "Content-Type": "application/json" }; - applyGeminiCLIHeaders(headers, model); - - const requestOptions = { - url: `${this.codeAssistEndpoint}/${this.apiVersion}:${method}`, - method: "POST", - headers: headers, - responseType: "json", - body: JSON.stringify(body), - }; - this._applySidecar(requestOptions); - const res = await this.authClient.request(requestOptions); - return res.data; - } catch (error) { - const status = error.response?.status; - const errorCode = error.code; - const errorMessage = error.message || ''; - - // 检查是否为可重试的网络错误 - const isNetworkError = isRetryableNetworkError(error); - - logger.error(`[Gemini API] Error calling (Status: ${status}, Code: ${errorCode}):`, errorMessage); - - // Handle 401 (Unauthorized) - refresh auth and retry once - if ((status === 400 || status === 401) && !isRetry) { - logger.info('[Gemini API] Received 401/400. Triggering background refresh via PoolManager...'); - - // 标记当前凭证为不健康(会自动进入刷新队列) - const poolManager = getProviderPoolManager(); - if (poolManager && this.uuid) { - logger.info(`[Gemini] Marking credential ${this.uuid} as needs refresh. Reason: 401/400 Unauthorized`); - poolManager.markProviderNeedRefresh(MODEL_PROVIDER.GEMINI_CLI, { - uuid: this.uuid - }); - error.credentialMarkedUnhealthy = true; - } - - // Mark error for credential switch without recording error count - error.shouldSwitchCredential = true; - error.skipErrorCount = true; - throw error; - } - - // Handle 429 (Too Many Requests) with exponential backoff - if (status === 429 && retryCount < maxRetries) { - const delay = parseRetryDelay(error.response?.data) || (baseDelay * Math.pow(2, retryCount)); - logger.info(`[Gemini API] Received 429 (Too Many Requests). Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, delay)); - return this.callApi(method, body, isRetry, retryCount + 1, model); - } - - // Handle other retryable errors (5xx server errors) - if (status >= 500 && status < 600 && retryCount < maxRetries) { - const delay = baseDelay * Math.pow(2, retryCount); - logger.info(`[Gemini API] Received ${status} server error. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, delay)); - return this.callApi(method, body, isRetry, retryCount + 1, model); - } - - // Handle network errors (ECONNRESET, ETIMEDOUT, etc.) with exponential backoff - if (isNetworkError && retryCount < maxRetries) { - const delay = baseDelay * Math.pow(2, retryCount); - const errorIdentifier = errorCode || errorMessage.substring(0, 50); - logger.info(`[Gemini API] Network error (${errorIdentifier}). Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, delay)); - return this.callApi(method, body, isRetry, retryCount + 1, model); - } - - throw error; - } - } - - async * streamApi(method, body, isRetry = false, retryCount = 0, model = 'unknown') { - const maxRetries = this.config.REQUEST_MAX_RETRIES || 3; - const baseDelay = this.config.REQUEST_BASE_DELAY || 1000; // 1 second base delay - - try { - const headers = { "Content-Type": "application/json" }; - applyGeminiCLIHeaders(headers, model); - - const requestOptions = { - url: `${this.codeAssistEndpoint}/${this.apiVersion}:${method}`, - method: "POST", - params: { alt: "sse" }, - headers: headers, - responseType: "stream", - body: JSON.stringify(body), - }; - this._applySidecar(requestOptions); - const res = await this.authClient.request(requestOptions); - if (res.status !== 200) { - let errorBody = ''; - for await (const chunk of res.data) errorBody += chunk.toString(); - throw new Error(`Upstream API Error (Status ${res.status}): ${errorBody}`); - } - yield* this.parseSSEStream(res.data); - } catch (error) { - const status = error.response?.status; - const errorCode = error.code; - const errorMessage = error.message || ''; - - // 检查是否为可重试的网络错误 - const isNetworkError = isRetryableNetworkError(error); - - logger.error(`[Gemini API] Error during stream (Status: ${status}, Code: ${errorCode}):`, errorMessage); - - // Handle 401 (Unauthorized) - refresh auth and retry once - if ((status === 400 || status === 401) && !isRetry) { - logger.info('[Gemini API] Received 401/400 during stream. Triggering background refresh via PoolManager...'); - - // 标记当前凭证为不健康(会自动进入刷新队列) - const poolManager = getProviderPoolManager(); - if (poolManager && this.uuid) { - logger.info(`[Gemini] Marking credential ${this.uuid} as needs refresh. Reason: 401/400 Unauthorized in stream`); - poolManager.markProviderNeedRefresh(MODEL_PROVIDER.GEMINI_CLI, { - uuid: this.uuid - }); - error.credentialMarkedUnhealthy = true; - } - - // Mark error for credential switch without recording error count - error.shouldSwitchCredential = true; - error.skipErrorCount = true; - throw error; - } - - // Handle 429 (Too Many Requests) with exponential backoff - if (status === 429 && retryCount < maxRetries) { - const delay = parseRetryDelay(error.response?.data) || (baseDelay * Math.pow(2, retryCount)); - logger.info(`[Gemini API] Received 429 (Too Many Requests) during stream. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, delay)); - yield* this.streamApi(method, body, isRetry, retryCount + 1, model); - return; - } - - // Handle other retryable errors (5xx server errors) - if (status >= 500 && status < 600 && retryCount < maxRetries) { - const delay = baseDelay * Math.pow(2, retryCount); - logger.info(`[Gemini API] Received ${status} server error during stream. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, delay)); - yield* this.streamApi(method, body, isRetry, retryCount + 1, model); - return; - } - - // Handle network errors (ECONNRESET, ETIMEDOUT, etc.) with exponential backoff - if (isNetworkError && retryCount < maxRetries) { - const delay = baseDelay * Math.pow(2, retryCount); - const errorIdentifier = errorCode || errorMessage.substring(0, 50); - logger.info(`[Gemini API] Network error (${errorIdentifier}) during stream. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, delay)); - yield* this.streamApi(method, body, isRetry, retryCount + 1, model); - return; - } - - throw error; - } - } - - async * parseSSEStream(stream) { - const rl = readline.createInterface({ input: stream, crlfDelay: Infinity }); - let buffer = []; - for await (const line of rl) { - if (line.startsWith("data: ")) buffer.push(line.slice(6)); - else if (line === "" && buffer.length > 0) { - try { yield JSON.parse(buffer.join('\n')); } catch (e) { logger.error("[Stream] Failed to parse JSON chunk:", buffer.join('\n')); } - buffer = []; - } - } - if (buffer.length > 0) { - try { yield JSON.parse(buffer.join('\n')); } catch (e) { logger.error("[Stream] Failed to parse final JSON chunk:", buffer.join('\n')); } - } - } - - async generateContent(model, requestBody) { - logger.info(`[Auth Token] Time until expiry: ${formatExpiryTime(this.authClient.credentials.expiry_date)}`); - - // 临时存储 monitorRequestId - if (requestBody._monitorRequestId) { - this.config._monitorRequestId = requestBody._monitorRequestId; - delete requestBody._monitorRequestId; - } - if (requestBody._requestBaseUrl) { - delete requestBody._requestBaseUrl; - } - - // 检查 token 是否即将过期,如果是则推送到刷新队列 - if (this.isExpiryDateNear()) { - const poolManager = getProviderPoolManager(); - if (poolManager && this.uuid) { - logger.info(`[Gemini] Token is near expiry, marking credential ${this.uuid} for refresh`); - poolManager.markProviderNeedRefresh(MODEL_PROVIDER.GEMINI_CLI, { - uuid: this.uuid - }); - } - } - - let baseModel = model; - if (!GEMINI_MODELS.includes(model)) { - logger.warn(`[Gemini] Model '${model}' not found. Using default model: '${GEMINI_MODELS[0]}'`); - baseModel = GEMINI_MODELS[0]; - } - - const processedRequestBody = ensureRolesInContents({ ...requestBody }); - const apiRequest = { model: baseModel, project: this.projectId, request: processedRequestBody }; - - const response = await this.callApi(API_ACTIONS.GENERATE_CONTENT, apiRequest, false, 0, baseModel); - return toGeminiApiResponse(response.response); - } - - async * generateContentStream(model, requestBody) { - logger.info(`[Auth Token] Time until expiry: ${formatExpiryTime(this.authClient.credentials.expiry_date)}`); - - // 临时存储 monitorRequestId - if (requestBody._monitorRequestId) { - this.config._monitorRequestId = requestBody._monitorRequestId; - delete requestBody._monitorRequestId; - } - if (requestBody._requestBaseUrl) { - delete requestBody._requestBaseUrl; - } - - // 检查 token 是否即将过期,如果是则推送到刷新队列 - if (this.isExpiryDateNear()) { - const poolManager = getProviderPoolManager(); - if (poolManager && this.uuid) { - logger.info(`[Gemini] Token is near expiry, marking credential ${this.uuid} for refresh`); - poolManager.markProviderNeedRefresh(MODEL_PROVIDER.GEMINI_CLI, { - uuid: this.uuid - }); - } - } - - // 检查是否为防截断模型 - if (is_anti_truncation_model(model)) { - // 从防截断模型名中提取实际模型名 - const actualModel = extract_model_from_anti_model(model); - // 使用防截断流处理 - const processedRequestBody = ensureRolesInContents({ ...requestBody }); - yield* apply_anti_truncation_to_stream(this, actualModel, processedRequestBody); - return; - } - - let baseModel = model; - if (!GEMINI_MODELS.includes(model)) { - logger.warn(`[Gemini] Model '${model}' not found. Using default model: '${GEMINI_MODELS[0]}'`); - baseModel = GEMINI_MODELS[0]; - } - - const processedRequestBody = ensureRolesInContents({ ...requestBody }); - const apiRequest = { model: baseModel, project: this.projectId, request: processedRequestBody }; - - const stream = this.streamApi(API_ACTIONS.STREAM_GENERATE_CONTENT, apiRequest, false, 0, baseModel); - for await (const chunk of stream) { - yield toGeminiApiResponse(chunk.response); - } - } - - /** - * Checks if the given expiry date is within the next 10 minutes from now. - * @returns {boolean} True if the expiry date is within the next 10 minutes, false otherwise. - */ - isExpiryDateNear() { - try { - const nearMinutes = 20; - const { message, isNearExpiry } = formatExpiryLog('Gemini', this.authClient.credentials.expiry_date, nearMinutes); - logger.info(message); - return isNearExpiry; - } catch (error) { - logger.error(`[Gemini] Error checking expiry date: ${error.message}`); - return false; - } - } - - isTokenExpiringSoon() { - if (!this.authClient.credentials.expiry_date) { - return false; - } - const currentTime = Date.now(); - const expiryTime = this.authClient.credentials.expiry_date; - const REFRESH_SKEW = 3000; // 50分钟 - const refreshSkewMs = REFRESH_SKEW * 1000; - return expiryTime <= (currentTime + refreshSkewMs); - } - - /** - * 保存凭证到文件 - * @param {string} filePath - 凭证文件路径 - * @param {Object} credentials - 凭证数据 - */ - async _saveCredentialsToFile(filePath, credentials) { - try { - await fs.writeFile(filePath, JSON.stringify(credentials, null, 2)); - logger.info(`[Gemini Auth] Credentials saved to ${filePath}`); - } catch (error) { - logger.error(`[Gemini Auth] Failed to save credentials to ${filePath}: ${error.message}`); - throw error; - } - } - - /** - * 获取模型配额信息 - * @returns {Promise} 模型配额信息 - */ - async getUsageLimits() { - if (!this.isInitialized) await this.initialize(); - - // 注意:V2 架构下不再在 getUsageLimits 中同步刷新 token - // 如果 token 过期,PoolManager 后台会自动处理 - // if (this.isExpiryDateNear()) { - // logger.info('[Gemini] Token is near expiry, refreshing before getUsageLimits request...'); - // await this.initializeAuth(true); - // } - - try { - const modelsWithQuotas = await this.getModelsWithQuotas(); - return modelsWithQuotas; - } catch (error) { - logger.error('[Gemini] Failed to get usage limits:', error.message); - throw error; - } - } - - /** - * 获取带配额信息的模型列表 - * @returns {Promise} 模型配额信息 - */ - async getModelsWithQuotas() { - try { - // 解析模型配额信息 - const result = { - lastUpdated: Date.now(), - models: {} - }; - - // 调用 retrieveUserQuota 接口获取用户配额信息 - try { - const quotaURL = `${this.codeAssistEndpoint}/${this.apiVersion}:retrieveUserQuota`; - const requestBody = { - project: `${this.projectId}` - }; - const requestOptions = { - url: quotaURL, - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - responseType: 'json', - body: JSON.stringify(requestBody) - }; - - this._applySidecar(requestOptions); - const res = await this.authClient.request(requestOptions); - // logger.info(`[Gemini] retrieveUserQuota success`, JSON.stringify(res.data)); - if (res.data && res.data.buckets) { - const buckets = res.data.buckets; - - // 遍历 buckets 数组,提取配额信息 - for (const bucket of buckets) { - const modelId = bucket.modelId; - - // 检查模型是否在支持的模型列表中 - if (!GEMINI_MODELS.includes(modelId)) continue; - - const modelInfo = { - remaining: bucket.remainingFraction || 0, - resetTime: bucket.resetTime || null, - resetTimeRaw: bucket.resetTime - }; - - result.models[modelId] = modelInfo; - } - - // 对模型按名称排序 - const sortedModels = {}; - Object.keys(result.models).sort().forEach(key => { - sortedModels[key] = result.models[key]; - }); - result.models = sortedModels; - // logger.info(`[Gemini] Sorted Models:`, sortedModels); - logger.info(`[Gemini] Successfully fetched quotas for ${Object.keys(result.models).length} models`); - } - } catch (fetchError) { - logger.error(`[Gemini] Failed to fetch user quota:`, fetchError.message); - - // 如果 retrieveUserQuota 失败,回退到使用固定模型列表 - for (const modelId of GEMINI_MODELS) { - result.models[modelId] = { - remaining: 0, - resetTime: null, - resetTimeRaw: null - }; - } - } - - return result; - } catch (error) { - logger.error('[Gemini] Failed to get models with quotas:', error.message); - throw error; - } - } -} - diff --git a/src/providers/gemini/gemini-strategy.js b/src/providers/gemini/gemini-strategy.js deleted file mode 100644 index 6b2071df1d94b622fcb70608b6c700c21fce376c..0000000000000000000000000000000000000000 --- a/src/providers/gemini/gemini-strategy.js +++ /dev/null @@ -1,71 +0,0 @@ -import { API_ACTIONS, extractSystemPromptFromRequestBody, MODEL_PROTOCOL_PREFIX } from '../../utils/common.js'; -import logger from '../../utils/logger.js'; -import { ProviderStrategy } from '../../utils/provider-strategy.js'; - -/** - * Gemini provider strategy implementation. - */ -class GeminiStrategy extends ProviderStrategy { - extractModelAndStreamInfo(req, requestBody) { - const requestUrl = new URL(req.url, `http://${req.headers.host}`); - const urlPattern = new RegExp(`/v1beta/models/(.+?):(${API_ACTIONS.GENERATE_CONTENT}|${API_ACTIONS.STREAM_GENERATE_CONTENT})`); - const urlMatch = requestUrl.pathname.match(urlPattern); - const [, urlmodel, action] = urlMatch; - const model = urlmodel; - const isStream = action === API_ACTIONS.STREAM_GENERATE_CONTENT; - return { model, isStream }; - } - - extractResponseText(response) { - if (response.candidates && response.candidates.length > 0) { - const candidate = response.candidates[0]; - if (candidate.content && candidate.content.parts && candidate.content.parts.length > 0) { - return candidate.content.parts.map(part => part.text).join(''); - } - } - return ''; - } - - extractPromptText(requestBody) { - if (requestBody.contents && requestBody.contents.length > 0) { - const lastContent = requestBody.contents[requestBody.contents.length - 1]; - if (lastContent.parts && lastContent.parts.length > 0) { - return lastContent.parts.map(part => part.text).join(''); - } - } - return ''; - } - - async applySystemPromptFromFile(config, requestBody) { - if (!config.SYSTEM_PROMPT_FILE_PATH) { - return requestBody; - } - - const filePromptContent = config.SYSTEM_PROMPT_CONTENT; - if (filePromptContent === null) { - return requestBody; - } - - const existingSystemText = extractSystemPromptFromRequestBody(requestBody, MODEL_PROTOCOL_PREFIX.GEMINI); - - const newSystemText = config.SYSTEM_PROMPT_MODE === 'append' && existingSystemText - ? `${existingSystemText}\n${filePromptContent}` - : filePromptContent; - - requestBody.systemInstruction = { parts: [{ text: newSystemText }] }; - if (requestBody.system_instruction) { - delete requestBody.system_instruction; - } - logger.info(`[System Prompt] Applied system prompt from ${config.SYSTEM_PROMPT_FILE_PATH} in '${config.SYSTEM_PROMPT_MODE}' mode for provider 'gemini'.`); - - return requestBody; - } - - async manageSystemPrompt(requestBody) { - const incomingSystemText = extractSystemPromptFromRequestBody(requestBody, MODEL_PROTOCOL_PREFIX.GEMINI); - await this._updateSystemPromptFile(incomingSystemText, MODEL_PROTOCOL_PREFIX.GEMINI); - } -} - -export { GeminiStrategy }; - diff --git a/src/providers/grok/grok-core.js b/src/providers/grok/grok-core.js deleted file mode 100644 index 50c64333dadfcc052cfa730deb6a1204aa8efa6c..0000000000000000000000000000000000000000 --- a/src/providers/grok/grok-core.js +++ /dev/null @@ -1,617 +0,0 @@ -import axios from 'axios'; -import logger from '../../utils/logger.js'; -import * as http from 'http'; -import * as https from 'https'; -import { v4 as uuidv4 } from 'uuid'; -import { MODEL_PROTOCOL_PREFIX } from '../../utils/common.js'; -import { getProviderModels } from '../provider-models.js'; -import { configureAxiosProxy, configureTLSSidecar } from '../../utils/proxy-utils.js'; -import { MODEL_PROVIDER } from '../../utils/common.js'; -import { ConverterFactory } from '../../converters/ConverterFactory.js'; -import * as readline from 'readline'; -import { getProviderPoolManager } from '../../services/service-manager.js'; - -// Chrome 136 TLS cipher suites -const CHROME_CIPHERS = [ - 'TLS_AES_128_GCM_SHA256', 'TLS_AES_256_GCM_SHA384', 'TLS_CHACHA20_POLY1305_SHA256', - 'ECDHE-ECDSA-AES128-GCM-SHA256', 'ECDHE-RSA-AES128-GCM-SHA256', 'ECDHE-ECDSA-AES256-GCM-SHA384', - 'ECDHE-RSA-AES256-GCM-SHA384', 'ECDHE-ECDSA-CHACHA20-POLY1305', 'ECDHE-RSA-CHACHA20-POLY1305', - 'ECDHE-RSA-AES128-SHA', 'ECDHE-RSA-AES256-SHA', 'AES128-GCM-SHA256', 'AES256-GCM-SHA384', - 'AES128-SHA', 'AES256-SHA', -].join(':'); - -const CHROME_SIGALGS = [ - 'ecdsa_secp256r1_sha256', 'rsa_pss_rsae_sha256', 'rsa_pkcs1_sha256', - 'ecdsa_secp384r1_sha384', 'rsa_pss_rsae_sha384', 'rsa_pkcs1_sha384', - 'rsa_pss_rsae_sha512', 'rsa_pkcs1_sha512', -].join(':'); - -const httpAgent = new http.Agent({ keepAlive: true, maxSockets: 100, maxFreeSockets: 5, timeout: 120000 }); -const httpsAgent = new https.Agent({ - keepAlive: true, maxSockets: 100, maxFreeSockets: 5, timeout: 120000, - ciphers: CHROME_CIPHERS, sigalgs: CHROME_SIGALGS, minVersion: 'TLSv1.2', maxVersion: 'TLSv1.3', - ALPNProtocols: ['http/1.1'], ecdhCurve: 'X25519:P-256:P-384', honorCipherOrder: false, sessionTimeout: 300, -}); - -const CORE_MODEL_MAPPING = { - 'grok-3': { name: 'grok-3', mode: 'MODEL_MODE_GROK_3' }, - 'grok-3-mini': { name: 'grok-3', mode: 'MODEL_MODE_GROK_3_MINI_THINKING' }, - 'grok-3-thinking': { name: 'grok-3', mode: 'MODEL_MODE_GROK_3_THINKING' }, - 'grok-4': { name: 'grok-4', mode: 'MODEL_MODE_GROK_4' }, - 'grok-4-mini': { name: 'grok-4-mini', mode: 'MODEL_MODE_GROK_4_MINI_THINKING' }, - 'grok-4-thinking': { name: 'grok-4', mode: 'MODEL_MODE_GROK_4_THINKING' }, - 'grok-4-heavy': { name: 'grok-4', mode: 'MODEL_MODE_HEAVY' }, - 'grok-4.1-mini': { name: 'grok-4-1-thinking-1129', mode: 'MODEL_MODE_GROK_4_1_MINI_THINKING' }, - 'grok-4.1-fast': { name: 'grok-4-1-thinking-1129', mode: 'MODEL_MODE_FAST' }, - 'grok-4.1-expert': { name: 'grok-4-1-thinking-1129', mode: 'MODEL_MODE_EXPERT' }, - 'grok-4.1-thinking': { name: 'grok-4-1-thinking-1129', mode: 'MODEL_MODE_GROK_4_1_THINKING' }, - 'grok-4.20-beta': { name: 'grok-420', mode: 'MODEL_MODE_GROK_420' }, - 'grok-imagine-1.0': { name: 'grok-3', mode: 'MODEL_MODE_FAST' }, - 'grok-imagine-1.0-edit': { name: 'imagine-image-edit', mode: 'MODEL_MODE_FAST' }, - 'grok-imagine-1.0-video': { name: 'grok-3', mode: 'MODEL_MODE_FAST' } -}; - -const MODEL_MAPPING = { ...CORE_MODEL_MAPPING }; -Object.keys(CORE_MODEL_MAPPING).forEach(key => { - if (!key.endsWith('-nsfw')) { - MODEL_MAPPING[`${key}-nsfw`] = CORE_MODEL_MAPPING[key]; - } -}); - -const GROK_MODELS = Object.keys(MODEL_MAPPING); - -function isGrokNsfwModel(modelId) { - return typeof modelId === 'string' && modelId.toLowerCase().endsWith('-nsfw'); -} - -function normalizeGrokModelId(modelId) { - if (typeof modelId !== 'string') return modelId; - return isGrokNsfwModel(modelId) ? modelId.slice(0, -5) : modelId; -} - -export class GrokApiService { - constructor(config) { - this.config = config; - this.uuid = config.uuid; - this.token = config.GROK_COOKIE_TOKEN; - this.cfClearance = config.GROK_CF_CLEARANCE; - this.userAgent = config.GROK_USER_AGENT || 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36'; - this.baseUrl = config.GROK_BASE_URL || 'https://grok.com'; - this.chatApi = `${this.baseUrl}/rest/app-chat/conversations/new`; - this.isInitialized = false; - this.nsfwSetupDone = false; - this.converter = ConverterFactory.getConverter(MODEL_PROTOCOL_PREFIX.GROK); - if (this.converter && this.uuid) this.converter.setUuid(this.uuid); - this.lastSyncAt = null; - } - - async setupNsfw() { - if (this.nsfwSetupDone) return; - try { - await this.acceptTos(); - await this.setBirthDate(); - await this.enableNsfwAccount(); - this.nsfwSetupDone = true; - logger.info(`[Grok NSFW] Account-level NSFW setup completed for ${this.uuid}`); - } catch (error) { - logger.warn(`[Grok NSFW] Failed to setup account-level NSFW: ${error.message}`); - } - } - - async acceptTos() { - const axiosConfig = { method: 'post', url: `${this.baseUrl}/rest/app-chat/accept-tos`, headers: this.buildHeaders(), data: {}, httpAgent, httpsAgent, timeout: 15000 }; - configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM); - this._applySidecar(axiosConfig); - try { await axios(axiosConfig); } catch (e) { logger.debug(`[Grok TOS] ${e.message}`); } - } - - async setBirthDate() { - const axiosConfig = { method: 'post', url: `${this.baseUrl}/rest/app-chat/set-birth-date`, headers: this.buildHeaders(), data: { "birthDate": "1990-01-01" }, httpAgent, httpsAgent, timeout: 15000 }; - configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM); - this._applySidecar(axiosConfig); - try { await axios(axiosConfig); } catch (e) { logger.debug(`[Grok Birth] ${e.message}`); } - } - - async enableNsfwAccount() { - const name = Buffer.from("always_show_nsfw_content"); - const inner = Buffer.concat([Buffer.from([0x0a, name.length]), name]); - const protobuf = Buffer.concat([Buffer.from([0x0a, 0x02, 0x10, 0x01, 0x12, inner.length]), inner]); - - const header = Buffer.alloc(5); - header.writeUInt8(0, 0); - header.writeUInt32BE(protobuf.length, 1); - const payload = Buffer.concat([header, protobuf]); - - const headers = this.buildHeaders(); - headers['content-type'] = 'application/grpc-web+proto'; - headers['x-grpc-web'] = '1'; - headers['x-user-agent'] = 'connect-es/2.1.1'; - headers['referer'] = `${this.baseUrl}/?_s=data`; - - const axiosConfig = { - method: 'post', - url: `${this.baseUrl}/auth_mgmt.AuthManagement/UpdateUserFeatureControls`, - headers, - data: payload, - httpAgent, - httpsAgent, - timeout: 15000, - responseType: 'arraybuffer' - }; - configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM); - this._applySidecar(axiosConfig); - try { await axios(axiosConfig); } catch (e) { throw e; } - } - - _applySidecar(axiosConfig) { - return configureTLSSidecar(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM); - } - - async initialize() { - if (this.isInitialized) return; - this.isInitialized = true; - this.getUsageLimits() - .catch((error) => { - logger.warn('[Grok] Initial usage sync failed:', error.message); - }); - } - - async refreshToken() { - try { await this.getUsageLimits(); return Promise.resolve(); } catch (error) { return Promise.reject(error); } - } - - async getUsageLimits() { - const headers = this.buildHeaders(); - const payload = { "requestKind": "DEFAULT", "modelName": "grok-3" }; - const axiosConfig = { method: 'post', url: `${this.baseUrl}/rest/rate-limits`, headers, data: payload, httpAgent, httpsAgent, timeout: 30000 }; - configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM); - this._applySidecar(axiosConfig); - try { - const response = await axios(axiosConfig); - const data = response.data; - let remaining = data.remainingTokens !== undefined ? data.remainingTokens : (data.remainingQueries !== undefined ? data.remainingQueries : data.totalQueries); - if (data.totalQueries > 0) { - data.totalLimit = data.totalQueries; - data.usedQueries = Math.max(0, data.totalQueries - (data.remainingQueries || 0)); - data.unit = 'queries'; - } else { - data.totalLimit = data.totalTokens || 0; - data.usedQueries = Math.max(0, (data.totalTokens || 0) - (data.remainingTokens || 0)); - data.unit = 'tokens'; - } - this.lastSyncAt = Date.now(); - this.config.usageData = data; - this.config.lastHealthCheckTime = new Date().toISOString(); - return { lastUpdated: this.lastSyncAt, remaining, ...data }; - } catch (error) { throw error; } - } - - isExpiryDateNear() { - if (!this.lastSyncAt) return true; - return (Date.now() - this.lastSyncAt) > (this.config.CRON_NEAR_MINUTES || 15) * 60 * 1000; - } - - genStatsigId() { - const randomString = (len, alpha = false) => { - const chars = alpha ? 'abcdefghijklmnopqrstuvwxyz0123456789' : 'abcdefghijklmnopqrstuvwxyz'; - let res = ''; - for (let i = 0; i < len; i++) res += chars[Math.floor(Math.random() * chars.length)]; - return res; - }; - const msg = Math.random() < 0.5 ? `e:TypeError: Cannot read properties of null (reading 'children['${randomString(5, true)}']')` : `e:TypeError: Cannot read properties of undefined (reading '${randomString(10)}')`; - return Buffer.from(msg).toString('base64'); - } - - buildHeaders() { - let ssoToken = this.token || ""; - if (ssoToken.startsWith("sso=")) ssoToken = ssoToken.substring(4); - const cookie = ssoToken ? [`sso=${ssoToken}`, `sso-rw=${ssoToken}`] : []; - if (this.cfClearance) cookie.push(`cf_clearance=${this.cfClearance}`); - return { - 'accept': '*/*', - 'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8,ja;q=0.7', - 'content-type': 'application/json', - 'cookie': cookie.join('; '), - 'origin': this.baseUrl, - 'priority': 'u=1, i', - 'referer': `${this.baseUrl}/`, - 'sec-ch-ua': '"Google Chrome";v="143", "Chromium";v="143", "Not A(Brand";v="24"', - 'sec-ch-ua-arch': '"x86"', - 'sec-ch-ua-bitness': '"64"', - 'sec-ch-ua-mobile': '?0', - 'sec-ch-ua-platform': '"Windows"', - 'user-agent': this.userAgent, - 'x-statsig-id': this.genStatsigId(), - 'x-xai-request-id': uuidv4() - }; - } - - _extractPostId(text) { - if (!text || typeof text !== 'string') return null; - const match = text.match(/\/post\/([0-9a-fA-F-]{32,36})/) || - text.match(/\/generated\/([0-9a-fA-F-]{32,36})\//) || - text.match(/\/([0-9a-fA-F-]{32,36})\/generated_video/); - return match ? match[1] : null; - } - - async createPost(mediaType, mediaUrl = null, prompt = null) { - const headers = this.buildHeaders(); - headers['referer'] = `${this.baseUrl}/imagine`; - - // 严格遵循成功示例的载荷结构 - const payload = { mediaType }; - if (prompt && prompt.trim()) payload.prompt = prompt; - if (mediaUrl && mediaUrl.trim()) payload.mediaUrl = mediaUrl; - - const axiosConfig = { method: 'post', url: `${this.baseUrl}/rest/media/post/create`, headers, data: payload, httpAgent, httpsAgent, timeout: 30000 }; - configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM); - this._applySidecar(axiosConfig); - try { - const response = await axios(axiosConfig); - const postId = response.data?.post?.id; - if (postId) logger.info(`[Grok Post] Media post created: ${postId} (type=${mediaType})`); - return postId; - } catch (error) { - const detail = error.response?.data ? JSON.stringify(error.response.data) : error.message; - logger.error(`[Grok Post] Failed to create media post: ${detail}`); - return null; - } - } - - async upscaleVideo(videoUrl) { - if (!videoUrl) return videoUrl; - const idMatch = videoUrl.match(/\/generated\/([0-9a-fA-F-]{32,36})\//) || videoUrl.match(/\/([0-9a-fA-F-]{32,36})\/generated_video/); - if (!idMatch) return videoUrl; - const videoId = idMatch[1]; - const axiosConfig = { method: 'post', url: `${this.baseUrl}/rest/media/video/upscale`, headers: this.buildHeaders(), data: { videoId }, httpAgent, httpsAgent, timeout: 30000 }; - configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM); - this._applySidecar(axiosConfig); - try { - const response = await axios(axiosConfig); - return response.data?.hdMediaUrl || videoUrl; - } catch (error) { return videoUrl; } - } - - async createVideoShareLink(postId) { - logger.info(`[Grok Video Link] Entering createVideoShareLink with postId: ${postId}`); - if (!postId) return null; - const headers = this.buildHeaders(); - headers['referer'] = `${this.baseUrl}/imagine/post/${postId}`; - const payload = { - "postId": postId, - "source": "post-page", - "platform": "web" - }; - const axiosConfig = { - method: 'post', - url: `${this.baseUrl}/rest/media/post/create-link`, - headers, - data: payload, - httpAgent, - httpsAgent, - timeout: 15000 - }; - configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM); - this._applySidecar(axiosConfig); - try { - const response = await axios(axiosConfig); - const shareLink = response.data?.shareLink; - if (shareLink) { - // 从 shareLink 中提取 ID (通常与输入的 postId 一致) - const idMatch = shareLink.match(/\/post\/([0-9a-fA-F-]{36}|[0-9a-fA-F]{32})/); - const resourceId = idMatch ? idMatch[1] : postId; - - // 构造公开的视频资源地址 - const resourceUrl = `https://imagine-public.x.ai/imagine-public/share-videos/${resourceId}.mp4?cache=1`; - - logger.info(`[Grok Video Link] Public resource created for post ${postId}: ${resourceUrl}`); - return resourceUrl; - } - return null; - } catch (error) { - const detail = error.response?.data ? JSON.stringify(error.response.data) : error.message; - logger.warn(`[Grok Video Link] Failed to create share link for ${postId}: ${detail}`); - return null; - } - } - - buildPayload(modelId, requestBody) { - if (requestBody && Object.prototype.hasOwnProperty.call(requestBody, 'tools')) { - delete requestBody.tools; - } - - const rawModelId = typeof modelId === 'string' ? modelId : ''; - const normalizedModelId = normalizeGrokModelId(rawModelId); - const mapping = MODEL_MAPPING[normalizedModelId] || MODEL_MAPPING['grok-3']; - let message = requestBody.message || ""; - let toolOverrides = requestBody.toolOverrides || {}; - let fileAttachments = requestBody.fileAttachments || []; - let modelConfigOverride = requestBody.responseMetadata?.modelConfigOverride || {}; - - if (requestBody.messages && Array.isArray(requestBody.messages)) { - let processedMessages = requestBody.messages; - if (requestBody.tools?.length > 0) processedMessages = this.converter.formatToolHistory(requestBody.messages); - const toolPrompt = this.converter.buildToolPrompt(requestBody.tools, requestBody.tool_choice); - if (requestBody.tools && Object.keys(toolOverrides).length === 0) toolOverrides = this.converter.buildToolOverrides(requestBody.tools); - - const extracted = []; - const imageAttachments = []; - const localFileAttachments = []; - - for (const msg of processedMessages) { - const role = msg.role || "user"; - const content = msg.content; - const parts = []; - if (typeof content === 'string') { if (content.trim()) parts.push(content.trim()); } - else if (Array.isArray(content)) { - for (const item of content) { - if (item.type === 'text' && item.text?.trim()) parts.push(item.text.trim()); - else if (item.type === 'image_url' && item.image_url?.url) imageAttachments.push(item.image_url.url); - else if (item.type === 'file' && item.file?.file_data) localFileAttachments.push(item.file.file_data); - } - } - if (role === "assistant" && parts.length === 0 && Array.isArray(msg.tool_calls)) { - for (const call of msg.tool_calls) { - const fn = call.function || {}; - parts.push(`[tool_call] ${fn.name || call.name} ${typeof fn.arguments === 'string' ? fn.arguments : JSON.stringify(fn.arguments)}`); - } - } - if (parts.length > 0) extracted.push({ role, text: parts.join("\n") }); - } - - let lastUserIdx = -1; - for (let i = extracted.length - 1; i >= 0; i--) { if (extracted[i].role === 'user') { lastUserIdx = i; break; } } - const texts = extracted.map((item, i) => i === lastUserIdx ? item.text : `${item.role}: ${item.text}`); - message = texts.join("\n\n"); - if (toolPrompt) message = `${toolPrompt}\n\n${message}`; - if (!message.trim() && (imageAttachments.length || localFileAttachments.length)) message = "Refer to the following content:"; - requestBody._extractedImages = imageAttachments; - requestBody._extractedFiles = localFileAttachments; - } - - if (requestBody.videoGenModelConfig) { - modelConfigOverride.modelMap = { videoGenModelConfig: requestBody.videoGenModelConfig }; - toolOverrides.videoGen = true; - if (requestBody.videoGenPrompt) message = requestBody.videoGenPrompt; - } - - const modelLower = normalizedModelId.toLowerCase(); - const isMediaModel = modelLower.includes('imagine') || modelLower.includes('video') || modelLower.includes('edit'); - const isNsfw = isGrokNsfwModel(rawModelId) || requestBody.nsfw === true || requestBody.disableNsfwFilter === true; - - const payload = { - "deviceEnvInfo": { "darkModeEnabled": false, "devicePixelRatio": 2, "screenWidth": 2056, "screenHeight": 1329, "viewportWidth": 2056, "viewportHeight": 1083 }, - "disableMemory": false, "disableNsfwFilter": isNsfw, "disableSearch": false, "disableSelfHarmShortCircuit": false, "disableTextFollowUps": false, - "enableImageGeneration": isMediaModel, "enableImageStreaming": isMediaModel, "enableSideBySide": true, - "fileAttachments": fileAttachments, "forceConcise": false, "forceSideBySide": false, "imageAttachments": [], "imageGenerationCount": 2, - "isAsyncChat": false, "isReasoning": false, "message": message, "modelMode": mapping.mode, "modelName": mapping.name, - "responseMetadata": { "requestModelDetails": { "modelId": mapping.name }, "modelConfigOverride": modelConfigOverride }, - "returnImageBytes": false, "returnRawGrokInXaiRequest": false, "sendFinalMetadata": true, "temporary": true, "toolOverrides": toolOverrides, - }; - - if (isMediaModel && !modelLower.includes('video')) { - payload.enable_nsfw = isNsfw; - if (requestBody.aspect_ratio || requestBody.aspectRatio) { - payload.aspect_ratio = requestBody.aspect_ratio || requestBody.aspectRatio; - } - } - - return payload; - } - - async generateContent(model, requestBody) { - logger.info(`[Grok] Starting generateContent (unified processing)`); - const stream = this.generateContentStream(model, requestBody); - const collected = { message: "", responseId: "", postId: "", llmInfo: {}, rolloutId: "", modelResponse: null, cardAttachment: null, streamingImageGenerationResponse: null, streamingVideoGenerationResponse: null, finalVideoUrl: null, finalThumbnailUrl: null }; - - for await (const chunk of stream) { - const resp = chunk.result?.response; - if (!resp) continue; - if (resp.token) collected.message += resp.token; - if (resp.responseId) collected.responseId = resp.responseId; - if (resp.llmInfo) Object.assign(collected.llmInfo, resp.llmInfo); - if (resp.rolloutId) collected.rolloutId = resp.rolloutId; - if (resp._requestBaseUrl) collected._requestBaseUrl = resp._requestBaseUrl; - if (resp._uuid) collected._uuid = resp._uuid; - if (resp.modelResponse) collected.modelResponse = resp.modelResponse; - if (resp.cardAttachment) collected.cardAttachment = resp.cardAttachment; - if (resp.streamingImageGenerationResponse) { - collected.streamingImageGenerationResponse = resp.streamingImageGenerationResponse; - } - if (resp.streamingVideoGenerationResponse) { - collected.streamingVideoGenerationResponse = resp.streamingVideoGenerationResponse; - if (resp.streamingVideoGenerationResponse.postId) collected.postId = resp.streamingVideoGenerationResponse.postId; - if (resp.streamingVideoGenerationResponse.progress === 100 && resp.streamingVideoGenerationResponse.videoUrl) { - collected.finalVideoUrl = resp.streamingVideoGenerationResponse.videoUrl; - collected.finalThumbnailUrl = resp.streamingVideoGenerationResponse.thumbnailImageUrl; - } - } - } - - logger.info(`[Grok] Finalizing collection. model: ${model}, respId: ${collected.responseId}, videoPostId: ${collected.postId}`); - - // 1. 仅针对视频进行 postId 提取和分享链接创建 - const isVideo = !!(collected.finalVideoUrl || collected.streamingVideoGenerationResponse || model.toLowerCase().includes('video')); - logger.info(`[Grok Decision] isVideo detected: ${isVideo}. (finalUrl: ${!!collected.finalVideoUrl}, streamResp: ${!!collected.streamingVideoGenerationResponse}, modelIncludeVideo: ${model.toLowerCase().includes('video')})`); - - if (isVideo && !collected.postId) { - if (collected.finalVideoUrl) { - collected.postId = this._extractPostId(collected.finalVideoUrl); - logger.info(`[Grok Decision] PostId extracted from finalVideoUrl: ${collected.postId}`); - } - if (!collected.postId && collected.message) { - collected.postId = this._extractPostId(collected.message); - logger.info(`[Grok Decision] PostId extracted from message text: ${collected.postId}`); - } - } - - // 2. 仅在确实是视频且有 postId 时,处理视频分享链接 (createVideoShareLink) - if (isVideo && collected.postId) { - logger.info(`[Grok Decision] Calling createVideoShareLink...`); - const shareUrl = await this.createVideoShareLink(collected.postId); - if (shareUrl) { - logger.info(`[Grok Video Result] ShareUrl created: ${shareUrl}. Replacing links...`); - if (collected.finalVideoUrl) collected.finalVideoUrl = shareUrl; - if (collected.streamingVideoGenerationResponse) collected.streamingVideoGenerationResponse.videoUrl = shareUrl; - - if (collected.message) { - const grokLinkRegex = /https?:\/\/grok\.com\/imagine\/post\/([0-9a-fA-F-]{32,36})/g; - collected.message = collected.message.replace(grokLinkRegex, shareUrl); - } - } else { - logger.warn(`[Grok Video Result] createVideoShareLink returned NULL for ${collected.postId}`); - } - } else if (isVideo) { - logger.warn(`[Grok Video Skip] isVideo is TRUE but NO postId found to create share link.`); - } - - return collected; - } - - async uploadFile(fileInput) { - let b64 = "", mime = "application/octet-stream"; - if (fileInput.startsWith("data:")) { - const match = fileInput.match(/^data:([^;]+);base64,(.*)$/); - if (match) { mime = match[1]; b64 = match[2]; } - } - if (!b64) return null; - const axiosConfig = { method: 'post', url: `${this.baseUrl}/rest/app-chat/upload-file`, headers: this.buildHeaders(), data: { fileName: `file.${mime.split("/")[1] || "bin"}`, fileMimeType: mime, content: b64 }, httpAgent, httpsAgent, timeout: 30000 }; - configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM); - this._applySidecar(axiosConfig); - try { return (await axios(axiosConfig)).data; } catch (error) { return null; } - } - - async * generateContentStream(model, requestBody) { - if (this.converter) { - if (this.uuid) this.converter.setUuid(this.uuid); - if (requestBody._requestBaseUrl) this.converter.setRequestBaseUrl(requestBody._requestBaseUrl); - } - - if (requestBody._monitorRequestId) { this.config._monitorRequestId = requestBody._monitorRequestId; delete requestBody._monitorRequestId; } - const reqBaseUrl = requestBody._requestBaseUrl; - if (requestBody._requestBaseUrl) delete requestBody._requestBaseUrl; - - if (this.isExpiryDateNear() && getProviderPoolManager() && this.uuid) { - getProviderPoolManager().markProviderNeedRefresh(MODEL_PROVIDER.GROK_CUSTOM, { uuid: this.uuid }); - } - - const rawModel = typeof model === 'string' ? model : ''; - const normalizedModel = normalizeGrokModelId(rawModel); - const modelLower = normalizedModel.toLowerCase(); - const isNsfw = isGrokNsfwModel(rawModel) || requestBody.nsfw === true || requestBody.disableNsfwFilter === true; - if (isNsfw) await this.setupNsfw(); - - this.buildPayload(model, requestBody); - - const isVideoModel = modelLower.includes('video'); - const isImageModel = modelLower.includes('imagine') && !isVideoModel && !modelLower.includes('edit'); - const isImageEditModel = modelLower.includes('edit'); - - if (isVideoModel) { - const videoConfig = requestBody.videoGenModelConfig || {}; - const aspectRatio = requestBody.aspect_ratio || requestBody.aspectRatio || videoConfig.aspectRatio || "3:2"; - const videoLength = parseInt(requestBody.video_length || requestBody.videoLength || videoConfig.videoLength || 6); - const resolutionName = requestBody.resolution_name || requestBody.resolution || videoConfig.resolutionName || "480p"; - const preset = requestBody.preset || "normal"; - let parentPostId = videoConfig.parentPostId; - - if (!parentPostId) { - // 修复:从 requestBody.message 或 messages 数组中提取 prompt - let prompt = requestBody.videoGenPrompt || requestBody.message; - if (!prompt && requestBody.messages?.length > 0) { - const lastMsg = requestBody.messages[requestBody.messages.length - 1]; - if (typeof lastMsg.content === 'string') { - prompt = lastMsg.content; - } else if (Array.isArray(lastMsg.content)) { - const textPart = lastMsg.content.find(p => p.type === 'text'); - if (textPart) prompt = textPart.text; - } - } - prompt = prompt || ""; - - let lastMsgImages = []; - if (requestBody.messages?.length > 0) { - const lastMsg = requestBody.messages[requestBody.messages.length - 1]; - if (lastMsg.role === 'user' && Array.isArray(lastMsg.content)) { - lastMsg.content.forEach(item => { if (item.type === 'image_url' && item.image_url?.url) lastMsgImages.push(item.image_url.url); }); - } - } - if (lastMsgImages.length > 0) { - let mediaUrl = lastMsgImages[0]; - if (mediaUrl.startsWith('data:') || !mediaUrl.startsWith('http')) { - const up = await this.uploadFile(mediaUrl); - if (up?.fileUri) mediaUrl = `https://assets.grok.com/${up.fileUri}`; - } - parentPostId = await this.createPost("MEDIA_POST_TYPE_VIDEO", mediaUrl); - } else { - parentPostId = await this.createPost("MEDIA_POST_TYPE_VIDEO", null, prompt); - } - } - - if (parentPostId) { - requestBody.videoGenModelConfig = { aspectRatio, parentPostId, resolutionName, videoLength }; - const modeMap = { "fun": "--mode=extremely-crazy", "normal": "--mode=normal", "spicy": "--mode=extremely-spicy-or-crazy" }; - requestBody.videoGenPrompt = `${requestBody.videoGenPrompt || requestBody.message || ""} ${modeMap[preset] || "--mode=custom"}`; - requestBody.toolOverrides = { ...requestBody.toolOverrides, videoGen: true }; - } - } else if (isImageModel || isImageEditModel) { - requestBody.toolOverrides = { ...requestBody.toolOverrides, imageGen: true }; - } - - let fileAttachments = requestBody.fileAttachments || []; - const toUpload = [...(requestBody._extractedImages || []), ...(requestBody._extractedFiles || [])]; - if (toUpload.length > 0) { - for (const data of toUpload) { - const res = await this.uploadFile(data); - if (res?.fileMetadataId) fileAttachments.push(res.fileMetadataId); - } - requestBody.fileAttachments = fileAttachments; - } - - const payload = this.buildPayload(model, requestBody); - const axiosConfig = { method: 'post', url: this.chatApi, headers: this.buildHeaders(), data: payload, responseType: 'stream', httpAgent, httpsAgent, timeout: 60000, maxRedirects: 0 }; - configureAxiosProxy(axiosConfig, this.config, MODEL_PROVIDER.GROK_CUSTOM); - this._applySidecar(axiosConfig); - - try { - const response = await axios(axiosConfig); - const rl = readline.createInterface({ input: response.data, terminal: false }); - let lastResponseId = payload.responseMetadata?.requestModelDetails?.modelId || "final"; - - for await (const line of rl) { - const trimmed = line.trim(); - if (!trimmed) continue; - let dataStr = trimmed.startsWith('data: ') ? trimmed.slice(6).trim() : trimmed; - if (dataStr === '[DONE]') break; - try { - const json = JSON.parse(dataStr); - if (json.result?.response) { - const resp = json.result.response; - resp._requestBaseUrl = reqBaseUrl; - resp._uuid = this.uuid; - if (resp.responseId) lastResponseId = resp.responseId; - if (resp.streamingVideoGenerationResponse) { - const vid = resp.streamingVideoGenerationResponse; - if (vid.progress === 100 && vid.videoUrl && (requestBody.videoGenModelConfig?.resolutionName === "720p")) { - const hdUrl = await this.upscaleVideo(vid.videoUrl); - if (hdUrl) vid.videoUrl = hdUrl; - } - } - } - yield json; - } catch (e) {} - } - yield { result: { response: { isDone: true, responseId: lastResponseId, _requestBaseUrl: reqBaseUrl, _uuid: this.uuid } } }; - } catch (error) { this.handleApiError(error); } - } - - handleApiError(error) { - const status = error.response?.status; - if (status === 401 || status === 403) { error.shouldSwitchCredential = true; error.message = 'Grok authentication failed (SSO token invalid or expired)'; } - throw error; - } - - async listModels() { - return { data: GROK_MODELS.map(id => ({ id, object: "model", created: Math.floor(Date.now() / 1000), owned_by: "xai", display_name: id.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ') })) }; - } -} diff --git a/src/providers/grok/grok-strategy.js b/src/providers/grok/grok-strategy.js deleted file mode 100644 index 623100a21092880446ae090714b10c18a23484ed..0000000000000000000000000000000000000000 --- a/src/providers/grok/grok-strategy.js +++ /dev/null @@ -1,56 +0,0 @@ -import { API_ACTIONS, MODEL_PROTOCOL_PREFIX } from '../../utils/common.js'; -import logger from '../../utils/logger.js'; -import { ProviderStrategy } from '../../utils/provider-strategy.js'; - -/** - * Grok provider strategy implementation. - */ -class GrokStrategy extends ProviderStrategy { - extractModelAndStreamInfo(req, requestBody) { - // Grok protocol usually used internally, but if exposed: - const model = requestBody.model || 'grok-3'; - const isStream = requestBody.stream !== false; - return { model, isStream }; - } - - extractResponseText(response) { - // From Grok response - return response.message || ''; - } - - extractPromptText(requestBody) { - // From converted Grok request - return requestBody.message || ''; - } - - async applySystemPromptFromFile(config, requestBody) { - if (!config.SYSTEM_PROMPT_FILE_PATH) { - return requestBody; - } - - const filePromptContent = config.SYSTEM_PROMPT_CONTENT; - if (filePromptContent === null) { - return requestBody; - } - - // Grok reverse interface combines system prompt into message - // Here we can prepend it if needed, or handle it during request conversion. - // Since requestBody already contains the converted message, we might need to prepend it here. - - const existingMessage = requestBody.message || ""; - const newSystemText = config.SYSTEM_PROMPT_MODE === 'append' - ? `${existingMessage}\n\nSystem: ${filePromptContent}` - : `System: ${filePromptContent}\n\n${existingMessage}`; - - requestBody.message = newSystemText; - logger.info(`[System Prompt] Applied system prompt for Grok in '${config.SYSTEM_PROMPT_MODE}' mode.`); - - return requestBody; - } - - async manageSystemPrompt(requestBody) { - // Not implemented for Grok yet - } -} - -export { GrokStrategy }; diff --git a/src/providers/openai/codex-core.js b/src/providers/openai/codex-core.js deleted file mode 100644 index 3eceb68b3b2e07191f61709893598cc816279553..0000000000000000000000000000000000000000 --- a/src/providers/openai/codex-core.js +++ /dev/null @@ -1,767 +0,0 @@ -import axios from 'axios'; -import logger from '../../utils/logger.js'; -import crypto from 'crypto'; -import { promises as fs } from 'fs'; -import path from 'path'; -import os from 'os'; -import { refreshCodexTokensWithRetry } from '../../auth/oauth-handlers.js'; -import { getProviderPoolManager } from '../../services/service-manager.js'; -import { configureTLSSidecar } from '../../utils/proxy-utils.js'; -import { MODEL_PROVIDER, formatExpiryLog } from '../../utils/common.js'; -import { getProxyConfigForProvider } from '../../utils/proxy-utils.js'; -import { getProviderModels } from '../provider-models.js'; - -const baseModels = getProviderModels(MODEL_PROVIDER.CODEX_API); -const fastModels = baseModels.map(m => `${m}-fast`); -const CODEX_MODELS = [...new Set([...baseModels, ...fastModels])]; -const CODEX_VERSION = '0.111.0'; - -/** - * Codex API 服务类 - */ -export class CodexApiService { - constructor(config) { - this.config = config; - this.baseUrl = config.CODEX_BASE_URL || 'https://chatgpt.com/backend-api/codex'; - this.accessToken = null; - this.refreshToken = null; - this.accountId = null; - this.email = null; - this.expiresAt = null; - this.idToken = null; - this.last_refresh = null; - this.credsPath = null; // 记录本次加载/使用的凭据文件路径,确保刷新后写回同一文件 - this.uuid = config.uuid; // 保存 uuid 用于号池管理 - this.isInitialized = false; - - // 会话缓存管理 - this.conversationCache = new Map(); // key: model-userId, value: {id, expire} - this.startCacheCleanup(); - } - - _applySidecar(axiosConfig) { - return configureTLSSidecar(axiosConfig, this.config, MODEL_PROVIDER.CODEX_API, this.baseUrl); - } - - /** - * 初始化服务(加载凭据) - */ - async initialize() { - if (this.isInitialized) return; - logger.info('[Codex] Initializing Codex API Service...'); - // 注意:V2 读写分离架构下,初始化不再执行同步认证/刷新逻辑 - // 仅执行基础的凭证加载 - await this.loadCredentials(); - - this.isInitialized = true; - logger.info(`[Codex] Initialization complete. Account: ${this.email || 'unknown'}`); - } - - /** - * 加载凭证信息(不执行刷新) - */ - async loadCredentials() { - const email = this.config.CODEX_EMAIL || 'default'; - - try { - let creds; - let credsPath; - - // 如果指定了具体路径,直接读取 - if (this.config.CODEX_OAUTH_CREDS_FILE_PATH) { - credsPath = this.config.CODEX_OAUTH_CREDS_FILE_PATH; - const exists = await this.fileExists(credsPath); - if (!exists) { - throw new Error('Codex credentials not found. Please authenticate first using OAuth.'); - } - creds = JSON.parse(await fs.readFile(credsPath, 'utf8')); - } else { - // 从 configs/codex 目录扫描加载 - const projectDir = process.cwd(); - const targetDir = path.join(projectDir, 'configs', 'codex'); - const files = await fs.readdir(targetDir); - const matchingFile = files - .filter(f => f.includes(`codex-${email}`) && f.endsWith('.json')) - .sort() - .pop(); // 获取最新的文件 - - if (!matchingFile) { - throw new Error('Codex credentials not found. Please authenticate first using OAuth.'); - } - - credsPath = path.join(targetDir, matchingFile); - creds = JSON.parse(await fs.readFile(credsPath, 'utf8')); - } - - // 记录凭据路径,确保 refresh 时写回同一文件。 - this.credsPath = credsPath; - - this.idToken = creds.id_token || this.idToken; - this.accessToken = creds.access_token; - this.refreshToken = creds.refresh_token; - this.accountId = creds.account_id; - this.email = creds.email; - this.last_refresh = creds.last_refresh || this.last_refresh; - this.expiresAt = new Date(creds.expired); // 注意:字段名是 expired - - // 检查 token 是否需要刷新(异步触发,不阻塞加载) - if (this.isExpiryDateNear()) { - this.triggerBackgroundRefresh(); - } - - this.isInitialized = true; - logger.info(`[Codex] Initialized with account: ${this.email}`); - } catch (error) { - logger.warn(`[Codex Auth] Failed to load credentials: ${error.message}`); - } - } - - /** - * 初始化认证并执行必要刷新 - */ - async initializeAuth(forceRefresh = false) { - // 检查 token 是否需要刷新 - const needsRefresh = forceRefresh; - - if (this.accessToken && !needsRefresh) { - return; - } - - // 首先执行基础凭证加载 - await this.loadCredentials(); - - // 只有在明确要求刷新,或者 AccessToken 缺失时,才执行刷新 - // 注意:在 V2 架构下,此方法主要由 PoolManager 的后台队列调用 - if (needsRefresh || !this.accessToken) { - if (!this.refreshToken) { - throw new Error('Codex credentials not found. Please authenticate first using OAuth.'); - } - logger.info('[Codex] Token expiring soon or refresh requested, refreshing...'); - await this.refreshAccessToken(); - } - } - - /** - * 后台异步刷新 token(不阻塞当前请求) - */ - triggerBackgroundRefresh() { - const poolManager = getProviderPoolManager(); - if (poolManager && this.uuid) { - logger.info(`[Codex] Token is near expiry, marking credential ${this.uuid} for background refresh`); - poolManager.markProviderNeedRefresh(MODEL_PROVIDER.CODEX_API, { - uuid: this.uuid - }); - } - } - - /** - * 生成内容(非流式) - */ - async generateContent(model, requestBody) { - if (!this.isInitialized) { - await this.initialize(); - } - - let selectedModel = model; - if (!CODEX_MODELS.includes(model)) { - const defaultModel = CODEX_MODELS[0] || 'gpt-5'; - logger.warn(`[Codex] Model '${model}' not found in supported list. Falling back to default: '${defaultModel}'`); - selectedModel = defaultModel; - } - - // 临时存储 monitorRequestId - if (requestBody._monitorRequestId) { - this.config._monitorRequestId = requestBody._monitorRequestId; - delete requestBody._monitorRequestId; - } - if (requestBody._requestBaseUrl) { - delete requestBody._requestBaseUrl; - } - - // 检查 token 是否即将过期,如果是则触发后台异步刷新 - if (this.isExpiryDateNear()) { - this.triggerBackgroundRefresh(); - } - - const url = `${this.baseUrl}/responses`; - const body = await this.prepareRequestBody(selectedModel, requestBody, true); - const headers = this.buildHeaders(body.prompt_cache_key, true); - - try { - const config = { - headers, - responseType: 'text', // 确保以文本形式接收 SSE 流 - timeout: 120000 // 2 分钟超时 - }; - - // 配置代理 - const proxyConfig = getProxyConfigForProvider(this.config, 'openai-codex-oauth'); - if (proxyConfig) { - config.httpAgent = proxyConfig.httpAgent; - config.httpsAgent = proxyConfig.httpsAgent; - } - - const axiosRequestConfig = { - method: 'post', - url, - data: body, - ...config - }; - this._applySidecar(axiosRequestConfig); - - const response = await axios.request(axiosRequestConfig); - - return this.parseNonStreamResponse(response.data); - } catch (error) { - if (error.response?.status === 401) { - logger.info('[Codex] Received 401. Triggering background refresh...'); - - // 触发后台异步刷新 - this.triggerBackgroundRefresh(); - error.credentialMarkedUnhealthy = true; - - // Mark error for credential switch without recording error count - error.shouldSwitchCredential = true; - error.skipErrorCount = true; - throw error; - } else { - logger.error(`[Codex] Error calling non-stream API (Status: ${error.response?.status}, Code: ${error.code || 'N/A'}):`, error.message); - throw error; - } - } - } - - /** - * 流式生成内容 - */ - async *generateContentStream(model, requestBody) { - if (!this.isInitialized) { - await this.initialize(); - } - - let selectedModel = model; - if (!CODEX_MODELS.includes(model)) { - const defaultModel = CODEX_MODELS[0] || 'gpt-5'; - logger.warn(`[Codex] Model '${model}' not found in supported list. Falling back to default: '${defaultModel}'`); - selectedModel = defaultModel; - } - - // 临时存储 monitorRequestId - if (requestBody._monitorRequestId) { - this.config._monitorRequestId = requestBody._monitorRequestId; - delete requestBody._monitorRequestId; - } - if (requestBody._requestBaseUrl) { - delete requestBody._requestBaseUrl; - } - - // 检查 token 是否即将过期,如果是则触发后台异步刷新 - if (this.isExpiryDateNear()) { - this.triggerBackgroundRefresh(); - } - - const url = `${this.baseUrl}/responses`; - const body = await this.prepareRequestBody(selectedModel, requestBody, true); - const headers = this.buildHeaders(body.prompt_cache_key, true); - - try { - const config = { - headers, - responseType: 'stream', - timeout: 120000 - }; - - // 配置代理 - const proxyConfig = getProxyConfigForProvider(this.config, 'openai-codex-oauth'); - if (proxyConfig) { - config.httpAgent = proxyConfig.httpAgent; - config.httpsAgent = proxyConfig.httpsAgent; - } - - const axiosRequestConfig = { - method: 'post', - url, - data: body, - ...config - }; - this._applySidecar(axiosRequestConfig); - - const response = await axios.request(axiosRequestConfig); - - yield* this.parseSSEStream(response.data); - } catch (error) { - if (error.response?.status === 401) { - logger.info('[Codex] Received 401 during stream. Triggering background refresh...'); - - // 触发后台异步刷新 - this.triggerBackgroundRefresh(); - error.credentialMarkedUnhealthy = true; - - // Mark error for credential switch without recording error count - error.shouldSwitchCredential = true; - error.skipErrorCount = true; - throw error; - } else { - logger.error(`[Codex] Error calling streaming API (Status: ${error.response?.status}, Code: ${error.code || 'N/A'}):`, error.message); - throw error; - } - } - } - - /** - * 构建请求头 - */ - buildHeaders(cacheId, stream = true) { - const headers = { - 'version': CODEX_VERSION, - 'x-codex-beta-features': 'powershell_utf8', - 'x-oai-web-search-eligible': 'true', - 'authorization': `Bearer ${this.accessToken}`, - 'chatgpt-account-id': this.accountId, - 'content-type': 'application/json', - 'user-agent': `codex_cli_rs/${CODEX_VERSION} (Windows 10.0.26100; x86_64) WindowsTerminal`, - 'originator': 'codex_cli_rs', - 'host': 'chatgpt.com', - 'Connection': 'Keep-Alive' - }; - - // 设置 Conversation_id 和 Session_id - if (cacheId) { - headers['Conversation_id'] = cacheId; - headers['Session_id'] = cacheId; - } - - // 根据是否流式设置 Accept 头 - if (stream) { - headers['accept'] = 'text/event-stream'; - } else { - headers['accept'] = 'application/json'; - } - - return headers; - } - - /** - * 准备请求体 - */ - async prepareRequestBody(model, requestBody, stream) { - // 提取 metadata 并从请求体中移除,避免透传到上游 - const metadata = requestBody.metadata || {}; - - // 明确会话维度:优先使用 session_id 或 conversation_id,其次 user_id - const sessionId = metadata.session_id || metadata.conversation_id || metadata.user_id || 'default'; - - // 判断是否为 fast 模型并确定默认值 - const normalizedModel = String(model || '').trim(); - const isFastModel = /-fast$/i.test(normalizedModel); - const upstreamModel = isFastModel ? normalizedModel.replace(/-fast$/i, '') : normalizedModel; - const defaultServiceTier = isFastModel ? 'priority' : 'default'; - const defaultReasoningEffort = isFastModel ? 'xhigh' : 'medium'; - - const cleanedBody = { ...requestBody }; - delete cleanedBody.metadata; - - // 【关键修复】确保传给上游的模型名称不带 -fast 后缀 - // 即使 originalRequestBody 中已经带了 model,这里也必须覆盖 - cleanedBody.model = upstreamModel; - - if (isFastModel) { - logger.info(`[Codex] Detected -fast model: ${normalizedModel} -> ${upstreamModel}, service_tier: ${cleanedBody.service_tier || defaultServiceTier}`); - } - - // 生成会话缓存键 - // 弱化 model 依赖,以提升同会话跨模型的缓存命中率 - // 仅当 sessionId 为 'default' 时加上 model 前缀,提供基础隔离 - let cacheKey = sessionId; - if (sessionId === 'default') { - cacheKey = `${model}-default`; - } - - let cache = this.conversationCache.get(cacheKey); - - if (!cache || cache.expire < Date.now()) { - cache = { - id: crypto.randomUUID(), - expire: Date.now() + 3600000 // 1 小时 - }; - this.conversationCache.set(cacheKey, cache); - } - - // 注意:requestBody 已经去除了 metadata - const result = { - ...cleanedBody, - service_tier: cleanedBody.service_tier || defaultServiceTier, - reasoning: { - ...cleanedBody.reasoning, - effort: isFastModel ? defaultReasoningEffort : cleanedBody.reasoning?.effort - }, - stream, - prompt_cache_key: cache.id - }; - - if (result.service_tier !== 'priority') { - delete result.service_tier; - } - - // 监控钩子:内部请求转换 - if (this.config?._monitorRequestId) { - try { - const { getPluginManager } = await import('../../core/plugin-manager.js'); - const pluginManager = getPluginManager(); - if (pluginManager) { - await pluginManager.executeHook('onInternalRequestConverted', { - requestId: this.config._monitorRequestId, - internalRequest: result, - converterName: 'prepareRequestBody' - }); - } - } catch (e) { - logger.error('[Codex] Error calling onInternalRequestConverted hook:', e.message); - } - } - - return result; - } - - /** - * 刷新访问令牌 - */ - async refreshAccessToken() { - try { - const newTokens = await refreshCodexTokensWithRetry(this.refreshToken, this.config); - - this.idToken = newTokens.id_token || this.idToken; - this.accessToken = newTokens.access_token; - this.refreshToken = newTokens.refresh_token; - this.accountId = newTokens.account_id; - this.email = newTokens.email; - this.last_refresh = new Date().toISOString(); - - // 关键修复:refreshCodexTokensWithRetry 返回字段名是 `expired`(ISO string),不是 `expire` - const expiredValue = newTokens.expired || newTokens.expire || newTokens.expires_at || newTokens.expiresAt; - const parsedExpiry = expiredValue ? new Date(expiredValue) : null; - if (!parsedExpiry || Number.isNaN(parsedExpiry.getTime())) { - // 如果上游没返回可解析的过期时间,保守处理:按 1h 有效期估算(避免 expiresAt 变成 NaN 导致永不刷新) - this.expiresAt = new Date(Date.now() + 3600 * 1000); - logger.warn('[Codex] Token refresh did not include a valid expiry time; falling back to 1h from now'); - } else { - this.expiresAt = parsedExpiry; - } - - // 保存更新的凭据 - await this.saveCredentials(); - - // 刷新成功,重置 PoolManager 中的刷新状态并标记为健康 - const poolManager = getProviderPoolManager(); - if (poolManager && this.uuid) { - poolManager.resetProviderRefreshStatus(MODEL_PROVIDER.CODEX_API, this.uuid); - } - logger.info('[Codex] Token refreshed successfully'); - } catch (error) { - logger.error('[Codex] Failed to refresh token:', error.message); - throw new Error('Failed to refresh Codex token. Please re-authenticate.'); - } - } - - /** - * 检查 token 是否即将过期 - */ - isExpiryDateNear() { - if (!this.expiresAt) return true; - const expiry = this.expiresAt.getTime(); - // 如果 expiresAt 是 Invalid Date(NaN),必须视为“接近过期/已过期”,否则刷新永远不会触发 - if (Number.isNaN(expiry)) { - logger.warn('[Codex] expiresAt is invalid (NaN). Treating as near expiry to force refresh'); - return true; - } - const nearMinutes = 20; - const { message, isNearExpiry } = formatExpiryLog('Codex', expiry, nearMinutes); - logger.info(message); - return isNearExpiry; - } - - /** - * 获取凭据文件路径 - */ - getCredentialsPath() { - const email = this.config.CODEX_EMAIL || this.email || 'default'; - - // 1) 优先使用配置中指定的路径(号池模式/显式配置) - if (this.config.CODEX_OAUTH_CREDS_FILE_PATH) { - return this.config.CODEX_OAUTH_CREDS_FILE_PATH; - } - - // 2) 如果本次是从 configs/codex 扫描加载的,务必写回同一文件 - if (this.credsPath) { - return this.credsPath; - } - - // 3) 兜底:写入 configs/codex(与 OAuth 保存默认目录保持一致,避免“读取 configs/codex、写入 .codex”导致永远读到旧 token) - const projectDir = process.cwd(); - return path.join(projectDir, 'configs', 'codex', `${Date.now()}_codex-${email}.json`); - } - - /** - * 保存凭据 - */ - async saveCredentials() { - const credsPath = this.getCredentialsPath(); - const credsDir = path.dirname(credsPath); - - if (!this.expiresAt || Number.isNaN(this.expiresAt.getTime())) { - throw new Error('Invalid expiresAt when saving Codex credentials'); - } - - await fs.mkdir(credsDir, { recursive: true }); - await fs.writeFile( - credsPath, - JSON.stringify( - { - id_token: this.idToken || '', - access_token: this.accessToken, - refresh_token: this.refreshToken, - account_id: this.accountId, - last_refresh: this.last_refresh || new Date().toISOString(), - email: this.email, - type: 'codex', - expired: this.expiresAt.toISOString() - }, - null, - 2 - ), - { mode: 0o600 } - ); - - // 更新缓存路径(例如首次无 credsPath 兜底生成了新文件) - this.credsPath = credsPath; - } - - /** - * 检查文件是否存在 - */ - async fileExists(filePath) { - try { - await fs.access(filePath); - return true; - } catch { - return false; - } - } - - /** - * 解析 SSE 流 - */ - async *parseSSEStream(stream) { - let buffer = ''; - - for await (const chunk of stream) { - buffer += chunk.toString(); - const lines = buffer.split('\n'); - buffer = lines.pop(); // 保留不完整的行 - - for (const line of lines) { - if (line.startsWith('data: ')) { - const data = line.slice(6).trim(); - if (data && data !== '[DONE]') { - try { - const parsed = JSON.parse(data); - yield parsed; - } catch (e) { - logger.error('[Codex] Failed to parse SSE data:', e.message); - } - } - } - } - } - - // 处理剩余的 buffer - if (buffer.trim()) { - if (buffer.startsWith('data: ')) { - const data = buffer.slice(6).trim(); - if (data && data !== '[DONE]') { - try { - const parsed = JSON.parse(data); - yield parsed; - } catch (e) { - logger.error('[Codex] Failed to parse final SSE data:', e.message); - } - } - } - } - } - - /** - * 解析非流式响应 - */ - parseNonStreamResponse(data) { - // 确保 data 是字符串 - const responseText = typeof data === 'string' ? data : String(data); - - // 从 SSE 流中提取 response.completed 事件 - const lines = responseText.split('\n'); - for (const line of lines) { - if (line.startsWith('data: ')) { - const jsonData = line.slice(6).trim(); - if (!jsonData || jsonData === '[DONE]') { - continue; - } - try { - const parsed = JSON.parse(jsonData); - if (parsed.type === 'response.completed') { - return parsed; - } - } catch (e) { - // 继续解析下一行 - logger.debug('[Codex] Failed to parse SSE line:', e.message); - } - } - } - - // 如果没有找到 response.completed,抛出错误 - logger.error('[Codex] No completed response found in Codex response'); - throw new Error('stream error: stream disconnected before completion: stream closed before response.completed'); - } - - /** - * 列出可用模型 - */ - async listModels() { - return { - object: 'list', - data: CODEX_MODELS.map(id => ({ - id, - object: 'model', - created: Math.floor(Date.now() / 1000), - owned_by: 'openai' - })) - }; - } - - /** - * 启动缓存清理 - */ - startCacheCleanup() { - // 每 15 分钟清理过期缓存 - this.cleanupInterval = setInterval(() => { - const now = Date.now(); - for (const [key, cache] of this.conversationCache.entries()) { - if (cache.expire < now) { - this.conversationCache.delete(key); - } - } - }, 15 * 60 * 1000); - } - - /** - * 停止缓存清理 - */ - stopCacheCleanup() { - if (this.cleanupInterval) { - clearInterval(this.cleanupInterval); - this.cleanupInterval = null; - } - } - - /** - * 获取使用限制信息 - * @returns {Promise} 使用限制信息(通用格式) - */ - async getUsageLimits() { - if (!this.isInitialized) { - await this.initialize(); - } - - try { - const url = 'https://chatgpt.com/backend-api/wham/usage'; - const headers = { - 'user-agent': `codex_cli_rs/${CODEX_VERSION} (Windows 10.0.26100; x86_64) WindowsTerminal`, - 'authorization': `Bearer ${this.accessToken}`, - 'chatgpt-account-id': this.accountId, - 'accept': '*/*', - 'host': 'chatgpt.com', - 'Connection': 'close' - }; - - const config = { - headers, - timeout: 30000 // 30 秒超时 - }; - - // 配置代理 - const proxyConfig = getProxyConfigForProvider(this.config, 'openai-codex-oauth'); - if (proxyConfig) { - config.httpAgent = proxyConfig.httpAgent; - config.httpsAgent = proxyConfig.httpsAgent; - } - - const axiosRequestConfig = { - method: 'get', - url, - ...config - }; - this._applySidecar(axiosRequestConfig); - - const response = await axios.request(axiosRequestConfig); - - // 解析响应数据并转换为通用格式 - const data = response.data; - - // 通用格式:{ lastUpdated, models: { "model-id": { remaining, resetTime, resetTimeRaw } } } - const result = { - lastUpdated: Date.now(), - models: {} - }; - - // 从 rate_limit 提取配额信息 - // Codex 使用百分比表示使用量,我们需要转换为剩余量 - if (data.rate_limit) { - const primaryWindow = data.rate_limit.primary_window; - const secondaryWindow = data.rate_limit.secondary_window; - - // 使用主窗口的数据作为主要配额信息 - if (primaryWindow) { - // remaining = 1 - (used_percent / 100) - const remaining = 1 - (primaryWindow.used_percent || 0) / 100; - const resetTime = primaryWindow.reset_at ? new Date(primaryWindow.reset_at * 1000).toISOString() : null; - - // 为所有 Codex 模型设置相同的配额信息 - const codexModels = ['default']; - for (const modelId of codexModels) { - result.models[modelId] = { - remaining: Math.max(0, Math.min(1, remaining)), // 确保在 0-1 之间 - resetTime: resetTime, - resetTimeRaw: primaryWindow.reset_at - }; - } - } - } - - // 保存原始响应数据供需要时使用 - result.raw = { - planType: data.plan_type || 'unknown', - rateLimit: data.rate_limit, - codeReviewRateLimit: data.code_review_rate_limit, - credits: data.credits - }; - - logger.info(`[Codex] Successfully fetched usage limits for plan: ${result.raw.planType}`); - return result; - } catch (error) { - if (error.response?.status === 401) { - logger.info('[Codex] Received 401 during getUsageLimits. Triggering background refresh...'); - - // 触发后台异步刷新 - this.triggerBackgroundRefresh(); - error.credentialMarkedUnhealthy = true; - - // Mark error for credential switch without recording error count - error.shouldSwitchCredential = true; - error.skipErrorCount = true; - } - - logger.error('[Codex] Failed to get usage limits:', error.message); - throw error; - } - } -} - diff --git a/src/providers/openai/codex-responses-strategy.js b/src/providers/openai/codex-responses-strategy.js deleted file mode 100644 index 21cacbb55d83320e03b7a0db1356c42572396f55..0000000000000000000000000000000000000000 --- a/src/providers/openai/codex-responses-strategy.js +++ /dev/null @@ -1,124 +0,0 @@ -import { ProviderStrategy } from '../../utils/provider-strategy.js'; -import logger from '../../utils/logger.js'; -import { extractSystemPromptFromRequestBody, MODEL_PROTOCOL_PREFIX } from '../../utils/common.js'; - -/** - * OpenAI Responses API strategy implementation. - * Migrated from Chat Completions API to Responses API. - */ -class CodexResponsesAPIStrategy extends ProviderStrategy { - extractModelAndStreamInfo(req, requestBody) { - const model = requestBody.model; - const isStream = requestBody.stream === true; - return { model, isStream }; - } - - extractResponseText(response) { - if (!response.output) { - return ''; - } - - // In Responses API, output is an array of items - for (const item of response.output) { - if (item.type === 'message' && item.content && item.content.length > 0) { - for (const content of item.content) { - if (content.type === 'output_text' && content.text) { - return content.text; - } - } - } - } - return ''; - } - - extractPromptText(requestBody) { - // In Responses API, input can be a string or array of items - if (typeof requestBody.input === 'string') { - return requestBody.input; - } else if (Array.isArray(requestBody.input)) { - // If input is an array of items/messages, get the last user content - const userInputItems = requestBody.input.filter(item => - (item.role && item.role === 'user') || - (item.type && item.type === 'message' && item.role === 'user') || - (item.type && item.type === 'user') - ); - - if (userInputItems.length > 0) { - const lastInput = userInputItems[userInputItems.length - 1]; - if (typeof lastInput.content === 'string') { - return lastInput.content; - } else if (Array.isArray(lastInput.content)) { - return lastInput.content.map(item => item.text || item.content || '').join('\n'); - } - } - } - return ''; - } - - async applySystemPromptFromFile(config, requestBody) { - if (!config.SYSTEM_PROMPT_FILE_PATH) { - return requestBody; - } - - const filePromptContent = config.SYSTEM_PROMPT_CONTENT; - if (filePromptContent === null) { - return requestBody; - } - - // In Responses API, system instructions are typically passed in 'instructions' field - // or in the input array with role: 'developer' - requestBody.instructions = requestBody.instructions || filePromptContent; - - // If using instructions field is not desired, append to input array instead - if (!requestBody.instructions || config.SYSTEM_PROMPT_MODE === 'append') { - if (typeof requestBody.input === 'string') { - // Convert to array format to add system message - requestBody.input = [ - { type: 'message', role: 'developer', content: filePromptContent }, - { type: 'message', role: 'user', content: requestBody.input } - ]; - } else if (Array.isArray(requestBody.input)) { - // Check if system message already exists - const systemMessageIndex = requestBody.input.findIndex(m => - m.role === 'developer' || (m.type && m.type === 'developer') - ); - - if (systemMessageIndex !== -1) { - requestBody.input[systemMessageIndex].content = filePromptContent; - } else { - requestBody.input.unshift({ type: 'message', role: 'developer', content: filePromptContent }); - } - } else { - // If input is not defined, initialize with system message - requestBody.input = [{ type: 'message', role: 'developer', content: filePromptContent }]; - } - } else if (requestBody.instructions) { - // If system prompt mode is not append, then replace the instructions - requestBody.instructions = filePromptContent; - } - - logger.info(`[System Prompt] Applied system prompt from ${config.SYSTEM_PROMPT_FILE_PATH} in '${config.SYSTEM_PROMPT_MODE}' mode for provider 'responses'.`); - - return requestBody; - } - - async manageSystemPrompt(requestBody) { - // For Responses API, we may extract instructions or system messages from input - let incomingSystemText = ''; - - if (requestBody.instructions) { - incomingSystemText = requestBody.instructions; - } else if (Array.isArray(requestBody.input)) { - const systemMessage = requestBody.input.find(item => - item.role === 'developer' || (item.type && item.type === 'developer') - ); - if (systemMessage && systemMessage.content) { - incomingSystemText = systemMessage.content; - } - } - - await this._updateSystemPromptFile(incomingSystemText, MODEL_PROTOCOL_PREFIX.OPENAI); - } -} - -export { CodexResponsesAPIStrategy }; diff --git a/src/providers/openai/iflow-core.js b/src/providers/openai/iflow-core.js deleted file mode 100644 index 74acbf5de95e48c739ceb01b98fed29fe4f4d676..0000000000000000000000000000000000000000 --- a/src/providers/openai/iflow-core.js +++ /dev/null @@ -1,1165 +0,0 @@ -/** - * iFlow API Service - * - * iFlow 是一个 AI 服务平台,提供 OpenAI 兼容的 API 接口。 - * 使用 Token 文件方式认证 - 从文件读取 API Key - * - * 支持的模型: - * - Qwen 系列: qwen3-max, qwen3-coder-plus, qwen3-vl-plus, qwen3-235b 等 - * - Kimi 系列: kimi-k2, kimi-k2-0905 - * - DeepSeek 系列: deepseek-v3, deepseek-v3.2, deepseek-r1 - * - GLM 系列: glm-4.6 - * - * 支持的特殊模型配置: - * - GLM-4.x: 使用 chat_template_kwargs.enable_thinking - * - Qwen thinking 模型: 内置推理能力 - * - DeepSeek R1: 内置推理能力 - */ - -import axios from 'axios'; -import logger from '../../utils/logger.js'; -import * as http from 'http'; -import * as https from 'https'; -import { promises as fs } from 'fs'; -import * as path from 'path'; -import * as os from 'os'; -import * as crypto from 'crypto'; -import { configureAxiosProxy } from '../../utils/proxy-utils.js'; -import { isRetryableNetworkError, MODEL_PROVIDER, formatExpiryLog } from '../../utils/common.js'; -import { getProviderPoolManager } from '../../services/service-manager.js'; -import { getProviderModels } from '../provider-models.js'; - -// iFlow API 端点 -const IFLOW_API_BASE_URL = 'https://apis.iflow.cn/v1'; -const IFLOW_USER_AGENT = 'iFlow-Cli'; -const IFLOW_OAUTH_TOKEN_ENDPOINT = 'https://iflow.cn/oauth/token'; -const IFLOW_USER_INFO_ENDPOINT = 'https://iflow.cn/api/oauth/getUserInfo'; -const IFLOW_OAUTH_CLIENT_ID = '10009311001'; -const IFLOW_OAUTH_CLIENT_SECRET = '4Z3YjXycVsQvyGF1etiNlIBB4RsqSDtW'; - -// 默认模型列表 -const IFLOW_MODELS = getProviderModels(MODEL_PROVIDER.IFLOW_API); - -// 支持 thinking 的模型前缀 -const THINKING_MODEL_PREFIXES = ['glm-', 'qwen3-235b-a22b-thinking', 'deepseek-r1']; - -// ==================== Token 管理 ==================== - -/** - * iFlow Token 存储类 - */ -class IFlowTokenStorage { - constructor(data = {}) { - this.accessToken = data.accessToken || data.access_token || ''; - this.refreshToken = data.refreshToken || data.refresh_token || ''; - this.expiryDate = data.expiryDate || data.expiry_date || ''; - this.apiKey = data.apiKey || data.api_key || ''; - this.tokenType = data.tokenType || data.token_type || ''; - this.scope = data.scope || ''; - } - - /** - * 转换为 JSON 对象 - */ - toJSON() { - return { - access_token: this.accessToken, - refresh_token: this.refreshToken, - expiry_date: this.expiryDate, - token_type: this.tokenType, - scope: this.scope, - apiKey: this.apiKey - }; - } - - /** - * 从 JSON 对象创建实例 - */ - static fromJSON(json) { - return new IFlowTokenStorage(json); - } -} - -/** - * 从文件加载 Token - * @param {string} filePath - Token 文件路径 - * @returns {Promise} - */ -async function loadTokenFromFile(filePath) { - try { - const absolutePath = path.isAbsolute(filePath) - ? filePath - : path.join(process.cwd(), filePath); - - const data = await fs.readFile(absolutePath, 'utf-8'); - const json = JSON.parse(data); - - // 记录加载的 token 信息 - const refreshToken = json.refreshToken || json.refresh_token || ''; - logger.info(`[iFlow] Token loaded from: ${filePath} (refresh_token: ${refreshToken ? refreshToken.substring(0, 8) + '...' : 'EMPTY'})`); - - return IFlowTokenStorage.fromJSON(json); - } catch (error) { - if (error.code === 'ENOENT') { - logger.warn(`[iFlow] Token file not found: ${filePath}`); - return null; - } - throw new Error(`[iFlow] Failed to load token from file: ${error.message}`); - } -} - -/** - * 保存 Token 到文件 - * @param {string} filePath - Token 文件路径 - * @param {IFlowTokenStorage} tokenStorage - Token 存储对象 - */ -async function saveTokenToFile(filePath, tokenStorage, uuid = null) { - const absolutePath = path.isAbsolute(filePath) - ? filePath - : path.join(process.cwd(), filePath); - - try { - // 确保目录存在 - const dir = path.dirname(absolutePath); - await fs.mkdir(dir, { recursive: true }); - - // 写入文件 - const json = tokenStorage.toJSON(); - - // 验证关键字段是否存在 - if (!json.refresh_token || json.refresh_token.trim() === '') { - logger.error('[iFlow] WARNING: Attempting to save token file with empty refresh_token!'); - } - if (!json.apiKey || json.apiKey.trim() === '') { - logger.error('[iFlow] WARNING: Attempting to save token file with empty apiKey!'); - } - - await fs.writeFile(absolutePath, JSON.stringify(json, null, 2), 'utf-8'); - - logger.info(`[iFlow] Token saved to: ${filePath} (refresh_token: ${json.refresh_token ? json.refresh_token.substring(0, 8) + '...' : 'EMPTY'})`); - } catch (error) { - throw new Error(`[iFlow] Failed to save token to file: ${error.message}`); - } -} - -// ==================== Token 刷新逻辑 ==================== - -/** - * 使用 refresh_token 刷新 OAuth Token - * @param {string} refreshToken - 刷新令牌 - * @param {Object} axiosInstance - axios 实例(可选,用于代理配置) - * @returns {Promise} - 新的 Token 数据 - */ -async function refreshOAuthTokens(refreshToken, axiosInstance = null) { - if (!refreshToken || refreshToken.trim() === '') { - throw new Error('[iFlow] refresh_token is empty'); - } - - logger.info('[iFlow] Refreshing OAuth tokens...'); - - // 构建请求参数 - const params = new URLSearchParams(); - params.append('grant_type', 'refresh_token'); - params.append('refresh_token', refreshToken); - params.append('client_id', IFLOW_OAUTH_CLIENT_ID); - params.append('client_secret', IFLOW_OAUTH_CLIENT_SECRET); - - // 构建 Basic Auth header - const basicAuth = Buffer.from(`${IFLOW_OAUTH_CLIENT_ID}:${IFLOW_OAUTH_CLIENT_SECRET}`).toString('base64'); - - const requestConfig = { - method: 'POST', - url: IFLOW_OAUTH_TOKEN_ENDPOINT, - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Accept': 'application/json', - 'Authorization': `Basic ${basicAuth}` - }, - data: params.toString(), - timeout: 30000 - }; - - try { - const response = axiosInstance - ? await axiosInstance.request(requestConfig) - : await axios.request(requestConfig); - - const tokenResp = response.data; - - // logger.info('[iFlow] Token response:', JSON.stringify(tokenResp)); - if (!tokenResp.access_token) { - logger.error('[iFlow] Token response:', JSON.stringify(tokenResp)); - throw new Error('[iFlow] Missing access_token in response'); - } - - // 计算过期时间(毫秒级时间戳) - const expiresIn = tokenResp.expires_in || 3600; - const expireTimestamp = Date.now() + expiresIn * 1000; - - const tokenData = { - accessToken: tokenResp.access_token, - refreshToken: tokenResp.refresh_token || refreshToken, - tokenType: tokenResp.token_type || 'Bearer', - scope: tokenResp.scope || '', - expiryDate: expireTimestamp // 毫秒级时间戳 - }; - - logger.info('[iFlow] OAuth tokens refreshed successfully'); - - // 获取用户信息以获取 API Key - const userInfo = await fetchUserInfo(tokenData.accessToken, axiosInstance); - if (userInfo && userInfo.apiKey) { - tokenData.apiKey = userInfo.apiKey; - tokenData.email = userInfo.email || userInfo.phone || ''; - } - - return tokenData; - } catch (error) { - const status = error.response?.status; - const data = error.response?.data; - logger.error(`[iFlow] OAuth token refresh failed (Status: ${status}):`, data || error.message); - throw error; - } -} - -/** - * 获取用户信息(包含 API Key) - * @param {string} accessToken - 访问令牌 - * @param {Object} axiosInstance - axios 实例(可选) - * @returns {Promise} - 用户信息 - */ -async function fetchUserInfo(accessToken, axiosInstance = null) { - if (!accessToken || accessToken.trim() === '') { - throw new Error('[iFlow] access_token is empty'); - } - - const url = `${IFLOW_USER_INFO_ENDPOINT}?accessToken=${encodeURIComponent(accessToken)}`; - - const requestConfig = { - method: 'GET', - url, - headers: { - 'Accept': 'application/json' - }, - timeout: 30000 - }; - - try { - const response = axiosInstance - ? await axiosInstance.request(requestConfig) - : await axios.request(requestConfig); - - const result = response.data; - // logger.info('[iFlow] User info response:', JSON.stringify(result)); - if (!result.success) { - throw new Error('[iFlow] User info request not successful'); - } - - if (!result.data || !result.data.apiKey) { - throw new Error('[iFlow] Missing apiKey in user info response'); - } - - return { - apiKey: result.data.apiKey, - email: result.data.email || '', - phone: result.data.phone || '' - }; - } catch (error) { - const status = error.response?.status; - const data = error.response?.data; - logger.error(`[iFlow] Fetch user info failed (Status: ${status}):`, data || error.message); - throw error; - } -} - -// ==================== 请求处理工具函数 ==================== - -/** - * 生成 UUID v4 - * @returns {string} - UUID 字符串 - */ -function generateUUID() { - return crypto.randomUUID(); -} - -/** - * 创建 iFlow 签名 - * 签名格式: HMAC-SHA256(userAgent:sessionId:timestamp, apiKey) - * @param {string} userAgent - User-Agent - * @param {string} sessionID - Session ID - * @param {number} timestamp - 时间戳(毫秒) - * @param {string} apiKey - API Key - * @returns {string} - 十六进制签名 - */ -function createIFlowSignature(userAgent, sessionID, timestamp, apiKey) { - if (!apiKey) { - return ''; - } - const payload = `${userAgent}:${sessionID}:${timestamp}`; - const hmac = crypto.createHmac('sha256', apiKey); - hmac.update(payload); - return hmac.digest('hex'); -} - -/** - * 检查模型是否支持 thinking 配置 - * @param {string} model - 模型名称 - * @returns {boolean} - */ -function isThinkingModel(model) { - if (!model) return false; - const lowerModel = model.toLowerCase(); - return THINKING_MODEL_PREFIXES.some(prefix => lowerModel.startsWith(prefix)); -} - -/** - * 应用 iFlow 特定的 thinking 配置 - * 将 reasoning_effort 转换为模型特定的配置 - * - * @param {Object} body - 请求体 - * @param {string} model - 模型名称 - * @returns {Object} - 处理后的请求体 - */ -function applyIFlowThinkingConfig(body, model) { - if (!body || !model) return body; - - const lowerModel = model.toLowerCase(); - const reasoningEffort = body.reasoning_effort; - - // 如果没有 reasoning_effort,直接返回 - if (reasoningEffort === undefined) return body; - - const enableThinking = reasoningEffort !== 'none' && reasoningEffort !== ''; - - // 创建新对象,移除 reasoning_effort 和 thinking - const newBody = { ...body }; - delete newBody.reasoning_effort; - delete newBody.thinking; - - // GLM-4.x: 使用 chat_template_kwargs - if (lowerModel.startsWith('glm-4')) { - newBody.chat_template_kwargs = { - ...(newBody.chat_template_kwargs || {}), - enable_thinking: enableThinking - }; - if (enableThinking) { - newBody.chat_template_kwargs.clear_thinking = false; - } - return newBody; - } - - // Qwen thinking 模型: 保持 thinking 配置 - if (lowerModel.includes('thinking')) { - // Qwen thinking 模型默认启用 thinking,不需要额外配置 - return newBody; - } - - // DeepSeek R1: 推理模型,不需要额外配置 - if (lowerModel.startsWith('deepseek-r1')) { - return newBody; - } - - return newBody; -} - -/** - * 保留消息历史中的 reasoning_content - * 对于支持 thinking 的模型,保留 assistant 消息中的 reasoning_content - * - * 对于 GLM-4.6/4.7 和 MiniMax M2/M2.1,建议在消息历史中包含完整的 assistant - * 响应(包括 reasoning_content)以保持更好的上下文连续性。 - * - * @param {Object} body - 请求体 - * @param {string} model - 模型名称 - * @returns {Object} - 处理后的请求体 - */ -function preserveReasoningContentInMessages(body, model) { - if (!body || !model) return body; - - const lowerModel = model.toLowerCase(); - - // 只对支持 thinking 且需要历史保留的模型应用 - const needsPreservation = lowerModel.startsWith('glm-4') || - lowerModel.startsWith('minimax-m2'); - - if (!needsPreservation) { - return body; - } - - const messages = body.messages; - if (!Array.isArray(messages)) return body; - - // 检查是否有 assistant 消息包含 reasoning_content - const hasReasoningContent = messages.some(msg => - msg.role === 'assistant' && msg.reasoning_content && msg.reasoning_content !== '' - ); - - // 如果 reasoning content 已经存在,说明消息格式正确 - // 客户端已经正确地在历史中保留了推理内容 - if (hasReasoningContent) { - logger.debug(`[iFlow] reasoning_content found in message history for ${model}`); - } - - return body; -} - -/** - * 确保 tools 数组存在(避免某些模型的问题) - * 如果 tools 是空数组,添加一个占位工具 - * - * @param {Object} body - 请求体 - * @returns {Object} - 处理后的请求体 - */ -function ensureToolsArray(body) { - if (!body || !body.tools) return body; - - if (Array.isArray(body.tools) && body.tools.length === 0) { - return { - ...body, - tools: [{ - type: 'function', - function: { - name: 'noop', - description: 'Placeholder tool to stabilise streaming', - parameters: { type: 'object' } - } - }] - }; - } - - return body; -} - -/** - * 预处理请求体 - * @param {Object} body - 原始请求体 - * @param {string} model - 模型名称 - * @returns {Object} - 处理后的请求体 - */ -function preprocessRequestBody(body, model) { - // 确保模型名称有效,如果不存在则使用默认模型 - let targetModel = model; - if (Array.isArray(IFLOW_MODELS) && IFLOW_MODELS.length > 0) { - if (!IFLOW_MODELS.includes(model)) { - logger.warn(`[iFlow] Model "${model}" not found in IFLOW_MODELS, defaulting to "${IFLOW_MODELS[0]}"`); - targetModel = IFLOW_MODELS[0]; - } - } - - let processedBody = { ...body }; - - // 确保模型名称正确 - processedBody.model = targetModel; - - // 应用 iFlow thinking 配置 - processedBody = applyIFlowThinkingConfig(processedBody, targetModel); - - // 保留 reasoning_content - processedBody = preserveReasoningContentInMessages(processedBody, targetModel); - - // 确保 tools 数组 - processedBody = ensureToolsArray(processedBody); - - return processedBody; -} - -// ==================== API 服务 ==================== - -/** - * iFlow API 服务类 - */ -// 默认 Token 文件路径 -const DEFAULT_TOKEN_FILE_PATH = path.join(os.homedir(), '.iflow', 'oauth_creds.json'); - -export class IFlowApiService { - constructor(config) { - this.config = config; - this.apiKey = null; - this.baseUrl = config.IFLOW_BASE_URL || IFLOW_API_BASE_URL; - this.tokenFilePath = config.IFLOW_TOKEN_FILE_PATH || DEFAULT_TOKEN_FILE_PATH; - this.uuid = config.uuid; // 保存 uuid 用于缓存管理 - this.isInitialized = false; - this.tokenStorage = null; - - // 配置 HTTP/HTTPS agent - const httpAgent = new http.Agent({ - keepAlive: true, - maxSockets: 100, - maxFreeSockets: 5, - timeout: 120000, - }); - const httpsAgent = new https.Agent({ - keepAlive: true, - maxSockets: 100, - maxFreeSockets: 5, - timeout: 120000, - }); - - const axiosConfig = { - baseURL: this.baseUrl, - httpAgent, - httpsAgent, - headers: { - 'Content-Type': 'application/json', - 'User-Agent': IFLOW_USER_AGENT, - }, - }; - - // 配置自定义代理 - configureAxiosProxy(axiosConfig, config, 'openai-iflow'); - - this.axiosInstance = axios.create(axiosConfig); - } - - /** - * 初始化服务 - */ - async initialize() { - if (this.isInitialized) return; - - logger.info('[iFlow] Initializing iFlow API Service...'); - // 注意:V2 读写分离架构下,初始化不再执行同步认证/刷新逻辑 - // 仅执行基础的凭证加载 - await this.loadCredentials(); - - this.isInitialized = true; - logger.info('[iFlow] Initialization complete.'); - } - - /** - * 加载凭证信息(不执行刷新) - */ - async loadCredentials() { - try { - // 从文件加载 - this.tokenStorage = await loadTokenFromFile(this.tokenFilePath); - if (this.tokenStorage && this.tokenStorage.apiKey) { - this.apiKey = this.tokenStorage.apiKey; - // 更新 axios 实例的 Authorization header - this.axiosInstance.defaults.headers['Authorization'] = `Bearer ${this.apiKey}`; - logger.info('[iFlow Auth] Credentials loaded successfully from file'); - } - } catch (error) { - logger.warn(`[iFlow Auth] Failed to load credentials from file: ${error.message}`); - } - } - - /** - * 初始化认证并执行必要刷新 - * @param {boolean} forceRefresh - 是否强制刷新 Token - */ - async initializeAuth(forceRefresh = false) { - // 首先执行基础凭证加载 - await this.loadCredentials(); - - // 如果已有 API Key 且不强制刷新且未过期,直接返回 - if (this.apiKey && !forceRefresh) return; - - // 从 Token 文件加载 API Key - if (!this.tokenFilePath) { - throw new Error('[iFlow] IFLOW_TOKEN_FILE_PATH is required.'); - } - - try { - // 从文件加载 - if (!this.tokenStorage) { - this.tokenStorage = await loadTokenFromFile(this.tokenFilePath); - logger.info('[iFlow Auth] Loaded credentials from file'); - } - - if (this.tokenStorage && this.tokenStorage.apiKey) { - this.apiKey = this.tokenStorage.apiKey; - logger.info('[iFlow Auth] Authentication configured successfully from file.'); - - if (forceRefresh) { - logger.info('[iFlow Auth] Forcing token refresh...'); - await this._refreshOAuthTokens(); - logger.info('[iFlow Auth] Token refreshed and saved successfully.'); - - // 刷新成功,重置 PoolManager 中的刷新状态并标记为健康 - const poolManager = getProviderPoolManager(); - if (poolManager && this.uuid) { - poolManager.resetProviderRefreshStatus(MODEL_PROVIDER.IFLOW_API, this.uuid); - } - } - } else { - throw new Error('[iFlow] No refresh token available in credentials.'); - } - } catch (error) { - logger.error('[iFlow Auth] Failed to initialize authentication:', error.message); - throw new Error(`[iFlow Auth] Failed to load OAuth credentials.`); - } - } - - /** - * 检查是否需要刷新 Token 并执行刷新 - * @returns {Promise} - 是否执行了刷新 - */ - async _checkAndRefreshTokenIfNeeded() { - if (!this.tokenStorage) { - return false; - } - - // 检查是否有 refresh_token - if (!this.tokenStorage.refreshToken || this.tokenStorage.refreshToken.trim() === '') { - logger.info('[iFlow] No refresh_token available, skipping token refresh check'); - return false; - } - - // 使用 isExpiryDateNear 检查过期时间 - // if (!this.isExpiryDateNear()) { - // logger.info('[iFlow] Token is valid, no refresh needed'); - // return false; - // } - - logger.info('[iFlow] Token is expiring soon, attempting refresh...'); - - try { - await this._refreshOAuthTokens(); - return true; - } catch (error) { - logger.error('[iFlow] Token refresh failed:', error.message); - // 刷新失败不抛出异常,继续使用现有 Token - return false; - } - } - - /** - * 使用 refresh_token 刷新 OAuth Token - * @returns {Promise} - */ - async _refreshOAuthTokens() { - if (!this.tokenStorage || !this.tokenStorage.refreshToken) { - throw new Error('[iFlow] No refresh_token available'); - } - - const oldAccessToken = this.tokenStorage.accessToken; - if (oldAccessToken) { - logger.info(`[iFlow] Refreshing access token, old: ${this._maskToken(oldAccessToken)}`); - } - - // 调用刷新函数 - const oldRefreshToken = this.tokenStorage.refreshToken; - const tokenData = await refreshOAuthTokens(oldRefreshToken, this.axiosInstance); - - // 更新 tokenStorage - 必须更新 refreshToken,因为 OAuth 服务器可能返回新的 refresh_token - this.tokenStorage.accessToken = tokenData.accessToken; - // 始终更新 refreshToken,即使服务器没有返回新的(tokenData.refreshToken 会回退到旧值) - this.tokenStorage.refreshToken = tokenData.refreshToken; - - // 记录 refresh_token 是否发生变化 - if (tokenData.refreshToken !== oldRefreshToken) { - logger.info(`[iFlow] refresh_token has been rotated (old: ${this._maskToken(oldRefreshToken)}, new: ${this._maskToken(tokenData.refreshToken)})`); - } - if (tokenData.apiKey) { - this.tokenStorage.apiKey = tokenData.apiKey; - this.apiKey = tokenData.apiKey; - } - this.tokenStorage.expiryDate = tokenData.expiryDate; - this.tokenStorage.tokenType = tokenData.tokenType || 'Bearer'; - this.tokenStorage.scope = tokenData.scope || ''; - if (tokenData.email) { - this.tokenStorage.email = tokenData.email; - } - - // 更新 axios 实例的 Authorization header - this.axiosInstance.defaults.headers['Authorization'] = `Bearer ${this.apiKey}`; - - // 保存到文件 - await saveTokenToFile(this.tokenFilePath, this.tokenStorage, this.uuid); - - logger.info(`[iFlow] Token refresh successful, new: ${this._maskToken(tokenData.accessToken)}`); - } - - /** - * 掩码 Token(只显示前后几个字符) - * @param {string} token - Token 字符串 - * @returns {string} - 掩码后的 Token - */ - _maskToken(token) { - if (!token || token.length < 10) { - return '***'; - } - return `${token.substring(0, 4)}...${token.substring(token.length - 4)}`; - } - - /** - * 手动刷新 Token(供外部调用) - * @returns {Promise} - 是否刷新成功 - */ - async refreshToken() { - if (!this.isInitialized) { - await this.initialize(); - } - - try { - await this._refreshOAuthTokens(); - return true; - } catch (error) { - logger.error('[iFlow] Manual token refresh failed:', error.message); - return false; - } - } - - /** - * Checks if the given expiry date is within the threshold from now or already expired. - * @returns {boolean} True if the expiry date is within the threshold or already expired, false otherwise. - */ - isExpiryDateNear() { - try { - if (!this.tokenStorage || !this.tokenStorage.expiryDate) { - return false; - } - - // 授权文件时效48小时,判断是否过期或接近过期 (45小时) - const cronNearMinutes = 60 * 45; - - // 解析过期时间 - let expireTime; - const expireValue = this.tokenStorage.expiryDate; - - // 检查是否为数字(毫秒时间戳) - if (typeof expireValue === 'number') { - expireTime = expireValue; - } else if (typeof expireValue === 'string') { - // 检查是否为纯数字字符串(毫秒时间戳) - if (/^\d+$/.test(expireValue)) { - expireTime = parseInt(expireValue, 10); - } else if (expireValue.includes('T')) { - // ISO 8601 格式 - expireTime = new Date(expireValue).getTime(); - } else { - // 格式:2006-01-02 15:04 - expireTime = new Date(expireValue.replace(' ', 'T') + ':00').getTime(); - } - } else { - logger.error(`[iFlow] Invalid expiry date type: ${typeof expireValue}`); - return false; - } - - if (isNaN(expireTime)) { - logger.error(`[iFlow] Error parsing expiry date: ${expireValue}`); - return false; - } - - const { message, isNearExpiry } = formatExpiryLog('iFlow', expireTime, cronNearMinutes); - logger.info(message); - - return isNearExpiry; - } catch (error) { - logger.error(`[iFlow] Error checking expiry date: ${error.message}`); - return false; - } - } - - /** - * 获取请求头 - * @param {boolean} stream - 是否为流式请求 - * @returns {Object} - 请求头 - */ - _getHeaders(stream = false) { - // 生成 session-id - const sessionID = 'session-' + generateUUID(); - - // 生成时间戳(毫秒) - const timestamp = Date.now(); - - // 生成签名 - const signature = createIFlowSignature(IFLOW_USER_AGENT, sessionID, timestamp, this.apiKey); - - const headers = { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${this.apiKey}`, - 'User-Agent': IFLOW_USER_AGENT, - 'session-id': sessionID, - 'x-iflow-timestamp': timestamp.toString(), - }; - - // 只有在签名生成成功时才添加 - if (signature) { - headers['x-iflow-signature'] = signature; - } - - if (stream) { - headers['Accept'] = 'text/event-stream'; - } else { - headers['Accept'] = 'application/json'; - } - - return headers; - } - - /** - * 调用 API - */ - async callApi(endpoint, body, model, isRetry = false, retryCount = 0) { - const maxRetries = this.config.REQUEST_MAX_RETRIES || 3; - const baseDelay = this.config.REQUEST_BASE_DELAY || 1000; - - // 预处理请求体 - const processedBody = preprocessRequestBody(body, model); - - try { - const response = await this.axiosInstance.post(endpoint, processedBody, { - headers: this._getHeaders(false) - }); - return response.data; - } catch (error) { - const status = error.response?.status; - const data = error.response?.data; - const errorCode = error.code; - const errorMessage = error.message || ''; - - // 检查是否为可重试的网络错误 - const isNetworkError = isRetryableNetworkError(error); - - // Handle 401/400 - refresh auth and retry once - if ((status === 400 || status === 401) && !isRetry) { - logger.info(`[iFlow] Received ${status}. Triggering background refresh via PoolManager...`); - - // 标记当前凭证为不健康(会自动进入刷新队列) - const poolManager = getProviderPoolManager(); - if (poolManager && this.uuid) { - logger.info(`[iFlow] Marking credential ${this.uuid} as needs refresh. Reason: ${status} Unauthorized`); - poolManager.markProviderNeedRefresh(MODEL_PROVIDER.IFLOW_API, { - uuid: this.uuid - }); - error.credentialMarkedUnhealthy = true; - } - - // Mark error for credential switch without recording error count - error.shouldSwitchCredential = true; - error.skipErrorCount = true; - throw error; - } - - if (status === 401 || status === 403) { - logger.error(`[iFlow] Received ${status}. API Key might be invalid or expired.`); - throw error; - } - - // Handle 429 (Too Many Requests) with exponential backoff - if (status === 429 && retryCount < maxRetries) { - const delay = baseDelay * Math.pow(2, retryCount); - logger.info(`[iFlow] Received 429 (Too Many Requests). Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, delay)); - return this.callApi(endpoint, body, model, isRetry, retryCount + 1); - } - - // Handle other retryable errors (5xx server errors) - if (status >= 500 && status < 600 && retryCount < maxRetries) { - const delay = baseDelay * Math.pow(2, retryCount); - logger.info(`[iFlow] Received ${status} server error. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, delay)); - return this.callApi(endpoint, body, model, isRetry, retryCount + 1); - } - - // Handle network errors (ECONNRESET, ETIMEDOUT, etc.) with exponential backoff - if (isNetworkError && retryCount < maxRetries) { - const delay = baseDelay * Math.pow(2, retryCount); - const errorIdentifier = errorCode || errorMessage.substring(0, 50); - logger.info(`[iFlow] Network error (${errorIdentifier}). Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, delay)); - return this.callApi(endpoint, body, model, isRetry, retryCount + 1); - } - - logger.error(`[iFlow] Error calling API (Status: ${status}, Code: ${errorCode}):`, errorMessage); - throw error; - } - } - - /** - * 流式调用 API - * - * - 使用大缓冲区处理长行 - * - 逐行处理 SSE 数据 - * - 正确处理 data: 前缀和 [DONE] 标记 - */ - async *streamApi(endpoint, body, model, isRetry = false, retryCount = 0) { - const maxRetries = this.config.REQUEST_MAX_RETRIES || 3; - const baseDelay = this.config.REQUEST_BASE_DELAY || 1000; - - // 预处理请求体并设置 stream: true - const processedBody = preprocessRequestBody({ ...body, stream: true }, model); - - try { - const response = await this.axiosInstance.post(endpoint, processedBody, { - responseType: 'stream', - headers: this._getHeaders(true) - }); - - const stream = response.data; - let buffer = ''; - - for await (const chunk of stream) { - // 将 chunk 转换为字符串并追加到缓冲区 - buffer += chunk.toString(); - - // 逐行处理 - let newlineIndex; - while ((newlineIndex = buffer.indexOf('\n')) !== -1) { - // 提取一行(不包含换行符) - const line = buffer.substring(0, newlineIndex); - buffer = buffer.substring(newlineIndex + 1); - - // 去除行首尾空白(处理 \r\n 情况) - const trimmedLine = line.trim(); - - // 跳过空行(SSE 格式中的分隔符) - if (trimmedLine === '') { - continue; - } - - // 处理 SSE data: 前缀 - if (trimmedLine.startsWith('data:')) { - // 提取 data: 后的内容(注意:data: 后可能有空格也可能没有) - let jsonData = trimmedLine.substring(5); - // 去除前导空格 - if (jsonData.startsWith(' ')) { - jsonData = jsonData.substring(1); - } - jsonData = jsonData.trim(); - - // 检查流结束标记 - if (jsonData === '[DONE]') { - return; // 流结束 - } - - // 跳过空数据 - if (jsonData === '') { - continue; - } - - try { - const parsedChunk = JSON.parse(jsonData); - yield parsedChunk; - } catch (e) { - // JSON 解析失败,记录警告但继续处理 - logger.warn("[iFlow] Failed to parse stream chunk JSON:", e.message, "Data:", jsonData.substring(0, 200)); - } - } - // 忽略其他 SSE 字段(如 event:, id:, retry: 等) - } - } - - // 处理缓冲区中剩余的数据(如果有的话) - if (buffer.trim() !== '') { - const trimmedLine = buffer.trim(); - if (trimmedLine.startsWith('data:')) { - let jsonData = trimmedLine.substring(5); - if (jsonData.startsWith(' ')) { - jsonData = jsonData.substring(1); - } - jsonData = jsonData.trim(); - - if (jsonData !== '[DONE]' && jsonData !== '') { - try { - const parsedChunk = JSON.parse(jsonData); - yield parsedChunk; - } catch (e) { - logger.warn("[iFlow] Failed to parse final stream chunk JSON:", e.message); - } - } - } - } - } catch (error) { - const status = error.response?.status; - const data = error.response?.data; - const errorCode = error.code; - const errorMessage = error.message || ''; - - // 检查是否为可重试的网络错误 - const isNetworkError = isRetryableNetworkError(error); - - // Handle 401/400 during stream - refresh auth and retry once - if ((status === 400 || status === 401) && !isRetry) { - logger.info(`[iFlow] Received ${status} during stream. Triggering background refresh via PoolManager...`); - - // 标记当前凭证为不健康(会自动进入刷新队列) - const poolManager = getProviderPoolManager(); - if (poolManager && this.uuid) { - logger.info(`[iFlow] Marking credential ${this.uuid} as needs refresh. Reason: ${status} Unauthorized in stream`); - poolManager.markProviderNeedRefresh(MODEL_PROVIDER.IFLOW_API, { - uuid: this.uuid - }); - error.credentialMarkedUnhealthy = true; - } - - // Mark error for credential switch without recording error count - error.shouldSwitchCredential = true; - error.skipErrorCount = true; - throw error; - } - - if (status === 401 || status === 403) { - logger.error(`[iFlow] Received ${status} during stream. API Key might be invalid or expired.`); - throw error; - } - - // Handle 429 (Too Many Requests) with exponential backoff - if (status === 429 && retryCount < maxRetries) { - const delay = baseDelay * Math.pow(2, retryCount); - logger.info(`[iFlow] Received 429 (Too Many Requests) during stream. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, delay)); - yield* this.streamApi(endpoint, body, model, isRetry, retryCount + 1); - return; - } - - // Handle other retryable errors (5xx server errors) - if (status >= 500 && status < 600 && retryCount < maxRetries) { - const delay = baseDelay * Math.pow(2, retryCount); - logger.info(`[iFlow] Received ${status} server error during stream. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, delay)); - yield* this.streamApi(endpoint, body, model, isRetry, retryCount + 1); - return; - } - - // Handle network errors (ECONNRESET, ETIMEDOUT, etc.) with exponential backoff - if (isNetworkError && retryCount < maxRetries) { - const delay = baseDelay * Math.pow(2, retryCount); - const errorIdentifier = errorCode || errorMessage.substring(0, 50); - logger.info(`[iFlow] Network error (${errorIdentifier}) during stream. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, delay)); - yield* this.streamApi(endpoint, body, model, isRetry, retryCount + 1); - return; - } - - logger.error(`[iFlow] Error calling streaming API (Status: ${status}, Code: ${errorCode}):`, errorMessage); - throw error; - } - } - - /** - * 生成内容 - */ - async generateContent(model, requestBody) { - if (!this.isInitialized) { - await this.initialize(); - } - - // 临时存储 monitorRequestId - if (requestBody._monitorRequestId) { - this.config._monitorRequestId = requestBody._monitorRequestId; - delete requestBody._monitorRequestId; - } - if (requestBody._requestBaseUrl) { - delete requestBody._requestBaseUrl; - } - - // 检查 token 是否即将过期,如果是则推送到刷新队列 - if (this.isExpiryDateNear()) { - const poolManager = getProviderPoolManager(); - if (poolManager && this.uuid) { - logger.info(`[iFlow] Token is near expiry, marking credential ${this.uuid} for refresh`); - poolManager.markProviderNeedRefresh(MODEL_PROVIDER.IFLOW_API, { - uuid: this.uuid - }); - } - } - - return this.callApi('/chat/completions', requestBody, model); - } - - /** - * 流式生成内容 - */ - async *generateContentStream(model, requestBody) { - if (!this.isInitialized) { - await this.initialize(); - } - - // 临时存储 monitorRequestId - if (requestBody._monitorRequestId) { - this.config._monitorRequestId = requestBody._monitorRequestId; - delete requestBody._monitorRequestId; - } - if (requestBody._requestBaseUrl) { - delete requestBody._requestBaseUrl; - } - - // 检查 token 是否即将过期,如果是则推送到刷新队列 - if (this.isExpiryDateNear()) { - const poolManager = getProviderPoolManager(); - if (poolManager && this.uuid) { - logger.info(`[iFlow] Token is near expiry, marking credential ${this.uuid} for refresh`); - poolManager.markProviderNeedRefresh(MODEL_PROVIDER.IFLOW_API, { - uuid: this.uuid - }); - } - } - - yield* this.streamApi('/chat/completions', requestBody, model); - } - - /** - * 列出可用模型 - */ - async listModels() { - if (!this.isInitialized) { - await this.initialize(); - } - - // 需要手动添加的模型列表 - const manualModels = ['glm-4.7', 'glm-5', 'kimi-k2.5', 'minimax-m2.1', 'minimax-m2.5']; - - try { - const response = await this.axiosInstance.get('/models', { - headers: this._getHeaders(false) - }); - - // 检查返回数据中是否包含手动添加的模型,如果没有则添加 - const modelsData = response.data; - if (modelsData && modelsData.data && Array.isArray(modelsData.data)) { - for (const modelId of manualModels) { - const hasModel = modelsData.data.some(model => model.id === modelId); - if (!hasModel) { - // 添加模型到返回列表 - modelsData.data.push({ - id: modelId, - object: 'model', - created: Math.floor(Date.now() / 1000), - owned_by: 'iflow' - }); - logger.info(`[iFlow] Added ${modelId} to models list`); - } - } - } - - return modelsData; - } catch (error) { - logger.warn('[iFlow] Failed to fetch models from API, using default list:', error.message); - // 返回默认模型列表,确保包含手动添加的模型 - const defaultModels = [...IFLOW_MODELS]; - for (const modelId of manualModels) { - if (!defaultModels.includes(modelId)) { - defaultModels.push(modelId); - } - } - return { - object: 'list', - data: defaultModels.map(id => ({ - id, - object: 'model', - created: Math.floor(Date.now() / 1000), - owned_by: 'iflow' - })) - }; - } - } - -} - -export { - IFLOW_MODELS, - IFLOW_USER_AGENT, - IFlowTokenStorage, - loadTokenFromFile, - saveTokenToFile, - refreshOAuthTokens, - fetchUserInfo, - isThinkingModel, - applyIFlowThinkingConfig, - preserveReasoningContentInMessages, - ensureToolsArray, - preprocessRequestBody, -}; diff --git a/src/providers/openai/openai-core.js b/src/providers/openai/openai-core.js deleted file mode 100644 index 6fe800a9ecf253288158107cf6e1d79ae4d7b92c..0000000000000000000000000000000000000000 --- a/src/providers/openai/openai-core.js +++ /dev/null @@ -1,244 +0,0 @@ -import axios from 'axios'; -import logger from '../../utils/logger.js'; -import * as http from 'http'; -import * as https from 'https'; -import { configureAxiosProxy, configureTLSSidecar } from '../../utils/proxy-utils.js'; -import { isRetryableNetworkError, MODEL_PROVIDER } from '../../utils/common.js'; - -// Assumed OpenAI API specification service for interacting with third-party models -export class OpenAIApiService { - constructor(config) { - if (!config.OPENAI_API_KEY) { - throw new Error("OpenAI API Key is required for OpenAIApiService."); - } - this.config = config; - this.apiKey = config.OPENAI_API_KEY; - this.baseUrl = config.OPENAI_BASE_URL; - this.useSystemProxy = config?.USE_SYSTEM_PROXY_OPENAI ?? false; - logger.info(`[OpenAI] System proxy ${this.useSystemProxy ? 'enabled' : 'disabled'}`); - - // 配置 HTTP/HTTPS agent 限制连接池大小,避免资源泄漏 - const httpAgent = new http.Agent({ - keepAlive: true, - maxSockets: 100, - maxFreeSockets: 5, - timeout: 120000, - }); - const httpsAgent = new https.Agent({ - keepAlive: true, - maxSockets: 100, - maxFreeSockets: 5, - timeout: 120000, - }); - - const axiosConfig = { - baseURL: this.baseUrl, - httpAgent, - httpsAgent, - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${this.apiKey}` - }, - }; - - // 禁用系统代理以避免HTTPS代理错误 - if (!this.useSystemProxy) { - axiosConfig.proxy = false; - } - - // 配置自定义代理 - configureAxiosProxy(axiosConfig, config, MODEL_PROVIDER.OPENAI_CUSTOM); - - this.axiosInstance = axios.create(axiosConfig); - } - - _applySidecar(axiosConfig) { - return configureTLSSidecar(axiosConfig, this.config, MODEL_PROVIDER.OPENAI_CUSTOM, this.baseUrl); - } - - async callApi(endpoint, body, isRetry = false, retryCount = 0) { - const maxRetries = this.config.REQUEST_MAX_RETRIES || 3; - const baseDelay = this.config.REQUEST_BASE_DELAY || 1000; // 1 second base delay - - try { - const axiosConfig = { - method: 'post', - url: endpoint, - data: body - }; - this._applySidecar(axiosConfig); - const response = await this.axiosInstance.request(axiosConfig); - return response.data; - } catch (error) { - const status = error.response?.status; - const data = error.response?.data; - const errorCode = error.code; - const errorMessage = error.message || ''; - - // 检查是否为可重试的网络错误 - const isNetworkError = isRetryableNetworkError(error); - - if (status === 401 || status === 403) { - logger.error(`[OpenAI API] Received ${status}. API Key might be invalid or expired.`); - throw error; - } - - // Handle 429 (Too Many Requests) with exponential backoff - if (status === 429 && retryCount < maxRetries) { - const delay = baseDelay * Math.pow(2, retryCount); - logger.info(`[OpenAI API] Received 429 (Too Many Requests). Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, delay)); - return this.callApi(endpoint, body, isRetry, retryCount + 1); - } - - // Handle other retryable errors (5xx server errors) - if (status >= 500 && status < 600 && retryCount < maxRetries) { - const delay = baseDelay * Math.pow(2, retryCount); - logger.info(`[OpenAI API] Received ${status} server error. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, delay)); - return this.callApi(endpoint, body, isRetry, retryCount + 1); - } - - // Handle network errors (ECONNRESET, ETIMEDOUT, etc.) with exponential backoff - if (isNetworkError && retryCount < maxRetries) { - const delay = baseDelay * Math.pow(2, retryCount); - const errorIdentifier = errorCode || errorMessage.substring(0, 50); - logger.info(`[OpenAI API] Network error (${errorIdentifier}). Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, delay)); - return this.callApi(endpoint, body, isRetry, retryCount + 1); - } - - logger.error(`[OpenAI API] Error calling API (Status: ${status}, Code: ${errorCode}):`, errorMessage); - throw error; - } - } - - async *streamApi(endpoint, body, isRetry = false, retryCount = 0) { - const maxRetries = this.config.REQUEST_MAX_RETRIES || 3; - const baseDelay = this.config.REQUEST_BASE_DELAY || 1000; // 1 second base delay - - // OpenAI 的流式请求需要将 stream 设置为 true - const streamRequestBody = { ...body, stream: true }; - - try { - const axiosConfig = { - method: 'post', - url: endpoint, - data: streamRequestBody, - responseType: 'stream' - }; - this._applySidecar(axiosConfig); - const response = await this.axiosInstance.request(axiosConfig); - - const stream = response.data; - let buffer = ''; - - for await (const chunk of stream) { - buffer += chunk.toString(); - let newlineIndex; - while ((newlineIndex = buffer.indexOf('\n')) !== -1) { - const line = buffer.substring(0, newlineIndex).trim(); - buffer = buffer.substring(newlineIndex + 1); - - if (line.startsWith('data: ')) { - const jsonData = line.substring(6).trim(); - if (jsonData === '[DONE]') { - return; // Stream finished - } - try { - const parsedChunk = JSON.parse(jsonData); - yield parsedChunk; - } catch (e) { - logger.warn("[OpenAIApiService] Failed to parse stream chunk JSON:", e.message, "Data:", jsonData); - } - } else if (line === '') { - // Empty line, end of an event - } - } - } - } catch (error) { - const status = error.response?.status; - const data = error.response?.data; - const errorCode = error.code; - const errorMessage = error.message || ''; - - // 检查是否为可重试的网络错误 - const isNetworkError = isRetryableNetworkError(error); - - if (status === 401 || status === 403) { - logger.error(`[OpenAI API] Received ${status} during stream. API Key might be invalid or expired.`); - throw error; - } - - // Handle 429 (Too Many Requests) with exponential backoff - if (status === 429 && retryCount < maxRetries) { - const delay = baseDelay * Math.pow(2, retryCount); - logger.info(`[OpenAI API] Received 429 (Too Many Requests) during stream. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, delay)); - yield* this.streamApi(endpoint, body, isRetry, retryCount + 1); - return; - } - - // Handle other retryable errors (5xx server errors) - if (status >= 500 && status < 600 && retryCount < maxRetries) { - const delay = baseDelay * Math.pow(2, retryCount); - logger.info(`[OpenAI API] Received ${status} server error during stream. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, delay)); - yield* this.streamApi(endpoint, body, isRetry, retryCount + 1); - return; - } - - // Handle network errors (ECONNRESET, ETIMEDOUT, etc.) with exponential backoff - if (isNetworkError && retryCount < maxRetries) { - const delay = baseDelay * Math.pow(2, retryCount); - const errorIdentifier = errorCode || errorMessage.substring(0, 50); - logger.info(`[OpenAI API] Network error (${errorIdentifier}) during stream. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, delay)); - yield* this.streamApi(endpoint, body, isRetry, retryCount + 1); - return; - } - - logger.error(`[OpenAI API] Error calling streaming API (Status: ${status}, Code: ${errorCode}):`, errorMessage); - throw error; - } - } - - async generateContent(model, requestBody) { - // 临时存储 monitorRequestId - if (requestBody._monitorRequestId) { - this.config._monitorRequestId = requestBody._monitorRequestId; - delete requestBody._monitorRequestId; - } - if (requestBody._requestBaseUrl) { - delete requestBody._requestBaseUrl; - } - - return this.callApi('/chat/completions', requestBody); - } - - async *generateContentStream(model, requestBody) { - // 临时存储 monitorRequestId - if (requestBody._monitorRequestId) { - this.config._monitorRequestId = requestBody._monitorRequestId; - delete requestBody._monitorRequestId; - } - if (requestBody._requestBaseUrl) { - delete requestBody._requestBaseUrl; - } - - yield* this.streamApi('/chat/completions', requestBody); - } - - async listModels() { - try { - const response = await this.axiosInstance.get('/models'); - return response.data; - } catch (error) { - const status = error.response?.status; - const data = error.response?.data; - logger.error(`Error listing OpenAI models (Status: ${status}):`, data || error.message); - throw error; - } - } -} - diff --git a/src/providers/openai/openai-responses-core.js b/src/providers/openai/openai-responses-core.js deleted file mode 100644 index d12cbad4dc96fd50138dca6cdb94c9b88c5b5902..0000000000000000000000000000000000000000 --- a/src/providers/openai/openai-responses-core.js +++ /dev/null @@ -1,217 +0,0 @@ -import axios from 'axios'; -import logger from '../../utils/logger.js'; -import * as http from 'http'; -import * as https from 'https'; -import { configureAxiosProxy, configureTLSSidecar } from '../../utils/proxy-utils.js'; -import { MODEL_PROVIDER } from '../../utils/common.js'; - -// OpenAI Responses API specification service for interacting with third-party models -export class OpenAIResponsesApiService { - constructor(config) { - if (!config.OPENAI_API_KEY) { - throw new Error("OpenAI API Key is required for OpenAIResponsesApiService."); - } - this.config = config; - this.apiKey = config.OPENAI_API_KEY; - this.baseUrl = config.OPENAI_BASE_URL || 'https://api.openai.com/v1'; - this.useSystemProxy = config?.USE_SYSTEM_PROXY_OPENAI ?? false; - logger.info(`[OpenAIResponses] System proxy ${this.useSystemProxy ? 'enabled' : 'disabled'}`); - - // 配置 HTTP/HTTPS agent 限制连接池大小,避免资源泄漏 - const httpAgent = new http.Agent({ - keepAlive: true, - maxSockets: 100, - maxFreeSockets: 5, - timeout: 120000, - }); - const httpsAgent = new https.Agent({ - keepAlive: true, - maxSockets: 100, - maxFreeSockets: 5, - timeout: 120000, - }); - - const axiosConfig = { - baseURL: this.baseUrl, - httpAgent, - httpsAgent, - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${this.apiKey}` - } - }; - - // 禁用系统代理以避免HTTPS代理错误 - if (!this.useSystemProxy) { - axiosConfig.proxy = false; - } - - // 配置自定义代理 (使用 openai-custom 的代理配置) - configureAxiosProxy(axiosConfig, config, MODEL_PROVIDER.OPENAI_CUSTOM_RESPONSES); - - this.axiosInstance = axios.create(axiosConfig); - } - - _applySidecar(axiosConfig) { - return configureTLSSidecar(axiosConfig, this.config, MODEL_PROVIDER.OPENAI_CUSTOM_RESPONSES, this.baseUrl); - } - - async callApi(endpoint, body, isRetry = false, retryCount = 0) { - const maxRetries = this.config.REQUEST_MAX_RETRIES || 3; - const baseDelay = this.config.REQUEST_BASE_DELAY || 1000; // 1 second base delay - - try { - const axiosConfig = { - method: 'post', - url: endpoint, - data: body - }; - this._applySidecar(axiosConfig); - const response = await this.axiosInstance.request(axiosConfig); - return response.data; - } catch (error) { - const status = error.response?.status; - const data = error.response?.data; - if (status === 401 || status === 403) { - logger.error(`[API] Received ${status}. API Key might be invalid or expired.`); - throw error; - } - - // Handle 429 (Too Many Requests) with exponential backoff - if (status === 429 && retryCount < maxRetries) { - const delay = baseDelay * Math.pow(2, retryCount); - logger.info(`[API] Received 429 (Too Many Requests). Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, delay)); - return this.callApi(endpoint, body, isRetry, retryCount + 1); - } - - // Handle other retryable errors (5xx server errors) - if (status >= 500 && status < 600 && retryCount < maxRetries) { - const delay = baseDelay * Math.pow(2, retryCount); - logger.info(`[API] Received ${status} server error. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, delay)); - return this.callApi(endpoint, body, isRetry, retryCount + 1); - } - - logger.error(`Error calling OpenAI Responses API (Status: ${status}):`, error.message); - throw error; - } - } - - async *streamApi(endpoint, body, isRetry = false, retryCount = 0) { - const maxRetries = this.config.REQUEST_MAX_RETRIES || 3; - const baseDelay = this.config.REQUEST_BASE_DELAY || 1000; // 1 second base delay - - // OpenAI 的流式请求需要将 stream 设置为 true - const streamRequestBody = { ...body, stream: true }; - - try { - const axiosConfig = { - method: 'post', - url: endpoint, - data: streamRequestBody, - responseType: 'stream' - }; - this._applySidecar(axiosConfig); - const response = await this.axiosInstance.request(axiosConfig); - - const stream = response.data; - let buffer = ''; - - for await (const chunk of stream) { - buffer += chunk.toString(); - let newlineIndex; - while ((newlineIndex = buffer.indexOf('\n')) !== -1) { - const line = buffer.substring(0, newlineIndex).trim(); - buffer = buffer.substring(newlineIndex + 1); - - if (line.startsWith('data: ')) { - const jsonData = line.substring(6).trim(); - if (jsonData === '[DONE]') { - return; // Stream finished - } - try { - const parsedChunk = JSON.parse(jsonData); - yield parsedChunk; - } catch (e) { - logger.warn("[OpenAIResponsesApiService] Failed to parse stream chunk JSON:", e.message, "Data:", jsonData); - } - } else if (line === '') { - // Empty line, end of an event - } - } - } - } catch (error) { - const status = error.response?.status; - const data = error.response?.data; - if (status === 401 || status === 403) { - logger.error(`[API] Received ${status} during stream. API Key might be invalid or expired.`); - throw error; - } - - // Handle 429 (Too Many Requests) with exponential backoff - if (status === 429 && retryCount < maxRetries) { - const delay = baseDelay * Math.pow(2, retryCount); - logger.info(`[API] Received 429 (Too Many Requests) during stream. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, delay)); - yield* this.streamApi(endpoint, body, isRetry, retryCount + 1); - return; - } - - // Handle other retryable errors (5xx server errors) - if (status >= 500 && status < 600 && retryCount < maxRetries) { - const delay = baseDelay * Math.pow(2, retryCount); - logger.info(`[API] Received ${status} server error during stream. Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, delay)); - yield* this.streamApi(endpoint, body, isRetry, retryCount + 1); - return; - } - - logger.error(`Error calling OpenAI Responses streaming API (Status: ${status}):`, error.message); - throw error; - } - } - - async generateContent(model, requestBody) { - // 临时存储 monitorRequestId - if (requestBody._monitorRequestId) { - this.config._monitorRequestId = requestBody._monitorRequestId; - delete requestBody._monitorRequestId; - } - if (requestBody._requestBaseUrl) { - delete requestBody._requestBaseUrl; - } - - return this.callApi('/responses', requestBody); - } - - async *generateContentStream(model, requestBody) { - // 临时存储 monitorRequestId - if (requestBody._monitorRequestId) { - this.config._monitorRequestId = requestBody._monitorRequestId; - delete requestBody._monitorRequestId; - } - if (requestBody._requestBaseUrl) { - delete requestBody._requestBaseUrl; - } - - yield* this.streamApi('/responses', requestBody); - } - - async listModels() { - try { - const axiosConfig = { - method: 'get', - url: '/models' - }; - this._applySidecar(axiosConfig); - const response = await this.axiosInstance.request(axiosConfig); - return response.data; - } catch (error) { - const status = error.response?.status; - const data = error.response?.data; - logger.error(`Error listing OpenAI Responses models (Status: ${status}):`, data || error.message); - throw error; - } - } -} diff --git a/src/providers/openai/openai-responses-core.mjs b/src/providers/openai/openai-responses-core.mjs deleted file mode 100644 index 20697859d92e4d41bd8c158b7d3d5e392aa7b0b6..0000000000000000000000000000000000000000 --- a/src/providers/openai/openai-responses-core.mjs +++ /dev/null @@ -1,329 +0,0 @@ -import { v4 as uuidv4 } from 'uuid'; - -// 流式处理状态管理 -class StreamState { - constructor() { - this.states = new Map(); // 使用Map存储不同请求的状态 - } - - // 获取或创建状态 - getOrCreateState(requestId) { - if (!this.states.has(requestId)) { - this.states.set(requestId, { - id: `resp_${uuidv4().replace(/-/g, '')}`, - msgId: `msg_${uuidv4().replace(/-/g, '')}`, - fullText: '', - sequenceNumber: 0, - model: null, - status: 'in_progress', - startTime: Math.floor(Date.now() / 1000) - }); - } - return this.states.get(requestId); - } - - // 更新文本内容 - updateText(requestId, textDelta) { - const state = this.getOrCreateState(requestId); - state.fullText += textDelta; - state.sequenceNumber += 1; - return state; - } - - // 设置模型信息 - setModel(requestId, model) { - const state = this.getOrCreateState(requestId); - state.model = model; - return state; - } - - // 完成请求 - completeRequest(requestId) { - const state = this.getOrCreateState(requestId); - state.status = 'completed'; - return state; - } - - // 清理状态 - cleanup(requestId) { - this.states.delete(requestId); - } -} - -// 创建全局流式状态管理器 -const streamStateManager = new StreamState(); - -/** - * Generates a response.created event - */ -function generateResponseCreated(requestId, model) { - const state = streamStateManager.getOrCreateState(requestId); - if (model) { - state.model = model; - } - - return { - type: 'response.created', - response: { - id: state.id, - object: 'response', - created_at: state.startTime, - status: 'in_progress', - error: null, - incomplete_details: null, - instructions: '', - max_output_tokens: null, - model: state.model || 'gpt-4.1-2025-04-14', - output: [], - parallel_tool_calls: true, - previous_response_id: null, - reasoning: { }, - store: false, - temperature: 1, - text: { format: { type: "text" }}, - tool_choice: "auto", - tools: [], - top_logprobs: 0, - top_p: 1, - truncation: "disabled", - usage: null, - user: null, - metadata: {} - } - }; -} - -/** - * Generates a response.in_progress event - */ -function generateResponseInProgress(requestId) { - const state = streamStateManager.getOrCreateState(requestId); - - return { - type: 'response.in_progress', - response: { - id: state.id, - object: 'response', - created_at: state.startTime, - status: 'in_progress', - error: null, - incomplete_details: null, - instructions: '', - max_output_tokens: null, - model: state.model || 'gpt-4.1-2025-04-14', - output: [], - parallel_tool_calls: true, - previous_response_id: null, - reasoning: { }, - service_tier: "auto", - store: false, - temperature: 1, - text: { format: { type: "text" }}, - tool_choice: "auto", - tools: [], - top_logprobs: 0, - top_p: 1, - truncation: "disabled", - usage: null, - user: null, - metadata: {} - } - }; -} - -/** - * Generates a response.output_item.added event - */ -function generateOutputItemAdded(requestId) { - const state = streamStateManager.getOrCreateState(requestId); - - return { - type: 'response.output_item.added', - output_index: 0, - item: { - id: state.msgId, - summary: [], - type: 'message', - role: 'assistant', - status: 'in_progress', - content: [] - } - }; -} - -/** - * Generates a response.content_part.added event - */ -function generateContentPartAdded(requestId) { - const state = streamStateManager.getOrCreateState(requestId); - - return { - type: 'response.content_part.added', - item_id: state.msgId, - output_index: 0, - content_index: 0, - part: { - type: 'output_text', - text: '', - annotations: [], - logprobs: [] - } - }; -} - -/** - * Generates a response.output_text.delta event - */ -function generateOutputTextDelta(requestId, delta) { - const state = streamStateManager.getOrCreateState(requestId); - state.fullText += delta; - - return { - type: 'response.output_text.delta', - item_id: state.msgId, - output_index: 0, - content_index: 0, - delta: delta, - logprobs: [], - obfuscation: null - }; -} - -/** - * Generates a response.output_text.done event - */ -function generateOutputTextDone(requestId) { - const state = streamStateManager.getOrCreateState(requestId); - - return { - type: 'response.output_text.done', - item_id: state.msgId, - output_index: 0, - content_index: 0, - text: state.fullText, - logprobs: [] - }; -} - -/** - * Generates a response.content_part.done event - */ -function generateContentPartDone(requestId) { - const state = streamStateManager.getOrCreateState(requestId); - - return { - type: 'response.content_part.done', - item_id: state.msgId, - output_index: 0, - content_index: 0, - part: { - type: 'output_text', - text: state.fullText, - annotations: [], - logprobs: [] - } - }; -} - -/** - * Generates a response.output_item.done event - */ -function generateOutputItemDone(requestId) { - const state = streamStateManager.getOrCreateState(requestId); - - return { - type: 'response.output_item.done', - output_index: 0, - item: { - id: state.msgId, - summary: [], - type: 'message', - role: 'assistant', - status: 'completed', - content: [ - { - type: 'output_text', - text: state.fullText, - annotations: [], - logprobs: [] - } - ] - } - }; -} - -/** - * Generates a response.completed event - */ -function generateResponseCompleted(requestId, usage) { - const state = streamStateManager.getOrCreateState(requestId); - - return { - type: 'response.completed', - response: { - background: false, - created_at: state.startTime, - error: null, - id: state.id, - incomplete_details: null, - max_output_tokens: null, - max_tool_calls: null, - metadata: {}, - model: state.model || 'gpt-4.1-2025-04-14', - object: 'response', - output: [ - { - id: state.msgId, - summary: [], - type: 'message', - role: 'assistant', - status: 'completed', - content: [ - { - type: 'output_text', - text: state.fullText, - annotations: [], - logprobs: [] - } - ] - } - ], - parallel_tool_calls: true, - previous_response_id: null, - prompt_cache_key: null, - reasoning: { - }, - safety_identifier: `user-${uuidv4().replace(/-/g, '')}`, // 随机值 - service_tier: "default", - status: "completed", - store: false, - temperature: 1, - text: { - format: { type: "text" } - }, - tool_choice: "auto", - tools: [], - top_logprobs: 0, - top_p: 1, - truncation: "disabled", - usage: usage || { - input_tokens: Math.floor(Math.random() * 100) + 20, // 随机值 - input_tokens_details: { - cached_tokens: Math.floor(Math.random() * 50) // 随机值 - }, - output_tokens: state.fullText.split('').length, - output_tokens_details: { - reasoning_tokens: 0 - }, - total_tokens: Math.floor(Math.random() * 100) + 20 + state.fullText.split('').length // 随机值+文本长度 - }, - user: null - } - }; -} - -// 导出流式状态管理器以供外部使用 -export { streamStateManager, generateResponseCreated, generateResponseInProgress, - generateOutputItemAdded, generateContentPartAdded, generateOutputTextDelta, - generateOutputTextDone, generateContentPartDone, generateOutputItemDone, - generateResponseCompleted }; \ No newline at end of file diff --git a/src/providers/openai/openai-responses-strategy.js b/src/providers/openai/openai-responses-strategy.js deleted file mode 100644 index 27f90802c140b545b55f4a68329dc5ef1d8cbeee..0000000000000000000000000000000000000000 --- a/src/providers/openai/openai-responses-strategy.js +++ /dev/null @@ -1,124 +0,0 @@ -import { ProviderStrategy } from '../../utils/provider-strategy.js'; -import logger from '../../utils/logger.js'; -import { extractSystemPromptFromRequestBody, MODEL_PROTOCOL_PREFIX } from '../../utils/common.js'; - -/** - * OpenAI Responses API strategy implementation. - * Migrated from Chat Completions API to Responses API. - */ -class ResponsesAPIStrategy extends ProviderStrategy { - extractModelAndStreamInfo(req, requestBody) { - const model = requestBody.model; - const isStream = requestBody.stream === true; - return { model, isStream }; - } - - extractResponseText(response) { - if (!response.output) { - return ''; - } - - // In Responses API, output is an array of items - for (const item of response.output) { - if (item.type === 'message' && item.content && item.content.length > 0) { - for (const content of item.content) { - if (content.type === 'output_text' && content.text) { - return content.text; - } - } - } - } - return ''; - } - - extractPromptText(requestBody) { - // In Responses API, input can be a string or array of items - if (typeof requestBody.input === 'string') { - return requestBody.input; - } else if (Array.isArray(requestBody.input)) { - // If input is an array of items/messages, get the last user content - const userInputItems = requestBody.input.filter(item => - (item.role && item.role === 'user') || - (item.type && item.type === 'message' && item.role === 'user') || - (item.type && item.type === 'user') - ); - - if (userInputItems.length > 0) { - const lastInput = userInputItems[userInputItems.length - 1]; - if (typeof lastInput.content === 'string') { - return lastInput.content; - } else if (Array.isArray(lastInput.content)) { - return lastInput.content.map(item => item.text || item.content || '').join('\n'); - } - } - } - return ''; - } - - async applySystemPromptFromFile(config, requestBody) { - if (!config.SYSTEM_PROMPT_FILE_PATH) { - return requestBody; - } - - const filePromptContent = config.SYSTEM_PROMPT_CONTENT; - if (filePromptContent === null) { - return requestBody; - } - - // In Responses API, system instructions are typically passed in 'instructions' field - // or in the input array with role: 'system' - requestBody.instructions = requestBody.instructions || filePromptContent; - - // If using instructions field is not desired, append to input array instead - if (!requestBody.instructions || config.SYSTEM_PROMPT_MODE === 'append') { - if (typeof requestBody.input === 'string') { - // Convert to array format to add system message - requestBody.input = [ - { role: 'system', content: filePromptContent }, - { role: 'user', content: requestBody.input } - ]; - } else if (Array.isArray(requestBody.input)) { - // Check if system message already exists - const systemMessageIndex = requestBody.input.findIndex(m => - m.role === 'system' || (m.type && m.type === 'system') - ); - - if (systemMessageIndex !== -1) { - requestBody.input[systemMessageIndex].content = filePromptContent; - } else { - requestBody.input.unshift({ role: 'system', content: filePromptContent }); - } - } else { - // If input is not defined, initialize with system message - requestBody.input = [{ role: 'system', content: filePromptContent }]; - } - } else if (requestBody.instructions) { - // If system prompt mode is not append, then replace the instructions - requestBody.instructions = filePromptContent; - } - - logger.info(`[System Prompt] Applied system prompt from ${config.SYSTEM_PROMPT_FILE_PATH} in '${config.SYSTEM_PROMPT_MODE}' mode for provider 'responses'.`); - - return requestBody; - } - - async manageSystemPrompt(requestBody) { - // For Responses API, we may extract instructions or system messages from input - let incomingSystemText = ''; - - if (requestBody.instructions) { - incomingSystemText = requestBody.instructions; - } else if (Array.isArray(requestBody.input)) { - const systemMessage = requestBody.input.find(item => - item.role === 'system' || (item.type && item.type === 'system') - ); - if (systemMessage && systemMessage.content) { - incomingSystemText = systemMessage.content; - } - } - - await this._updateSystemPromptFile(incomingSystemText, MODEL_PROTOCOL_PREFIX.OPENAI); - } -} - -export { ResponsesAPIStrategy }; diff --git a/src/providers/openai/openai-strategy.js b/src/providers/openai/openai-strategy.js deleted file mode 100644 index 801b3d414e31e26b5c7b459694d1965a33b06f9a..0000000000000000000000000000000000000000 --- a/src/providers/openai/openai-strategy.js +++ /dev/null @@ -1,86 +0,0 @@ -import { ProviderStrategy } from '../../utils/provider-strategy.js'; -import logger from '../../utils/logger.js'; -import { extractSystemPromptFromRequestBody, MODEL_PROTOCOL_PREFIX } from '../../utils/common.js'; - -/** - * OpenAI provider strategy implementation. - */ -class OpenAIStrategy extends ProviderStrategy { - extractModelAndStreamInfo(req, requestBody) { - const model = requestBody.model; - const isStream = requestBody.stream === true; - return { model, isStream }; - } - - extractResponseText(response) { - if (!response.choices) { - return ''; - } - if (response.choices && response.choices.length > 0) { - const choice = response.choices[0]; - if (choice.message && choice.message.content) { - return choice.message.content; - } else if (choice.delta && choice.delta.content) { - return choice.delta.content; - } else if (choice.delta && choice.delta.tool_calls && choice.delta.tool_calls.length > 0) { - return choice.delta.tool_calls; - } - } - return ''; - } - - extractPromptText(requestBody) { - if (requestBody.messages && requestBody.messages.length > 0) { - const lastMessage = requestBody.messages[requestBody.messages.length - 1]; - let content = lastMessage.content; - if (typeof content === 'object' && content !== null) { - if (Array.isArray(content)) { - return content.map(item => item.text).join('\n'); - } else { - return JSON.stringify(content); - } - } - return content; - } - return ''; - } - - async applySystemPromptFromFile(config, requestBody) { - if (!config.SYSTEM_PROMPT_FILE_PATH) { - return requestBody; - } - - const filePromptContent = config.SYSTEM_PROMPT_CONTENT; - if (filePromptContent === null) { - return requestBody; - } - - const existingSystemText = extractSystemPromptFromRequestBody(requestBody, MODEL_PROTOCOL_PREFIX.OPENAI); - - const newSystemText = config.SYSTEM_PROMPT_MODE === 'append' && existingSystemText - ? `${existingSystemText}\n${filePromptContent}` - : filePromptContent; - - if (!requestBody.messages) { - requestBody.messages = []; - } - const systemMessageIndex = requestBody.messages.findIndex(m => m.role === 'system'); - if (systemMessageIndex !== -1) { - requestBody.messages[systemMessageIndex].content = newSystemText; - } else { - requestBody.messages.unshift({ role: 'system', content: newSystemText }); - } - logger.info(`[System Prompt] Applied system prompt from ${config.SYSTEM_PROMPT_FILE_PATH} in '${config.SYSTEM_PROMPT_MODE}' mode for provider 'openai'.`); - - return requestBody; - } - - async manageSystemPrompt(requestBody) { - //logger.info('[System Prompt] Managing system prompt for provider "openai".', requestBody); - const incomingSystemText = extractSystemPromptFromRequestBody(requestBody, MODEL_PROTOCOL_PREFIX.OPENAI); - await this._updateSystemPromptFile(incomingSystemText, MODEL_PROTOCOL_PREFIX.OPENAI); - } -} - -export { OpenAIStrategy }; - diff --git a/src/providers/openai/qwen-core.js b/src/providers/openai/qwen-core.js deleted file mode 100644 index 8d3163875bb4bbe13066b2398fd1e43e07ab88de..0000000000000000000000000000000000000000 --- a/src/providers/openai/qwen-core.js +++ /dev/null @@ -1,1118 +0,0 @@ -import axios from 'axios'; -import logger from '../../utils/logger.js'; -import crypto from 'crypto'; -import path from 'node:path'; -import { promises as fs, unlinkSync } from 'node:fs'; -import * as os from 'os'; -import * as http from 'http'; -import * as https from 'https'; -import open from 'open'; -import { EventEmitter } from 'events'; -import { randomUUID } from 'node:crypto'; -import { getProviderModels } from '../provider-models.js'; -import { handleQwenOAuth } from '../../auth/oauth-handlers.js'; -import { configureAxiosProxy, configureTLSSidecar } from '../../utils/proxy-utils.js'; -import { isRetryableNetworkError, MODEL_PROVIDER, formatExpiryLog } from '../../utils/common.js'; -import { getProviderPoolManager } from '../../services/service-manager.js'; - -// --- Constants --- -const QWEN_DIR = '.qwen'; -const QWEN_CREDENTIAL_FILENAME = 'oauth_creds.json'; -// 从 provider-models.js 获取支持的模型列表 -const QWEN_MODELS = getProviderModels(MODEL_PROVIDER.QWEN_API); -const QWEN_MODEL_LIST = QWEN_MODELS.map(id => ({ - id: id, - name: id.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ') -})); - -const TOKEN_REFRESH_BUFFER_MS = 30 * 1000; -const LOCK_TIMEOUT_MS = 10000; -const CACHE_CHECK_INTERVAL_MS = 1000; - -const DEFAULT_LOCK_CONFIG = { - maxAttempts: 50, - attemptInterval: 200, -}; - -const DEFAULT_QWEN_OAUTH_BASE_URL = 'https://chat.qwen.ai'; -const DEFAULT_QWEN_BASE_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1'; -const QWEN_OAUTH_CLIENT_ID = 'f0304373b74a44d2b584a3fb70ca9e56'; -const QWEN_OAUTH_SCOPE = 'openid profile email model.completion'; -const QWEN_OAUTH_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code'; - -export const QwenOAuth2Event = { - AuthUri: 'auth-uri', - AuthProgress: 'auth-progress', - AuthCancel: 'auth-cancel', -}; -export const qwenOAuth2Events = new EventEmitter(); - - -// --- Helper Functions --- - -// 封装公共的 await fetch 方法 -async function commonFetch(url, options = {}, useSystemProxy = false) { - const defaultOptions = { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - }, - }; - - // 合并默认选项和传入的选项 - const mergedOptions = { - ...defaultOptions, - ...options, - headers: { - ...defaultOptions.headers, - ...options.headers, - }, - }; - - // 如果不使用系统代理,设置空的代理配置 - // 注意: Node.js 的 fetch 实现会自动使用环境变量中的代理设置 - // 这里通过设置 agent 为 null 来尝试禁用代理 - if (!useSystemProxy && typeof mergedOptions.agent === 'undefined') { - // 对于 Node.js fetch,我们可以通过设置 dispatcher 来控制代理 - // 但这需要 undici 支持,这里我们先记录日志 - logger.debug('[Qwen] System proxy disabled for fetch request'); - } - - const response = await fetch(url, mergedOptions); - - // 检查响应是否成功 - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - const error = new Error(`HTTP ${response.status}: ${response.statusText}`); - error.status = response.status; - error.data = errorData; - throw error; - } - - // 返回 JSON 响应 - return await response.json(); -} - -function generateCodeVerifier() { - return crypto.randomBytes(32).toString('base64url'); -} - -function generateCodeChallenge(codeVerifier) { - const hash = crypto.createHash('sha256'); - hash.update(codeVerifier); - return hash.digest('base64url'); -} - -function generatePKCEPair() { - const codeVerifier = generateCodeVerifier(); - const codeChallenge = generateCodeChallenge(codeVerifier); - return { code_verifier: codeVerifier, code_challenge: codeChallenge }; -} - -function objectToUrlEncoded(data) { - return Object.keys(data) - .map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(data[key])}`) - .join('&'); -} - -function isDeviceAuthorizationSuccess(response) { - return 'device_code' in response; -} - -function isDeviceTokenSuccess(response) { - return ( - 'access_token' in response && - response.access_token !== null && - response.access_token !== undefined && - typeof response.access_token === 'string' && - response.access_token.length > 0 - ); -} - -function isDeviceTokenPending(response) { - return 'status' in response && response.status === 'pending'; -} - -function isErrorResponse(response) { - return 'error' in response; -} - - -// --- Error Classes --- - -export const TokenError = { - REFRESH_FAILED: 'REFRESH_FAILED', - NO_REFRESH_TOKEN: 'NO_REFRESH_TOKEN', - LOCK_TIMEOUT: 'LOCK_TIMEOUT', - FILE_ACCESS_ERROR: 'FILE_ACCESS_ERROR', - NETWORK_ERROR: 'NETWORK_ERROR', -}; - -export class TokenManagerError extends Error { - constructor(type, message, originalError) { - super(message); - this.type = type; - this.originalError = originalError; - this.name = 'TokenManagerError'; - } -} - -/** - * 自定义错误类,用于指示需要清除凭证 - * 当令牌刷新时发生 400 错误时抛出,表示刷新令牌已过期或无效 - */ -export class CredentialsClearRequiredError extends Error { - constructor(message, originalError) { - super(message); - this.name = 'CredentialsClearRequiredError'; - this.originalError = originalError; - } -} - - -// --- Core Service Class --- - -export class QwenApiService { - constructor(config) { - this.config = config; - this.isInitialized = false; - this.sharedManager = SharedTokenManager.getInstance(); - this.currentAxiosInstance = null; - this.tokenManagerOptions = { credentialFilePath: this._getQwenCachedCredentialPath() }; - this.useSystemProxy = config?.USE_SYSTEM_PROXY_QWEN ?? false; - this.uuid = config.uuid; // 保存 uuid 用于号池管理 - - // Initialize instance-specific endpoints - this.baseUrl = config.QWEN_BASE_URL || DEFAULT_QWEN_BASE_URL; - const oauthBaseUrl = config.QWEN_OAUTH_BASE_URL || DEFAULT_QWEN_OAUTH_BASE_URL; - this.oauthDeviceCodeEndpoint = `${oauthBaseUrl}/api/v1/oauth2/device/code`; - this.oauthTokenEndpoint = `${oauthBaseUrl}/api/v1/oauth2/token`; - - logger.info(`[Qwen] System proxy ${this.useSystemProxy ? 'enabled' : 'disabled'}`); - this.qwenClient = new QwenOAuth2Client(config, this.useSystemProxy); - } - - async initialize() { - if (this.isInitialized) return; - logger.info('[Qwen] Initializing Qwen API Service...'); - // 注意:V2 读写分离架构下,初始化不再执行同步认证/刷新逻辑 - // 仅执行基础的凭证加载 - await this.loadCredentials(); - - // 配置 HTTP/HTTPS agent 限制连接池大小,避免资源泄漏 - const httpAgent = new http.Agent({ - keepAlive: true, - maxSockets: 100, - maxFreeSockets: 5, - timeout: 120000, - }); - const httpsAgent = new https.Agent({ - keepAlive: true, - maxSockets: 100, - maxFreeSockets: 5, - timeout: 120000, - }); - - const axiosConfig = { - baseURL: this.baseUrl, - httpAgent, - httpsAgent, - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer `, - }, - }; - - // 禁用系统代理 - if (!this.useSystemProxy) { - axiosConfig.proxy = false; - } - - // 配置自定义代理 - configureAxiosProxy(axiosConfig, this.config, 'openai-qwen-oauth'); - - this.currentAxiosInstance = axios.create(axiosConfig); - - this.isInitialized = true; - logger.info('[Qwen] Initialization complete.'); - } - - _applySidecar(axiosConfig) { - return configureTLSSidecar(axiosConfig, this.config, MODEL_PROVIDER.QWEN_API, this.baseUrl); - } - - /** - * 加载凭证信息(不执行刷新) - */ - async loadCredentials() { - try { - const keyFile = this._getQwenCachedCredentialPath(); - const creds = await fs.readFile(keyFile, 'utf-8'); - const credentials = JSON.parse(creds); - this.qwenClient.setCredentials(credentials); - logger.info('[Qwen Auth] Credentials loaded successfully from file.'); - } catch (error) { - if (error.code === 'ENOENT') { - logger.debug('[Qwen Auth] No cached credentials found.'); - } else { - logger.warn(`[Qwen Auth] Failed to load credentials from file: ${error.message}`); - } - } - } - - async _initializeAuth(forceRefresh = false) { - // 首先执行基础凭证加载 - await this.loadCredentials(); - - try { - const credentials = await this.sharedManager.getValidCredentials( - this.qwenClient, - forceRefresh, - this.tokenManagerOptions, - ); - // logger.info('credentials', credentials); - this.qwenClient.setCredentials(credentials); - - // 如果执行了刷新或认证,重置状态 - if (forceRefresh || (credentials && credentials.access_token)) { - const poolManager = getProviderPoolManager(); - if (poolManager && this.uuid) { - poolManager.resetProviderRefreshStatus(MODEL_PROVIDER.QWEN_API, this.uuid); - } - } - } catch (error) { - logger.debug('Shared token manager failed, attempting device flow:', error); - - if (error instanceof TokenManagerError) { - switch (error.type) { - case TokenError.NO_REFRESH_TOKEN: - logger.debug('No refresh token available, proceeding with device flow'); - break; - case TokenError.REFRESH_FAILED: - logger.debug('Token refresh failed, proceeding with device flow'); - break; - case TokenError.NETWORK_ERROR: - logger.warn('Network error during token refresh, trying device flow'); - break; - default: - logger.warn('Token manager error:', error.message); - } - } - - // If cached credentials are present and still valid, use them directly. - if (await this._loadCachedQwenCredentials(this.qwenClient)) { - logger.info('[Qwen] Using cached OAuth credentials.'); - return; - } - - // Otherwise, run device authorization flow to obtain fresh credentials. - const result = await this._authWithQwenDeviceFlow(this.qwenClient, this.config); - if (!result.success) { - if (result.reason === 'timeout') { - qwenOAuth2Events.emit( - QwenOAuth2Event.AuthProgress, - 'timeout', - 'Authentication timed out. Please try again or select a different authentication method.', - ); - } - switch (result.reason) { - case 'timeout': - throw new Error('Qwen OAuth authentication timed out'); - case 'cancelled': - throw new Error('Qwen OAuth authentication was cancelled by user'); - case 'rate_limit': - throw new Error('Too many request for Qwen OAuth authentication, please try again later.'); - case 'error': - default: - throw new Error('Qwen OAuth authentication failed'); - } - } else { - // 认证成功,重置状态 - const poolManager = getProviderPoolManager(); - if (poolManager && this.uuid) { - poolManager.resetProviderRefreshStatus(MODEL_PROVIDER.QWEN_API, this.uuid); - } - } - } - } - - /** - * 实现与其它 provider 统一的 initializeAuth 接口 - */ - async initializeAuth(forceRefresh = false) { - return this._initializeAuth(forceRefresh); - } - - async _authWithQwenDeviceFlow(client, config) { - try { - // 使用统一的 OAuth 处理方法 - const { authUrl, authInfo } = await handleQwenOAuth(config); - - // 发送授权 URI 事件 - qwenOAuth2Events.emit(QwenOAuth2Event.AuthUri, { - verification_uri_complete: authUrl, - user_code: authInfo.userCode, - verification_uri: authInfo.verificationUri, - device_code: authInfo.deviceCode, - expires_in: authInfo.expiresIn, - interval: authInfo.interval - }); - - const showFallbackMessage = () => { - logger.info('\n=== Qwen OAuth Device Authorization ==='); - logger.info('Please visit the following URL in your browser to authorize:'); - logger.info(`\n${authUrl}\n`); - logger.info('Waiting for authorization to complete...\n'); - }; - - if (config) { - try { - const childProcess = await open(authUrl); - if (childProcess) { - childProcess.on('error', () => showFallbackMessage()); - } - } catch (_err) { - showFallbackMessage(); - } - } else { - showFallbackMessage(); - } - - qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'polling', 'Waiting for authorization...'); - logger.debug('Waiting for authorization...\n'); - - // 等待 OAuth 回调完成并读取保存的凭据 - const credPath = this._getQwenCachedCredentialPath(); - const credentials = await new Promise((resolve, reject) => { - const checkInterval = setInterval(async () => { - try { - const data = await fs.readFile(credPath, 'utf8'); - const creds = JSON.parse(data); - if (creds.access_token) { - clearInterval(checkInterval); - logger.info('[Qwen Auth] New token obtained successfully.'); - resolve(creds); - } - } catch (error) { - // 文件尚未创建或无效,继续等待 - } - }, 1000); - - // 设置超时(5分钟) - setTimeout(() => { - clearInterval(checkInterval); - reject(new Error('[Qwen Auth] OAuth 授权超时')); - }, 5 * 60 * 1000); - }); - - client.setCredentials(credentials); - qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'success', 'Authentication successful! Access token obtained.'); - return { success: true }; - } catch (error) { - logger.error('Device authorization flow failed:', error.message); - qwenOAuth2Events.emit(QwenOAuth2Event.AuthProgress, 'error', error.message); - return { success: false, reason: 'error' }; - } - } - - _getQwenCachedCredentialPath() { - if (this.config && this.config.QWEN_OAUTH_CREDS_FILE_PATH) { - return path.resolve(this.config.QWEN_OAUTH_CREDS_FILE_PATH); - } - return path.join(os.homedir(), QWEN_DIR, QWEN_CREDENTIAL_FILENAME); - } - - async _loadCachedQwenCredentials(client) { - try { - const keyFile = this._getQwenCachedCredentialPath(); - const creds = await fs.readFile(keyFile, 'utf-8'); - const credentials = JSON.parse(creds); - client.setCredentials(credentials); - // Consider credentials usable only if access_token exists and not near expiry - const hasToken = !!credentials?.access_token; - const notExpired = !!credentials?.expiry_date && (Date.now() < credentials.expiry_date - TOKEN_REFRESH_BUFFER_MS); - return hasToken && notExpired; - } catch (_) { - return false; - } - } - - async _cacheQwenCredentials(credentials) { - const filePath = this._getQwenCachedCredentialPath(); - try { - await fs.mkdir(path.dirname(filePath), { recursive: true }); - const credString = JSON.stringify(credentials, null, 2); - await fs.writeFile(filePath, credString); - logger.info(`[Qwen Auth] Credentials cached to ${filePath}`); - } catch (error) { - logger.error(`[Qwen Auth] Failed to cache credentials to ${filePath}: ${error.message}`); - } - } - - getCurrentEndpoint(resourceUrl) { - const baseEndpoint = resourceUrl || this.baseUrl; - const suffix = '/v1'; - - const normalizedUrl = baseEndpoint.startsWith('http') ? - baseEndpoint : - `https://${baseEndpoint}`; - - return normalizedUrl.endsWith(suffix) ? - normalizedUrl : - `${normalizedUrl}${suffix}`; - } - - isAuthError(error) { - if (!error) return false; - const errorMessage = (error instanceof Error ? error.message : String(error)).toLowerCase(); - const errorCode = error?.status || error?.code || error.response?.status; - - const code = String(errorCode); - return ( - code.startsWith('401') || code.startsWith('403') || - errorMessage.includes('unauthorized') || - errorMessage.includes('forbidden') || - errorMessage.includes('invalid api key') || - errorMessage.includes('invalid access token') || - errorMessage.includes('token expired') || - errorMessage.includes('authentication') || - errorMessage.includes('access denied') - ); - } - - async getValidToken() { - try { - const credentials = await this.sharedManager.getValidCredentials( - this.qwenClient, - false, - this.tokenManagerOptions, - ); - if (!credentials.access_token) throw new Error('No access token available'); - return { - token: credentials.access_token, - endpoint: this.getCurrentEndpoint(credentials.resource_url), - }; - } catch (error) { - if (this.isAuthError(error)) throw error; - logger.warn('Failed to get token from shared manager:', error); - throw new Error('Failed to obtain valid Qwen access token. Please re-authenticate.'); - } - } - - /** - * Processes message content in the request body. - * If content is an array, it joins the elements with newlines. - * @param {Object} requestBody - The request body to process - * @returns {Object} The processed request body - */ - processMessageContent(requestBody) { - if (!requestBody || !requestBody.messages || !Array.isArray(requestBody.messages)) { - return requestBody; - } - - const processedMessages = requestBody.messages.map(message => { - if (message.content && Array.isArray(message.content)) { - // Convert each item to JSON string before joining - const stringifiedContent = message.content.map(item => - typeof item === 'string' ? item : item.text - ); - return { - ...message, - content: stringifiedContent.join('\n') - }; - } - return message; - }); - - return { - ...requestBody, - messages: processedMessages - }; - } - - async callApiWithAuthAndRetry(endpoint, body, isStream = false, retryCount = 0) { - const maxRetries = (this.config && this.config.REQUEST_MAX_RETRIES) || 3; - const baseDelay = (this.config && this.config.REQUEST_BASE_DELAY) || 1000; - - const version = "0.10.1"; - const userAgent = `QwenCode/${version} (${process.platform}; ${process.arch})`; - logger.info(`[QwenApiService] User-Agent: ${userAgent}`); - - try { - const { token, endpoint: qwenBaseUrl } = await this.getValidToken(); - - // 配置 HTTP/HTTPS agent 限制连接池大小,避免资源泄漏 - const httpAgent = new http.Agent({ - keepAlive: true, - maxSockets: 100, - maxFreeSockets: 5, - timeout: 120000, - }); - const httpsAgent = new https.Agent({ - keepAlive: true, - maxSockets: 100, - maxFreeSockets: 5, - timeout: 120000, - }); - - const axiosConfig = { - baseURL: qwenBaseUrl, - httpAgent, - httpsAgent, - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, - 'X-DashScope-CacheControl': 'enable', - 'X-DashScope-UserAgent': userAgent, - 'X-DashScope-AuthType': 'qwen-oauth', - }, - }; - - // 禁用系统代理 - if (!this.useSystemProxy) { - axiosConfig.proxy = false; - } - - // 配置自定义代理 - configureAxiosProxy(axiosConfig, this.config, 'openai-qwen-oauth'); - - this.currentAxiosInstance = axios.create(axiosConfig); - - // Process message content before sending the request - const processedBody = body;//this.processMessageContent(body); - - // Check if model in body is in QWEN_MODEL_LIST, if not, use the first model's id - if (processedBody.model && !QWEN_MODEL_LIST.some(model => model.id === processedBody.model)) { - logger.warn(`[QwenApiService] Model '${processedBody.model}' not found. Using default model: '${QWEN_MODEL_LIST[0].id}'`); - processedBody.model = QWEN_MODEL_LIST[0].id; - } - - const defaultTools = [ - { - "type": "function", - "function": { - "name": "ext" - } - } - ]; - - // Merge tools if requestBody already has tools defined - const mergedTools = processedBody.tools ? [...defaultTools, ...processedBody.tools] : defaultTools; - - const requestBody = isStream ? { ...processedBody, stream: true, tools: mergedTools } : { ...processedBody, tools: mergedTools }; - - const axiosRequestConfig = { - method: 'post', - url: endpoint, - data: requestBody, - ...(isStream ? { responseType: 'stream' } : {}) - }; - this._applySidecar(axiosRequestConfig); - - const response = await this.currentAxiosInstance.request(axiosRequestConfig); - return response.data; - - } catch (error) { - const status = error.response?.status; - const data = error.response?.data || error.message; - const errorCode = error.code; - const errorMessage = error.message || ''; - - // 检查是否为可重试的网络错误 - const isNetworkError = isRetryableNetworkError(error); - - if (this.isAuthError(error) && retryCount === 0) { - logger.warn(`[QwenApiService] Auth error (${status}). Triggering background refresh via PoolManager...`); - - // 标记当前凭证为不健康(会自动进入刷新队列) - const poolManager = getProviderPoolManager(); - if (poolManager && this.uuid) { - logger.info(`[Qwen] Marking credential ${this.uuid} as needs refresh. Reason: Auth Error ${status}`); - poolManager.markProviderNeedRefresh(MODEL_PROVIDER.QWEN_API, { - uuid: this.uuid - }); - error.credentialMarkedUnhealthy = true; - } - - // Mark error for credential switch without recording error count - error.shouldSwitchCredential = true; - error.skipErrorCount = true; - throw error; - } - - if ((status === 429 || (status >= 500 && status < 600)) && retryCount < maxRetries) { - const delay = baseDelay * Math.pow(2, retryCount); - logger.info(`[QwenApiService] Status ${status}. Retrying in ${delay}ms...`); - await new Promise(resolve => setTimeout(resolve, delay)); - return this.callApiWithAuthAndRetry(endpoint, body, isStream, retryCount + 1); - } - - // Handle network errors (ECONNRESET, ETIMEDOUT, etc.) with exponential backoff - if (isNetworkError && retryCount < maxRetries) { - const delay = baseDelay * Math.pow(2, retryCount); - const errorIdentifier = errorCode || errorMessage.substring(0, 50); - logger.info(`[QwenApiService] Network error (${errorIdentifier}). Retrying in ${delay}ms... (attempt ${retryCount + 1}/${maxRetries})`); - await new Promise(resolve => setTimeout(resolve, delay)); - return this.callApiWithAuthAndRetry(endpoint, body, isStream, retryCount + 1); - } - - logger.error(`[QwenApiService] Error calling API (Status: ${status}, Code: ${errorCode}):`, errorMessage); - throw error; - } - } - - async generateContent(model, requestBody) { - // 临时存储 monitorRequestId - if (requestBody._monitorRequestId) { - this.config._monitorRequestId = requestBody._monitorRequestId; - delete requestBody._monitorRequestId; - } - if (requestBody._requestBaseUrl) { - delete requestBody._requestBaseUrl; - } - - // 检查 token 是否即将过期,如果是则推送到刷新队列 - if (this.isExpiryDateNear()) { - const poolManager = getProviderPoolManager(); - if (poolManager && this.uuid) { - logger.info(`[Qwen] Token is near expiry, marking credential ${this.uuid} for refresh`); - poolManager.markProviderNeedRefresh(MODEL_PROVIDER.QWEN_API, { - uuid: this.uuid - }); - } - } - - return this.callApiWithAuthAndRetry('/chat/completions', requestBody, false); - } - - async *generateContentStream(model, requestBody) { - // 临时存储 monitorRequestId - if (requestBody._monitorRequestId) { - this.config._monitorRequestId = requestBody._monitorRequestId; - delete requestBody._monitorRequestId; - } - if (requestBody._requestBaseUrl) { - delete requestBody._requestBaseUrl; - } - - // 检查 token 是否即将过期,如果是则推送到刷新队列 - if (this.isExpiryDateNear()) { - const poolManager = getProviderPoolManager(); - if (poolManager && this.uuid) { - logger.info(`[Qwen] Token is near expiry, marking credential ${this.uuid} for refresh`); - poolManager.markProviderNeedRefresh(MODEL_PROVIDER.QWEN_API, { - uuid: this.uuid - }); - } - } - - const stream = await this.callApiWithAuthAndRetry('/chat/completions', requestBody, true); - let buffer = ''; - for await (const chunk of stream) { - buffer += chunk.toString(); - let newlineIndex; - while ((newlineIndex = buffer.indexOf('\n')) !== -1) { - const line = buffer.substring(0, newlineIndex).trim(); - buffer = buffer.substring(newlineIndex + 1); - - if (line.startsWith('data: ')) { - const jsonData = line.substring(6).trim(); - if (jsonData === '[DONE]') return; - try { - yield JSON.parse(jsonData); - } catch (e) { - logger.warn("[QwenApiService] Failed to parse stream chunk:", jsonData); - } - } - } - } - } - - async listModels() { - // Return the predefined models for Qwen - return { - data: QWEN_MODEL_LIST - }; - } - - isExpiryDateNear() { - try { - const credentials = this.qwenClient.getCredentials(); - if (!credentials || !credentials.expiry_date) { - return false; - } - const nearMinutes = 20; - const { message, isNearExpiry } = formatExpiryLog('Qwen', credentials.expiry_date, nearMinutes); - logger.info(message); - return isNearExpiry; - } catch (error) { - logger.error(`[Qwen] Error checking expiry date: ${error.message}`); - return false; - } - } -} - - -// --- SharedTokenManager Class (Singleton) --- - -class SharedTokenManager { - static instance = null; - - constructor() { - this.contexts = new Map(); - this.lockPaths = new Set(); - this.cleanupHandlersRegistered = false; - this.cleanupFunction = null; - this.sigintHandler = null; - this.registerCleanupHandlers(); - } - - static getInstance() { - if (!SharedTokenManager.instance) { - SharedTokenManager.instance = new SharedTokenManager(); - } - return SharedTokenManager.instance; - } - - getContext(options = {}) { - const credentialFilePath = this.resolveCredentialFilePath(options.credentialFilePath); - const lockFilePath = this.resolveLockFilePath(credentialFilePath, options.lockFilePath); - let context = this.contexts.get(credentialFilePath); - if (!context) { - context = { - credentialFilePath, - lockFilePath, - lockConfig: options.lockConfig || DEFAULT_LOCK_CONFIG, - memoryCache: { credentials: null, fileModTime: 0, lastCheck: 0 }, - refreshPromise: null, - }; - this.contexts.set(credentialFilePath, context); - this.lockPaths.add(lockFilePath); - } else if (options.lockConfig) { - context.lockConfig = options.lockConfig; - } - return context; - } - - resolveCredentialFilePath(customPath) { - if (customPath) { - return path.resolve(customPath); - } - return path.join(os.homedir(), QWEN_DIR, QWEN_CREDENTIAL_FILENAME); - } - - resolveLockFilePath(credentialFilePath, customLockPath) { - if (customLockPath) { - return path.resolve(customLockPath); - } - return `${credentialFilePath}.lock`; - } - - registerCleanupHandlers() { - if (this.cleanupHandlersRegistered) return; - this.cleanupFunction = () => { - for (const lockPath of this.lockPaths) { - try { unlinkSync(lockPath); } catch (_error) { /* ignore */ } - } - }; - this.sigintHandler = () => { - this.cleanupFunction(); - process.exit(0); - }; - process.on('exit', this.cleanupFunction); - process.on('SIGINT', this.sigintHandler); - this.cleanupHandlersRegistered = true; - } - - async getValidCredentials(qwenClient, forceRefresh = false, options = {}) { - const context = this.getContext(options); - try { - await this.checkAndReloadIfNeeded(context); - if (!forceRefresh && context.memoryCache.credentials && this.isTokenValid(context.memoryCache.credentials)) { - return context.memoryCache.credentials; - } - if (context.refreshPromise) { - return context.refreshPromise; - } - - qwenClient.setCredentials(context.memoryCache.credentials); - context.refreshPromise = this.performTokenRefresh(context, qwenClient, forceRefresh); - const credentials = await context.refreshPromise; - context.refreshPromise = null; - return credentials; - } catch (error) { - context.refreshPromise = null; - if (error instanceof TokenManagerError) throw error; - throw new TokenManagerError( - TokenError.REFRESH_FAILED, - `Failed to get valid credentials: ${error.message}`, - error, - ); - } - } - - async checkAndReloadIfNeeded(context) { - const now = Date.now(); - if (now - context.memoryCache.lastCheck < CACHE_CHECK_INTERVAL_MS) return; - context.memoryCache.lastCheck = now; - - try { - const stats = await fs.stat(context.credentialFilePath); - if (stats.mtimeMs > context.memoryCache.fileModTime) { - await this.reloadCredentialsFromFile(context); - context.memoryCache.fileModTime = stats.mtimeMs; - } - } catch (error) { - if (error.code !== 'ENOENT') { - context.memoryCache.credentials = null; - context.memoryCache.fileModTime = 0; - throw new TokenManagerError( - TokenError.FILE_ACCESS_ERROR, - `Failed to access credentials file: ${error.message}`, - error, - ); - } - context.memoryCache.credentials = null; - context.memoryCache.fileModTime = 0; - } - } - - async reloadCredentialsFromFile(context) { - try { - const content = await fs.readFile(context.credentialFilePath, 'utf-8'); - context.memoryCache.credentials = JSON.parse(content); - } catch (_error) { - context.memoryCache.credentials = null; - } - } - - async performTokenRefresh(context, qwenClient, forceRefresh = false) { - const currentCredentials = qwenClient.getCredentials() || context.memoryCache.credentials; - if (!currentCredentials || !currentCredentials.refresh_token) { - throw new TokenManagerError(TokenError.NO_REFRESH_TOKEN, 'No refresh token available'); - } - - try { - await this.acquireLock(context); - try { - await this.checkAndReloadIfNeeded(context); - - if (!forceRefresh && context.memoryCache.credentials && this.isTokenValid(context.memoryCache.credentials)) { - qwenClient.setCredentials(context.memoryCache.credentials); - return context.memoryCache.credentials; - } - - const response = await qwenClient.refreshAccessToken(); - if (!response || isErrorResponse(response)) { - throw new TokenManagerError(TokenError.REFRESH_FAILED, `Token refresh failed: ${response?.error}`); - } - if (!response.access_token) { - throw new TokenManagerError(TokenError.REFRESH_FAILED, 'No access token in refresh response'); - } - const newCredentials = { - access_token: response.access_token, - token_type: response.token_type, - refresh_token: response.refresh_token || currentCredentials.refresh_token, - resource_url: response.resource_url, - expiry_date: Date.now() + response.expires_in * 1000, - }; - - context.memoryCache.credentials = newCredentials; - qwenClient.setCredentials(newCredentials); - await this.saveCredentialsToFile(context, newCredentials); - logger.info('[Qwen Auth] Token refresh response: ok'); - return newCredentials; - } finally { - await this.releaseLock(context); - } - } catch (error) { - if (error instanceof TokenManagerError) throw error; - - // 处理 CredentialsClearRequiredError - 清除凭证文件 - if (error instanceof CredentialsClearRequiredError) { - try { - await fs.unlink(context.credentialFilePath); - logger.info('[Qwen Auth] Credentials cleared due to refresh token expiry'); - } catch (_) { /* ignore */ } - throw error; // 重新抛出以便上层处理 - } - - // 如果刷新令牌无效/过期,删除此上下文对应的凭证文件 - if (error && (error.status === 400 || /expired|invalid/i.test(error.message || ''))) { - try { await fs.unlink(context.credentialFilePath); } catch (_) { /* ignore */ } - } - throw new TokenManagerError( - TokenError.REFRESH_FAILED, - `Unexpected error during token refresh: ${error.message}`, - error, - ); - } - } - - async saveCredentialsToFile(context, credentials) { - try { - await fs.mkdir(path.dirname(context.credentialFilePath), { recursive: true, mode: 0o700 }); - await fs.writeFile(context.credentialFilePath, JSON.stringify(credentials, null, 2), { mode: 0o600 }); - const stats = await fs.stat(context.credentialFilePath); - context.memoryCache.fileModTime = stats.mtimeMs; - } catch (error) { - logger.error(`[Qwen Auth] Failed to save credentials to ${context.credentialFilePath}: ${error.message}`); - } - } - - isTokenValid(credentials) { - return credentials?.expiry_date && Date.now() < credentials.expiry_date - TOKEN_REFRESH_BUFFER_MS; - } - - async acquireLock(context) { - const { maxAttempts, attemptInterval } = context.lockConfig || DEFAULT_LOCK_CONFIG; - for (let attempt = 0; attempt < maxAttempts; attempt++) { - try { - await fs.writeFile(context.lockFilePath, randomUUID(), { flag: 'wx' }); - return; - } catch (error) { - if (error.code === 'EEXIST') { - try { - const stats = await fs.stat(context.lockFilePath); - if (Date.now() - stats.mtimeMs > LOCK_TIMEOUT_MS) { - await fs.unlink(context.lockFilePath); - continue; - } - } catch (_statError) { /* ignore */ } - await new Promise(resolve => setTimeout(resolve, attemptInterval)); - } else { - throw new TokenManagerError( - TokenError.FILE_ACCESS_ERROR, - `Failed to create lock file: ${error.message}`, - error, - ); - } - } - } - throw new TokenManagerError(TokenError.LOCK_TIMEOUT, 'Lock acquisition timeout'); - } - - async releaseLock(context) { - try { - await fs.unlink(context.lockFilePath); - } catch (error) { - if (error.code !== 'ENOENT') { - logger.warn(`Failed to release lock: ${error.message}`); - } - } - } -} - - -// --- QwenOAuth2Client Class --- - -class QwenOAuth2Client { - credentials = {}; - - constructor(config, useSystemProxy = false) { - this.config = config; - this.useSystemProxy = useSystemProxy; - - // Initialize OAuth endpoints - const oauthBaseUrl = config.QWEN_OAUTH_BASE_URL || DEFAULT_QWEN_OAUTH_BASE_URL; - this.oauthDeviceCodeEndpoint = `${oauthBaseUrl}/api/v1/oauth2/device/code`; - this.oauthTokenEndpoint = `${oauthBaseUrl}/api/v1/oauth2/token`; - } - - setCredentials(credentials) { this.credentials = credentials; } - getCredentials() { return this.credentials; } - - async refreshAccessToken() { - if (!this.credentials.refresh_token) throw new Error('No refresh token'); - const bodyData = { - grant_type: 'refresh_token', - refresh_token: this.credentials.refresh_token, - client_id: QWEN_OAUTH_CLIENT_ID, - }; - try { - const endpoint = this.oauthTokenEndpoint; - const response = await commonFetch(endpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }, - body: objectToUrlEncoded(bodyData), - }, this.useSystemProxy); - return response; - } catch (error) { - const errorData = error.data || {}; - // 处理 400 错误,可能表示刷新令牌已过期 - if (error.status === 400) { - // 清除凭证将由 SharedTokenManager 处理 - throw new CredentialsClearRequiredError( - "刷新令牌已过期或无效。请使用 '/auth' 重新认证。", - { status: error.status, response: errorData } - ); - } - throw new Error( - `Token refresh failed: ${error.status || 'Unknown'} - ${errorData.error_description || error.message || 'No details'}` - ); - } - } - - async requestDeviceAuthorization(options) { - const bodyData = { - client_id: QWEN_OAUTH_CLIENT_ID, - scope: options.scope, - code_challenge: options.code_challenge, - code_challenge_method: options.code_challenge_method, - }; - try { - const endpoint = this.oauthDeviceCodeEndpoint; - const response = await commonFetch(endpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }, - body: objectToUrlEncoded(bodyData), - }, this.useSystemProxy); - return response; - } catch (error) { - throw new Error(`Device authorization failed: ${error.status || error.message}`); - } - } - - async pollDeviceToken(options) { - const bodyData = { - grant_type: QWEN_OAUTH_GRANT_TYPE, - client_id: QWEN_OAUTH_CLIENT_ID, - device_code: options.device_code, - code_verifier: options.code_verifier, - }; - try { - const endpoint = this.oauthTokenEndpoint; - const response = await commonFetch(endpoint, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }, - body: objectToUrlEncoded(bodyData), - }, this.useSystemProxy); - return response; - } catch (error) { - // 根据 OAuth RFC 8628,处理标准轮询响应 - // 尝试解析错误响应为 JSON - const errorData = error.data || {}; - const status = error.status; - - // 用户尚未批准授权请求,继续轮询 - if (status === 400 && errorData.error === 'authorization_pending') { - return { status: 'pending' }; - } - - // 客户端轮询过于频繁,返回 pending 并设置 slowDown 标志 - if (status === 429 && errorData.error === 'slow_down') { - return { status: 'pending', slowDown: true }; - } - - // 处理其他 400 错误(access_denied, expired_token 等)作为真正的错误 - // 对于其他错误,抛出适当的错误信息 - const err = new Error( - `Device token poll failed: ${errorData.error || 'Unknown error'} - ${errorData.error_description || 'No details provided'}` - ); - err.status = status; - throw err; - } - } -} - diff --git a/src/providers/provider-models.js b/src/providers/provider-models.js deleted file mode 100644 index 4998f7143320399bec93399df25835565780c923..0000000000000000000000000000000000000000 --- a/src/providers/provider-models.js +++ /dev/null @@ -1,127 +0,0 @@ -/** - * 各提供商支持的模型列表 - * 用于前端UI选择不支持的模型 - */ - -export const PROVIDER_MODELS = { - 'gemini-cli-oauth': [ - 'gemini-2.5-flash', - 'gemini-2.5-flash-lite', - 'gemini-2.5-pro', - 'gemini-2.5-pro-preview-06-05', - 'gemini-2.5-flash-preview-09-2025', - 'gemini-3-pro-preview', - 'gemini-3-flash-preview', - 'gemini-3.1-pro-preview', - 'gemini-3.1-flash-lite-preview', - ], - 'gemini-antigravity': [ - 'gemini-3-flash', - 'gemini-3.1-pro-high', - 'gemini-3.1-pro-low', - 'gemini-3.1-flash-image', - 'gemini-3-flash-agent', - 'gemini-2.5-flash', - 'gemini-2.5-flash-lite', - 'gemini-2.5-flash-thinking', - 'gemini-claude-sonnet-4-6', - 'gemini-claude-opus-4-6-thinking', - ], - 'claude-custom': [], - 'claude-kiro-oauth': [ - 'claude-haiku-4-5', - 'claude-opus-4-6', - 'claude-sonnet-4-6', - 'claude-opus-4-5', - 'claude-opus-4-5-20251101', - 'claude-sonnet-4-5', - 'claude-sonnet-4-5-20250929', - 'claude-sonnet-4-20250514', - 'claude-3-7-sonnet-20250219' - ], - 'openai-custom': [], - 'openaiResponses-custom': [], - 'openai-qwen-oauth': [ - 'qwen3-coder-plus', - 'qwen3-coder-flash', - 'coder-model', - 'vision-model' - ], - 'openai-iflow': [ - // iFlow 特有模型 - 'iflow-rome-30ba3b', - // Qwen 模型 - 'qwen3-coder-plus', - 'qwen3-max', - 'qwen3-vl-plus', - 'qwen3-max-preview', - 'qwen3-32b', - 'qwen3-235b-a22b-thinking-2507', - 'qwen3-235b-a22b-instruct', - 'qwen3-235b', - // Kimi 模型 - 'kimi-k2-0905', - 'kimi-k2', - // GLM 模型 - 'glm-4.6', - // DeepSeek 模型 - 'deepseek-v3.2', - 'deepseek-r1', - 'deepseek-v3', - // 手动定义 - 'glm-4.7', - 'glm-5', - 'kimi-k2.5', - 'minimax-m2.1', - 'minimax-m2.5', - ], - 'openai-codex-oauth': [ - 'gpt-5', - 'gpt-5-codex', - 'gpt-5-codex-mini', - 'gpt-5.1', - 'gpt-5.1-codex', - 'gpt-5.1-codex-mini', - 'gpt-5.1-codex-max', - 'gpt-5.2', - 'gpt-5.2-codex', - 'gpt-5.3-codex', - 'gpt-5.3-codex-spark', - 'gpt-5.4', - ], - 'forward-api': [], - 'grok-custom': [ - 'grok-3', - 'grok-3-mini', - 'grok-3-thinking', - 'grok-4', - 'grok-4-mini', - 'grok-4-thinking', - 'grok-4-heavy', - 'grok-4.1-mini', - 'grok-4.1-fast', - 'grok-4.1-expert', - 'grok-4.1-thinking', - 'grok-4.20-beta', - 'grok-imagine-1.0', - 'grok-imagine-1.0-edit', - 'grok-imagine-1.0-video' - ] -}; - -/** - * 获取指定提供商类型支持的模型列表 - * @param {string} providerType - 提供商类型 - * @returns {Array} 模型列表 - */ -export function getProviderModels(providerType) { - return PROVIDER_MODELS[providerType] || []; -} - -/** - * 获取所有提供商的模型列表 - * @returns {Object} 所有提供商的模型映射 - */ -export function getAllProviderModels() { - return PROVIDER_MODELS; -} diff --git a/src/providers/provider-pool-manager.js b/src/providers/provider-pool-manager.js deleted file mode 100644 index a6a67835ae3e5de7319ec3a73382ba48508bd84e..0000000000000000000000000000000000000000 --- a/src/providers/provider-pool-manager.js +++ /dev/null @@ -1,1914 +0,0 @@ -import * as fs from 'fs'; -import { getServiceAdapter, getRegisteredProviders } from './adapter.js'; -import logger from '../utils/logger.js'; -import { MODEL_PROVIDER, getProtocolPrefix } from '../utils/common.js'; -import { getProviderModels } from './provider-models.js'; -import { broadcastEvent } from '../ui-modules/event-broadcast.js'; -import { convertData } from '../convert/convert.js'; -import { ENDPOINT_TYPE } from '../utils/common.js'; - -/** - * Manages a pool of API service providers, handling their health and selection. - */ -export class ProviderPoolManager { - // 默认健康检查模型配置 - // 键名必须与 MODEL_PROVIDER 常量值一致 - static DEFAULT_HEALTH_CHECK_MODELS = { - 'gemini-cli-oauth': 'gemini-2.5-flash', - 'gemini-antigravity': 'gemini-2.5-flash', - 'openai-custom': 'gpt-4o-mini', - 'claude-custom': 'claude-3-7-sonnet-20250219', - 'claude-kiro-oauth': 'claude-haiku-4-5', - 'openai-qwen-oauth': 'qwen3-coder-flash', - 'openai-iflow': 'qwen3-coder-plus', - 'openai-codex-oauth': 'gpt-5-codex-mini', - 'openaiResponses-custom': 'gpt-4o-mini', - 'forward-api': 'gpt-4o-mini', - }; - - constructor(providerPools, options = {}) { - this.providerPools = providerPools; - this.globalConfig = options.globalConfig || {}; // 存储全局配置 - this.providerStatus = {}; // Tracks health and usage for each provider instance - this.roundRobinIndex = {}; // Tracks the current index for round-robin selection for each provider type - // 使用 ?? 运算符确保 0 也能被正确设置,而不是被 || 替换为默认值 - this.maxErrorCount = options.maxErrorCount ?? 10; // Default to 10 errors before marking unhealthy - this.healthCheckInterval = options.healthCheckInterval ?? 10 * 60 * 1000; // Default to 10 minutes - - // 日志级别控制 - this.logLevel = options.logLevel || 'info'; // 'debug', 'info', 'warn', 'error' - - // 添加防抖机制,避免频繁的文件 I/O 操作 - this.saveDebounceTime = options.saveDebounceTime || 1000; // 默认1秒防抖 - this.saveTimer = null; - this.pendingSaves = new Set(); // 记录待保存的 providerType - - // Fallback 链配置 - this.fallbackChain = options.globalConfig?.providerFallbackChain || {}; - - // Model Fallback 映射配置 - this.modelFallbackMapping = options.globalConfig?.modelFallbackMapping || {}; - - // 并发控制:每个 providerType 的选择锁 - // 用于确保 selectProvider 的排序 and 更新操作是原子的 - this._selectionLocks = {}; - this._isSelecting = {}; // 同步标志位锁 - - // --- V2: 读写分离 and 异步刷新队列 --- - // 刷新并发控制配置 - this.refreshConcurrency = { - global: options.globalConfig?.REFRESH_CONCURRENCY_GLOBAL ?? 2, // 全局最大并行提供商数 - perProvider: options.globalConfig?.REFRESH_CONCURRENCY_PER_PROVIDER ?? 1 // 每个提供商内部最大并行数 - }; - - this.activeProviderRefreshes = 0; // 当前正在刷新的提供商类型数量 - this.globalRefreshWaiters = []; // 等待全局并发槽位的任务 - - this.warmupTarget = options.globalConfig?.WARMUP_TARGET || 0; // 默认预热0个节点 - this.refreshingUuids = new Set(); // 正在刷新的节点 UUID 集合 - - this.refreshQueues = {}; // 按 providerType 分组的队列 - // 缓冲队列机制:延迟5秒,去重后再执行刷新 - this.refreshBufferQueues = {}; // 按 providerType 分组的缓冲队列 - this.refreshBufferTimers = {}; // 按 providerType 分组的定时器 - this.bufferDelay = options.globalConfig?.REFRESH_BUFFER_DELAY ?? 5000; // 默认5秒缓冲延迟 - - // 用于并发选点时的原子排序辅助(自增序列) - this._selectionSequence = 0; - - this.initializeProviderStatus(); - } - - /** - * 检查所有节点的配置文件,如果发现即将过期则触发刷新 - */ - async checkAndRefreshExpiringNodes() { - this._log('info', 'Checking nodes for approaching expiration dates using provider adapters...'); - - for (const providerType in this.providerStatus) { - const providers = this.providerStatus[providerType]; - for (const providerStatus of providers) { - const config = providerStatus.config; - - // 根据 providerType 确定配置文件路径字段名 - let configPath = null; - if (providerType.startsWith('claude-kiro')) { - configPath = config.KIRO_OAUTH_CREDS_FILE_PATH; - } else if (providerType.startsWith('gemini-cli')) { - configPath = config.GEMINI_OAUTH_CREDS_FILE_PATH; - } else if (providerType.startsWith('gemini-antigravity')) { - configPath = config.ANTIGRAVITY_OAUTH_CREDS_FILE_PATH; - } else if (providerType.startsWith('openai-qwen')) { - configPath = config.QWEN_OAUTH_CREDS_FILE_PATH; - } else if (providerType.startsWith('openai-iflow')) { - configPath = config.IFLOW_OAUTH_CREDS_FILE_PATH; - } else if (providerType.startsWith('openai-codex')) { - configPath = config.CODEX_OAUTH_CREDS_FILE_PATH; - } - - // logger.info(`Checking node ${providerStatus.uuid} (${providerType}) expiry date... configPath: ${configPath}`); - // 排除不健康和禁用的节点 - if (!config.isHealthy || config.isDisabled) continue; - - if (configPath && fs.existsSync(configPath)) { - try { - if (true) { - this._log('warn', `Node ${providerStatus.uuid} (${providerType}) is near expiration. Enqueuing refresh...`); - this._enqueueRefresh(providerType, providerStatus); - } - } catch (err) { - this._log('error', `Failed to check expiry for node ${providerStatus.uuid}: ${err.message}`); - } - } else { - this._log('debug', `Node ${providerStatus.uuid} (${providerType}) has no valid config file path or file does not exist.`); - } - } - } - } - - /** - * 系统预热逻辑:按提供商分组,每组预热 warmupTarget 个节点 - * @returns {Promise} - */ - async warmupNodes() { - if (this.warmupTarget <= 0) return; - this._log('info', `Starting system warmup (Group Target: ${this.warmupTarget} nodes per provider)...`); - - const nodesToWarmup = []; - - for (const type in this.providerStatus) { - const pool = this.providerStatus[type]; - - // 挑选当前提供商下需要预热的节点 - const candidates = pool - .filter(p => p.config.isHealthy && !p.config.isDisabled && !this.refreshingUuids.has(p.uuid)) - .sort((a, b) => { - // 优先级 A: 明确标记需要刷新的 - if (a.config.needsRefresh && !b.config.needsRefresh) return -1; - if (!a.config.needsRefresh && b.config.needsRefresh) return 1; - - // 优先级 B: 按照正常的选择权重排序(最久没用过的优先补) - const scoreA = this._calculateNodeScore(a); - const scoreB = this._calculateNodeScore(b); - return scoreA - scoreB; - }) - .slice(0, this.warmupTarget); - - candidates.forEach(p => nodesToWarmup.push({ type, status: p })); - } - - this._log('info', `Warmup: Selected total ${nodesToWarmup.length} nodes across all providers to refresh.`); - - for (const node of nodesToWarmup) { - this._enqueueRefresh(node.type, node.status, true); - } - - // 注意:warmupNodes 不等待队列结束,它是异步后台执行的 - } - - /** - * 将节点放入缓冲队列,延迟5秒后去重并执行刷新 - * @param {string} providerType - * @param {object} providerStatus - * @param {boolean} force - 是否强制刷新(跳过缓冲队列) - * @private - */ - _enqueueRefresh(providerType, providerStatus, force = false) { - const uuid = providerStatus.uuid; - - // 如果已经在刷新中,直接返回 - if (this.refreshingUuids.has(uuid)) { - this._log('debug', `Node ${uuid} is already in refresh queue.`); - return; - } - - // 判断提供商池内的总可用节点数,小于5个时,不等待缓冲,直接加入刷新队列 - const healthyCount = this.getHealthyCount(providerType); - if (healthyCount < 5) { - this._log('info', `Provider ${providerType} has only ${healthyCount} healthy nodes. Bypassing buffer and enqueuing refresh for ${uuid} immediately.`); - this._enqueueRefreshImmediate(providerType, providerStatus, force); - return; - } - - // 初始化缓冲队列 - if (!this.refreshBufferQueues[providerType]) { - this.refreshBufferQueues[providerType] = new Map(); // 使用 Map 自动去重 - } - - const bufferQueue = this.refreshBufferQueues[providerType]; - - // 检查是否已在缓冲队列中 - const existing = bufferQueue.get(uuid); - const isNewEntry = !existing; - - // 更新或添加节点(保留 force: true 状态) - bufferQueue.set(uuid, { - providerStatus, - force: existing ? (existing.force || force) : force - }); - - if (isNewEntry) { - this._log('debug', `Node ${uuid} added to buffer queue for ${providerType}. Buffer size: ${bufferQueue.size}`); - } else { - this._log('debug', `Node ${uuid} already in buffer queue, updated force flag. Buffer size: ${bufferQueue.size}`); - } - - // 只在新增节点或缓冲队列为空时重置定时器 - // 避免频繁重置导致刷新被无限延迟 - if (isNewEntry || !this.refreshBufferTimers[providerType]) { - // 清除之前的定时器 - if (this.refreshBufferTimers[providerType]) { - clearTimeout(this.refreshBufferTimers[providerType]); - } - - // 设置新的定时器,延迟5秒后处理缓冲队列 - this.refreshBufferTimers[providerType] = setTimeout(() => { - this._flushRefreshBuffer(providerType); - }, this.bufferDelay); - } - } - - /** - * 处理缓冲队列,将去重后的节点放入实际刷新队列 - * @param {string} providerType - * @private - */ - _flushRefreshBuffer(providerType) { - const bufferQueue = this.refreshBufferQueues[providerType]; - if (!bufferQueue || bufferQueue.size === 0) { - return; - } - - this._log('info', `Flushing refresh buffer for ${providerType}. Processing ${bufferQueue.size} unique nodes.`); - - // 将缓冲队列中的所有节点放入实际刷新队列 - for (const [uuid, { providerStatus, force }] of bufferQueue.entries()) { - this._enqueueRefreshImmediate(providerType, providerStatus, force); - } - - // 清空缓冲队列和定时器 - bufferQueue.clear(); - delete this.refreshBufferTimers[providerType]; - } - - /** - * 立即将节点放入刷新队列(内部方法,由缓冲队列调用) - * @param {string} providerType - * @param {object} providerStatus - * @param {boolean} force - * @private - */ - _enqueueRefreshImmediate(providerType, providerStatus, force = false) { - const uuid = providerStatus.uuid; - - // 再次检查是否已经在刷新中(防止并发问题) - if (this.refreshingUuids.has(uuid)) { - this._log('debug', `Node ${uuid} is already in refresh queue (immediate check).`); - return; - } - - this.refreshingUuids.add(uuid); - - // 初始化提供商队列 - if (!this.refreshQueues[providerType]) { - this.refreshQueues[providerType] = { - activeCount: 0, - waitingTasks: [] - }; - } - - const queue = this.refreshQueues[providerType]; - - const runTask = async () => { - try { - await this._refreshNodeToken(providerType, providerStatus, force); - } catch (err) { - this._log('error', `Failed to process refresh for node ${uuid}: ${err.message}`); - } finally { - this.refreshingUuids.delete(uuid); - - // 再次获取当前队列引用 - const currentQueue = this.refreshQueues[providerType]; - if (!currentQueue) return; - - currentQueue.activeCount--; - - // 1. 尝试从当前提供商队列中取下一个任务 - if (currentQueue.waitingTasks.length > 0) { - const nextTask = currentQueue.waitingTasks.shift(); - currentQueue.activeCount++; - // 使用 Promise.resolve().then 避免过深的递归 - Promise.resolve().then(nextTask); - } else if (currentQueue.activeCount === 0) { - // 2. 如果当前提供商的所有任务都完成了,释放全局槽位 - // 只有在确定队列为空且没有新任务时才清理 - if (currentQueue.waitingTasks.length === 0 && - this.refreshQueues[providerType] === currentQueue) { - this.activeProviderRefreshes--; - delete this.refreshQueues[providerType]; // 清理空队列 - } - - // 3. 尝试启动下一个等待中的提供商队列 - if (this.globalRefreshWaiters.length > 0) { - const nextProviderStart = this.globalRefreshWaiters.shift(); - Promise.resolve().then(nextProviderStart); - } - } - } - }; - - const tryStartProviderQueue = () => { - if (queue.activeCount < this.refreshConcurrency.perProvider) { - queue.activeCount++; - runTask(); - } else { - queue.waitingTasks.push(runTask); - } - }; - - // 检查全局并发限制(按提供商分组) - // 情况1: 该提供商已经在运行,直接加入其队列(不占用新的全局槽位) - if (this.refreshQueues[providerType].activeCount > 0) { - tryStartProviderQueue(); - } - // 情况2: 该提供商未运行,需要检查全局槽位 - else if (this.activeProviderRefreshes < this.refreshConcurrency.global) { - this.activeProviderRefreshes++; - tryStartProviderQueue(); - } - // 情况3: 全局槽位已满,进入等待队列 - else { - this.globalRefreshWaiters.push(() => { - // 重新获取最新的队列引用 - if (!this.refreshQueues[providerType]) { - this.refreshQueues[providerType] = { - activeCount: 0, - waitingTasks: [] - }; - } - // 重要:从等待队列启动时需要增加全局计数 - this.activeProviderRefreshes++; - tryStartProviderQueue(); - }); - } - } - - /** - * 实际执行节点刷新逻辑 - * @private - */ - async _refreshNodeToken(providerType, providerStatus, force = false) { - const config = providerStatus.config; - - // 检查刷新次数是否已达上限(最大5次) - const currentRefreshCount = config.refreshCount || 0; - if (currentRefreshCount >= 5 && !force) { - this._log('warn', `Node ${providerStatus.uuid} has reached maximum refresh count (3), marking as unhealthy`); - // 标记为不健康 - this.markProviderUnhealthyImmediately(providerType, config, 'Maximum refresh count (3) reached'); - return; - } - - // 添加5秒内的随机等待时间,避免并发刷新时的冲突 - // const randomDelay = Math.floor(Math.random() * 5000); - // this._log('info', `Starting token refresh for node ${providerStatus.uuid} (${providerType}) with ${randomDelay}ms delay`); - // await new Promise(resolve => setTimeout(resolve, randomDelay)); - - try { - // 增加刷新计数 - config.refreshCount = currentRefreshCount + 1; - - // 使用适配器进行刷新 - const tempConfig = { - ...this.globalConfig, - ...config, - MODEL_PROVIDER: providerType - }; - const serviceAdapter = getServiceAdapter(tempConfig); - - // 调用适配器的 refreshToken 方法(内部封装了具体的刷新逻辑) - if (typeof serviceAdapter.refreshToken === 'function') { - const startTime = Date.now(); - force ? await serviceAdapter.forceRefreshToken() : await serviceAdapter.refreshToken() - const duration = Date.now() - startTime; - this._log('info', `Token refresh successful for node ${providerStatus.uuid} (Duration: ${duration}ms)`); - } else { - throw new Error(`refreshToken method not implemented for ${providerType}`); - } - - } catch (error) { - this._log('error', `Token refresh failed for node ${providerStatus.uuid}: ${error.message}`); - this.markProviderUnhealthyImmediately(providerType, config, `Refresh failed: ${error.message}`); - throw error; - } - } - - /** - * 计算节点的权重/评分,用于排序 - * 分数越低,优先级越高 - * @private - */ - _calculateNodeScore(providerStatus, now = Date.now(), minSeqInPool = -1) { - const config = providerStatus.config; - const state = providerStatus.state; - - // 1. 基础健康分:不健康的排最后 - if (!config.isHealthy || config.isDisabled) return 1e18; - - // 检查并发限制 - const concurrencyLimit = parseInt(config.concurrencyLimit || 0); - const queueLimit = parseInt(config.queueLimit || 0); - - if (concurrencyLimit > 0) { - if (state.activeCount >= concurrencyLimit) { - // 如果队列也满了,排在最后(但优于不健康节点) - if (queueLimit > 0 && state.waitingCount >= queueLimit) { - return 1e17; - } - // 没满,但需要排队。排队数量越多,权重越大 - return 1e15 + (state.waitingCount || 0) * 1e10; - } - } - - // 2. 预热/新鲜度判断 - const lastHealthCheckTime = config.lastHealthCheckTime ? new Date(config.lastHealthCheckTime).getTime() : 0; - const isFresh = lastHealthCheckTime && (now - lastHealthCheckTime < 60000); - - // 3. 计算统一评分 - // 基础分:新鲜节点使用固定负偏移 (-1e14),普通节点使用上次使用时间 (约 1.7e12) - const lastUsedTime = config.lastUsed ? new Date(config.lastUsed).getTime() : (now - 86400000); - const baseScore = isFresh ? -1e14 : lastUsedTime; - - // 惩罚项 A: 使用次数 (每多用一次增加 10 秒权重) - const usageCount = config.usageCount || 0; - const usageScore = usageCount * 10000; - - // 惩罚项 B: 相对序列号 (用于打破平局,确保轮询) - const lastSelectionSeq = config._lastSelectionSeq || 0; - if (minSeqInPool === -1) { - const pool = this.providerStatus[providerStatus.type] || []; - minSeqInPool = Math.min(...pool.map(p => p.config._lastSelectionSeq || 0)); - } - const relativeSeq = Math.max(0, lastSelectionSeq - minSeqInPool); - const cappedRelativeSeq = Math.min(relativeSeq, 100); - const sequenceScore = cappedRelativeSeq * 1000; - - // 惩罚项 C: 负载 (每个活跃请求增加 5 秒权重) - const loadScore = (state.activeCount || 0) * 5000; - - // 新鲜节点的微调:配合 usageScore 和 sequenceScore 在多个新鲜节点间轮询 - const freshBonus = isFresh ? (now - lastHealthCheckTime) : 0; - - return baseScore + usageScore + sequenceScore + loadScore + freshBonus; - } - - /** - * 获取指定类型的健康节点数量 - */ - getHealthyCount(providerType) { - return (this.providerStatus[providerType] || []).filter(p => p.config.isHealthy && !p.config.isDisabled).length; - } - - /** - * 日志输出方法,支持日志级别控制 - * @private - */ - _log(level, message) { - const levels = { debug: 0, info: 1, warn: 2, error: 3 }; - if (levels[level] >= levels[this.logLevel]) { - logger[level](`[ProviderPoolManager] ${message}`); - } - } - - /** - * 记录健康状态变化日志 - * @param {string} providerType - 提供商类型 - * @param {object} providerConfig - 提供商配置 - * @param {string} fromStatus - 之前状态 - * @param {string} toStatus - 当前状态 - * @param {string} [errorMessage] - 错误信息(可选) - * @private - */ - _logHealthStatusChange(providerType, providerConfig, fromStatus, toStatus, errorMessage = null) { - const customName = providerConfig.customName || providerConfig.uuid; - const timestamp = new Date().toISOString(); - - const logEntry = { - timestamp, - providerType, - uuid: providerConfig.uuid, - customName, - fromStatus, - toStatus, - errorMessage, - usageCount: providerConfig.usageCount || 0, - errorCount: providerConfig.errorCount || 0 - }; - - // 输出详细的状态变化日志 - if (toStatus === 'unhealthy') { - logger.warn(`[HealthMonitor] ⚠️ Provider became UNHEALTHY: ${customName} (${providerType})`); - logger.warn(`[HealthMonitor] Reason: ${errorMessage || 'Unknown'}`); - logger.warn(`[HealthMonitor] Error Count: ${providerConfig.errorCount}`); - - // 触发告警(如果配置了 Webhook) - this._triggerHealthAlert(providerType, providerConfig, 'unhealthy', errorMessage); - } else if (toStatus === 'healthy' && fromStatus === 'unhealthy') { - logger.info(`[HealthMonitor] ✅ Provider recovered to HEALTHY: ${customName} (${providerType})`); - - // 触发恢复通知 - this._triggerHealthAlert(providerType, providerConfig, 'recovered', null); - } - - // 广播健康状态变化事件 - broadcastEvent('health_status_change', logEntry); - } - - /** - * 触发健康状态告警 - * @param {string} providerType - 提供商类型 - * @param {object} providerConfig - 提供商配置 - * @param {string} status - 状态 ('unhealthy' | 'recovered') - * @param {string} [errorMessage] - 错误信息 - * @private - */ - async _triggerHealthAlert(providerType, providerConfig, status, errorMessage = null) { - const webhookUrl = this.globalConfig?.HEALTH_ALERT_WEBHOOK_URL; - if (!webhookUrl) { - return; // 未配置 Webhook,跳过 - } - - const customName = providerConfig.customName || providerConfig.uuid; - const payload = { - timestamp: new Date().toISOString(), - providerType, - uuid: providerConfig.uuid, - customName, - status, - errorMessage, - stats: { - usageCount: providerConfig.usageCount || 0, - errorCount: providerConfig.errorCount || 0 - } - }; - - try { - const axios = (await import('axios')).default; - await axios.post(webhookUrl, payload, { - timeout: 5000, - headers: { 'Content-Type': 'application/json' } - }); - this._log('info', `Health alert sent to webhook for ${customName}: ${status}`); - } catch (error) { - this._log('error', `Failed to send health alert to webhook: ${error.message}`); - } - } - - /** - * 查找指定的 provider - * @private - */ - _findProvider(providerType, uuid) { - if (!providerType || !uuid) { - this._log('error', `Invalid parameters: providerType=${providerType}, uuid=${uuid}`); - return null; - } - const pool = this.providerStatus[providerType]; - return pool?.find(p => p.uuid === uuid) || null; - } - - /** - * 根据 UUID 在所有池中查找提供商配置 - * @param {string} uuid - 提供商 UUID - * @returns {object|null} 提供商配置对象或 null - */ - findProviderByUuid(uuid) { - if (!uuid) return null; - for (const type in this.providerStatus) { - const provider = this.providerStatus[type].find(p => p.uuid === uuid); - if (provider) return provider.config; - } - return null; - } - - /** - * Initializes the status for each provider in the pools. - * Initially, all providers are considered healthy and have zero usage. - */ - initializeProviderStatus() { - for (const providerType in this.providerPools) { - const oldStatus = this.providerStatus[providerType] || []; - this.providerStatus[providerType] = []; - this.roundRobinIndex[providerType] = 0; // Initialize round-robin index for each type - // 只有在锁不存在时才初始化,避免在运行中被重置导致并发问题 - if (!this._selectionLocks[providerType]) { - this._selectionLocks[providerType] = Promise.resolve(); - } - this.providerPools[providerType].forEach((providerConfig) => { - // 尝试从旧状态中恢复活跃请求计数和队列,避免重载配置时重置并发限制 - const existing = oldStatus.find(p => p.uuid === providerConfig.uuid); - - // Ensure initial health and usage stats are present in the config - providerConfig.isHealthy = providerConfig.isHealthy !== undefined ? providerConfig.isHealthy : true; - providerConfig.isDisabled = providerConfig.isDisabled !== undefined ? providerConfig.isDisabled : false; - providerConfig.lastUsed = providerConfig.lastUsed !== undefined ? providerConfig.lastUsed : null; - providerConfig.usageCount = providerConfig.usageCount !== undefined ? providerConfig.usageCount : 0; - providerConfig.errorCount = providerConfig.errorCount !== undefined ? providerConfig.errorCount : 0; - - // --- V2: 刷新监控字段 --- - providerConfig.needsRefresh = providerConfig.needsRefresh !== undefined ? providerConfig.needsRefresh : false; - providerConfig.refreshCount = providerConfig.refreshCount !== undefined ? providerConfig.refreshCount : 0; - - // 优化2: 简化 lastErrorTime 处理逻辑 - providerConfig.lastErrorTime = providerConfig.lastErrorTime instanceof Date - ? providerConfig.lastErrorTime.toISOString() - : (providerConfig.lastErrorTime || null); - - // 健康检测相关字段 - providerConfig.lastHealthCheckTime = providerConfig.lastHealthCheckTime || null; - providerConfig.lastHealthCheckModel = providerConfig.lastHealthCheckModel || null; - providerConfig.lastErrorMessage = providerConfig.lastErrorMessage || null; - providerConfig.customName = providerConfig.customName || null; - - this.providerStatus[providerType].push({ - config: providerConfig, - uuid: providerConfig.uuid, // Still keep uuid at the top level for easy access - type: providerType, // 保存 providerType 引用 - state: existing ? existing.state : { - activeCount: 0, - waitingCount: 0, - queue: [] - } - }); - }); - } - this._log('info', `Initialized provider statuses: ok (maxErrorCount: ${this.maxErrorCount})`); - } - - /** - * 获取一个可用的提供商插槽,考虑并发限制和队列 - * @param {string} providerType - * @param {string} requestedModel - * @param {object} options - */ - async acquireSlot(providerType, requestedModel = null, options = {}) { - // 使用 selectProvider 进行初次选择(评分逻辑已经包含了并发考虑) - const selectedConfig = await this.selectProvider(providerType, requestedModel, { ...options, skipUsageCount: true }); - - if (!selectedConfig) { - return null; - } - - const provider = this._findProvider(providerType, selectedConfig.uuid); - if (!provider) return selectedConfig; - - const config = provider.config; - const state = provider.state; - const concurrencyLimit = parseInt(config.concurrencyLimit || 0); - const queueLimit = parseInt(config.queueLimit || 0); - - // 如果没有限制,直接增加活跃计数并返回 - if (concurrencyLimit <= 0) { - state.activeCount++; - return config; - } - - // 检查是否在并发限制内 - if (state.activeCount < concurrencyLimit) { - state.activeCount++; - return config; - } - - // 超过并发限制,尝试进入队列 - if (queueLimit > 0 && state.waitingCount < queueLimit) { - this._log('info', `[Concurrency] Node ${config.uuid} busy (${state.activeCount}/${concurrencyLimit}), enqueuing request (queue: ${state.waitingCount + 1}/${queueLimit})`); - - state.waitingCount++; - try { - // 等待释放信号 - await new Promise((resolve, reject) => { - // 设置较短的超时用于测试验证,或者由外部控制 - const timeoutMs = options.queueTimeout || 300000; - const timeout = setTimeout(() => { - const idx = state.queue.indexOf(handler); - if (idx !== -1) { - state.queue.splice(idx, 1); - reject(new Error(`Queue timeout after ${timeoutMs/1000}s`)); - } - }, timeoutMs); - - const handler = () => { - clearTimeout(timeout); - resolve(); - }; - state.queue.push(handler); - }); - } finally { - state.waitingCount--; - } - - // 获得信号后,增加活跃计数 - state.activeCount++; - return config; - } - - // 队列也满了 - this._log('warn', `[Concurrency] Node ${config.uuid} full capacity (${state.activeCount}/${concurrencyLimit}, queue: ${state.waitingCount}/${queueLimit}), returning 429`); - const error = new Error('Too many requests: account concurrency limit and queue reached'); - error.status = 429; - error.code = 429; - throw error; - } - - /** - * 释放提供商插槽 - */ - releaseSlot(providerType, uuid) { - if (!providerType || !uuid) return; - - const provider = this._findProvider(providerType, uuid); - if (!provider) return; - - const state = provider.state; - if (state.activeCount > 0) { - state.activeCount--; - } - - // 如果队列中有等待的任务,释放下一个 - if (state.queue && state.queue.length > 0) { - const next = state.queue.shift(); - if (next) { - // 异步触发 - setImmediate(next); - } - } - } - - /** - * Selects a provider from the pool for a given provider type. - * Currently uses a simple round-robin for healthy providers. - * If requestedModel is provided, providers that don't support the model will be excluded. - * - * 注意:此方法现在返回 Promise,使用互斥锁确保并发安全。 - * - * @param {string} providerType - The type of provider to select (e.g., 'gemini-cli', 'openai-custom'). - * @param {string} [requestedModel] - Optional. The model name to filter providers by. - * @returns {Promise} The selected provider's configuration, or null if no healthy provider is found. - */ - async selectProvider(providerType, requestedModel = null, options = {}) { - // 参数校验 - if (!providerType || typeof providerType !== 'string') { - this._log('error', `Invalid providerType: ${providerType}`); - return null; - } - - // 使用标志位 + 异步等待实现更强力的互斥锁 - // 这种方式能更好地处理同一微任务循环内的并发 - while (this._isSelecting[providerType]) { - await new Promise(resolve => setImmediate(resolve)); - } - - this._isSelecting[providerType] = true; - - try { - // 在锁内部执行同步选择 - return this._doSelectProvider(providerType, requestedModel, options); - } finally { - this._isSelecting[providerType] = false; - } - } - - /** - * 实际执行 provider 选择的内部方法(同步执行,由锁保护) - * @private - */ - _doSelectProvider(providerType, requestedModel, options) { - const availableProviders = this.providerStatus[providerType] || []; - - // 检查并恢复已到恢复时间的提供商 - this._checkAndRecoverScheduledProviders(providerType); - - // 获取固定时间戳,确保排序过程中一致 - const now = Date.now(); - - // 提前计算池中最小序列号,避免在排序算法中重复 O(N) 计算 - const minSeq = Math.min(...availableProviders.map(p => p.config._lastSelectionSeq || 0)); - - let availableAndHealthyProviders = availableProviders.filter(p => - p.config.isHealthy && !p.config.isDisabled && !p.config.needsRefresh - ); - - // 如果指定了模型,则排除不支持该模型的提供商 - if (requestedModel) { - const modelFilteredProviders = availableAndHealthyProviders.filter(p => { - // 如果提供商没有配置 notSupportedModels,则认为它支持所有模型 - if (!p.config.notSupportedModels || !Array.isArray(p.config.notSupportedModels)) { - return true; - } - // 检查 notSupportedModels 数组中是否包含请求的模型,如果包含则排除 - return !p.config.notSupportedModels.includes(requestedModel); - }); - - if (modelFilteredProviders.length === 0) { - this._log('warn', `No available providers for type: ${providerType} that support model: ${requestedModel}`); - return null; - } - - availableAndHealthyProviders = modelFilteredProviders; - this._log('debug', `Filtered ${modelFilteredProviders.length} providers supporting model: ${requestedModel}`); - } - - if (availableAndHealthyProviders.length === 0) { - this._log('warn', `No available and healthy providers for type: ${providerType}`); - return null; - } - - // 改进:使用统一的评分策略进行选择 - // 传入当前时间戳 now 确保一致性 - const selected = availableAndHealthyProviders.sort((a, b) => { - const scoreA = this._calculateNodeScore(a, now, minSeq); - const scoreB = this._calculateNodeScore(b, now, minSeq); - if (scoreA !== scoreB) return scoreA - scoreB; - // 如果分值相同,使用 UUID 排序确保确定性 - return a.uuid < b.uuid ? -1 : 1; - })[0]; - - // 始终更新 lastUsed(确保 LRU 策略生效,避免并发请求选到同一个 provider) - // usageCount 只在请求成功后才增加(由 skipUsageCount 控制) - selected.config.lastUsed = new Date().toISOString(); - - // 更新自增序列号,确保即使毫秒级并发,也能在下一轮排序中被区分开 - this._selectionSequence++; - selected.config._lastSelectionSeq = this._selectionSequence; - - // 强制打印选中日志,方便排查并发问题 - this._log('info', `[Concurrency Control] Atomic selection: ${selected.config.uuid} (Seq: ${this._selectionSequence})`); - - if (!options.skipUsageCount) { - selected.config.usageCount++; - } - // 使用防抖保存(文件 I/O 是异步的,但内存已经更新) - this._debouncedSave(providerType); - - this._log('debug', `Selected provider for ${providerType} (LRU): ${selected.config.uuid}${requestedModel ? ` for model: ${requestedModel}` : ''}${options.skipUsageCount ? ' (skip usage count)' : ''}`); - - return selected.config; - } - - /** - * 获取一个可用的提供商插槽,支持 Fallback 机制 - */ - async acquireSlotWithFallback(providerType, requestedModel = null, options = {}) { - if (!providerType || typeof providerType !== 'string') { - this._log('error', `Invalid providerType: ${providerType}`); - return null; - } - - const triedTypes = new Set(); - const typesToTry = [providerType]; - - const fallbackTypes = this.fallbackChain[providerType] || []; - if (Array.isArray(fallbackTypes)) { - typesToTry.push(...fallbackTypes); - } - - for (const currentType of typesToTry) { - if (triedTypes.has(currentType)) continue; - triedTypes.add(currentType); - - if (!this.providerStatus[currentType] || this.providerStatus[currentType].length === 0) { - continue; - } - - if (currentType !== providerType && requestedModel) { - const primaryProtocol = getProtocolPrefix(providerType); - const fallbackProtocol = getProtocolPrefix(currentType); - if (primaryProtocol !== fallbackProtocol) continue; - - const supportedModels = getProviderModels(currentType); - if (supportedModels.length > 0 && !supportedModels.includes(requestedModel)) continue; - } - - // 尝试获取插槽 - try { - const selectedConfig = await this.acquireSlot(currentType, requestedModel, options); - if (selectedConfig) { - if (currentType !== providerType) { - this._log('info', `Fallback Slot activated (Chain): ${providerType} -> ${currentType} (uuid: ${selectedConfig.uuid})`); - } - return { - config: selectedConfig, - actualProviderType: currentType, - isFallback: currentType !== providerType - }; - } - } catch (err) { - if (err.status === 429) { - // 如果是因为 429 (并发/队列满),尝试下一个 Fallback - this._log('info', `Type ${currentType} busy (429), trying next fallback...`); - continue; - } - throw err; // 其他错误抛出 - } - } - - // Model Fallback Mapping - if (requestedModel && this.modelFallbackMapping && this.modelFallbackMapping[requestedModel]) { - const mapping = this.modelFallbackMapping[requestedModel]; - const targetProviderType = mapping.targetProviderType; - const targetModel = mapping.targetModel; - - if (targetProviderType && targetModel) { - if (this.providerStatus[targetProviderType] && this.providerStatus[targetProviderType].length > 0) { - try { - const selectedConfig = await this.acquireSlot(targetProviderType, targetModel, options); - if (selectedConfig) { - return { - config: selectedConfig, - actualProviderType: targetProviderType, - isFallback: true, - actualModel: targetModel - }; - } - } catch (err) { - // 如果目标类型繁忙,尝试它的 fallback chain - const targetFallbackTypes = this.fallbackChain[targetProviderType] || []; - for (const fallbackType of targetFallbackTypes) { - const targetProtocol = getProtocolPrefix(targetProviderType); - const fallbackProtocol = getProtocolPrefix(fallbackType); - if (targetProtocol !== fallbackProtocol) continue; - - const supportedModels = getProviderModels(fallbackType); - if (supportedModels.length > 0 && !supportedModels.includes(targetModel)) continue; - - try { - const fallbackSelectedConfig = await this.acquireSlot(fallbackType, targetModel, options); - if (fallbackSelectedConfig) { - return { - config: fallbackSelectedConfig, - actualProviderType: fallbackType, - isFallback: true, - actualModel: targetModel - }; - } - } catch (e) { - continue; - } - } - } - } - } - } - - return null; - } - - /** - * Selects a provider from the pool with fallback support. - * When the primary provider type has no healthy providers, it will try fallback types. - * @param {string} providerType - The primary type of provider to select. - * @param {string} [requestedModel] - Optional. The model name to filter providers by. - * @param {Object} [options] - Optional. Additional options. - * @param {boolean} [options.skipUsageCount] - Optional. If true, skip incrementing usage count. - * @returns {object|null} An object containing the selected provider's configuration and the actual provider type used, or null if no healthy provider is found. - */ - /** - * Selects a provider from the pool with fallback support. - * When the primary provider type has no healthy providers, it will try fallback types. - * - * 注意:此方法现在返回 Promise,因为内部调用的 selectProvider 是异步的。 - * - * @param {string} providerType - The primary type of provider to select. - * @param {string} [requestedModel] - Optional. The model name to filter providers by. - * @param {Object} [options] - Optional. Additional options. - * @param {boolean} [options.skipUsageCount] - Optional. If true, skip incrementing usage count. - * @returns {Promise} An object containing the selected provider's configuration and the actual provider type used, or null if no healthy provider is found. - */ - async selectProviderWithFallback(providerType, requestedModel = null, options = {}) { - // 参数校验 - if (!providerType || typeof providerType !== 'string') { - this._log('error', `Invalid providerType: ${providerType}`); - return null; - } - - // ========================== - // 优先级 1: Provider Fallback Chain (同协议/兼容协议的回退) - // ========================== - - // 记录尝试过的类型,避免循环 - const triedTypes = new Set(); - const typesToTry = [providerType]; - - const fallbackTypes = this.fallbackChain[providerType] || []; - if (Array.isArray(fallbackTypes)) { - typesToTry.push(...fallbackTypes); - } - - for (const currentType of typesToTry) { - // 避免重复尝试 - if (triedTypes.has(currentType)) { - continue; - } - triedTypes.add(currentType); - - // 检查该类型是否有配置的池 - if (!this.providerStatus[currentType] || this.providerStatus[currentType].length === 0) { - this._log('debug', `No provider pool configured for type: ${currentType}`); - continue; - } - - // 如果是 fallback 类型,需要检查模型兼容性 - if (currentType !== providerType && requestedModel) { - // 检查协议前缀是否兼容 - const primaryProtocol = getProtocolPrefix(providerType); - const fallbackProtocol = getProtocolPrefix(currentType); - - if (primaryProtocol !== fallbackProtocol) { - this._log('debug', `Skipping fallback type ${currentType}: protocol mismatch (${primaryProtocol} vs ${fallbackProtocol})`); - continue; - } - - // 检查 fallback 类型是否支持请求的模型 - const supportedModels = getProviderModels(currentType); - if (supportedModels.length > 0 && !supportedModels.includes(requestedModel)) { - this._log('debug', `Skipping fallback type ${currentType}: model ${requestedModel} not supported`); - continue; - } - } - - // 尝试从当前类型选择提供商(现在是异步的) - const selectedConfig = await this.selectProvider(currentType, requestedModel, options); - - if (selectedConfig) { - if (currentType !== providerType) { - this._log('info', `Fallback activated (Chain): ${providerType} -> ${currentType} (uuid: ${selectedConfig.uuid})`); - } - return { - config: selectedConfig, - actualProviderType: currentType, - isFallback: currentType !== providerType - }; - } - } - - // ========================== - // 优先级 2: Model Fallback Mapping (跨协议/特定模型的回退) - // ========================== - - if (requestedModel && this.modelFallbackMapping && this.modelFallbackMapping[requestedModel]) { - const mapping = this.modelFallbackMapping[requestedModel]; - const targetProviderType = mapping.targetProviderType; - const targetModel = mapping.targetModel; - - if (targetProviderType && targetModel) { - this._log('info', `Trying Model Fallback Mapping for ${requestedModel}: -> ${targetProviderType} (${targetModel})`); - - // 递归调用 selectProviderWithFallback,但这次针对目标提供商类型 - // 注意:这里我们直接尝试从目标提供商池中选择,因为如果再次递归可能会导致死循环或逻辑复杂化 - // 简单起见,我们直接尝试选择目标提供商 - - // 检查目标类型是否有配置的池 - if (this.providerStatus[targetProviderType] && this.providerStatus[targetProviderType].length > 0) { - // 尝试从目标类型选择提供商(使用转换后的模型名,现在是异步的) - const selectedConfig = await this.selectProvider(targetProviderType, targetModel, options); - - if (selectedConfig) { - this._log('info', `Fallback activated (Model Mapping): ${providerType} (${requestedModel}) -> ${targetProviderType} (${targetModel}) (uuid: ${selectedConfig.uuid})`); - return { - config: selectedConfig, - actualProviderType: targetProviderType, - isFallback: true, - actualModel: targetModel // 返回实际使用的模型名,供上层进行请求转换 - }; - } else { - // 如果目标类型的主池也不可用,尝试目标类型的 fallback chain - // 例如 claude-kiro-oauth (mapped) -> claude-custom (chain) - // 这需要我们小心处理,避免无限递归。 - // 我们可以手动检查目标类型的 fallback chain - - const targetFallbackTypes = this.fallbackChain[targetProviderType] || []; - for (const fallbackType of targetFallbackTypes) { - // 检查协议兼容性 (目标类型 vs 它的 fallback) - const targetProtocol = getProtocolPrefix(targetProviderType); - const fallbackProtocol = getProtocolPrefix(fallbackType); - - if (targetProtocol !== fallbackProtocol) continue; - - // 检查模型支持 - const supportedModels = getProviderModels(fallbackType); - if (supportedModels.length > 0 && !supportedModels.includes(targetModel)) continue; - - const fallbackSelectedConfig = await this.selectProvider(fallbackType, targetModel, options); - if (fallbackSelectedConfig) { - this._log('info', `Fallback activated (Model Mapping -> Chain): ${providerType} (${requestedModel}) -> ${targetProviderType} -> ${fallbackType} (${targetModel}) (uuid: ${fallbackSelectedConfig.uuid})`); - return { - config: fallbackSelectedConfig, - actualProviderType: fallbackType, - isFallback: true, - actualModel: targetModel - }; - } - } - } - } else { - this._log('warn', `Model Fallback target provider ${targetProviderType} not configured or empty.`); - } - } - } - - this._log('warn', `None available provider found for ${providerType} (Model: ${requestedModel}) after checking fallback chain and model mapping.`); - return null; - } - - /** - * Gets the fallback chain for a given provider type. - * @param {string} providerType - The provider type to get fallback chain for. - * @returns {Array} The fallback chain array, or empty array if not configured. - */ - getFallbackChain(providerType) { - return this.fallbackChain[providerType] || []; - } - - /** - * Sets or updates the fallback chain for a provider type. - * @param {string} providerType - The provider type to set fallback chain for. - * @param {Array} fallbackTypes - Array of fallback provider types. - */ - setFallbackChain(providerType, fallbackTypes) { - if (!Array.isArray(fallbackTypes)) { - this._log('error', `Invalid fallbackTypes: must be an array`); - return; - } - this.fallbackChain[providerType] = fallbackTypes; - this._log('info', `Updated fallback chain for ${providerType}: ${fallbackTypes.join(' -> ')}`); - } - - /** - * Checks if all providers of a given type are unhealthy. - * @param {string} providerType - The provider type to check. - * @returns {boolean} True if all providers are unhealthy or disabled. - */ - isAllProvidersUnhealthy(providerType) { - const providers = this.providerStatus[providerType] || []; - if (providers.length === 0) { - return true; - } - return providers.every(p => !p.config.isHealthy || p.config.isDisabled); - } - - /** - * Gets statistics about provider health for a given type. - * @param {string} providerType - The provider type to get stats for. - * @returns {Object} Statistics object with total, healthy, unhealthy, and disabled counts. - */ - getProviderStats(providerType) { - const providers = this.providerStatus[providerType] || []; - const stats = { - total: providers.length, - healthy: 0, - unhealthy: 0, - disabled: 0 - }; - - for (const p of providers) { - if (p.config.isDisabled) { - stats.disabled++; - } else if (p.config.isHealthy) { - stats.healthy++; - } else { - stats.unhealthy++; - } - } - - return stats; - } - - /** - * Gets all available models across all provider pools, with optional format conversion. - * @param {string} [endpointType] - Optional endpoint type for format conversion (OPENAI_MODEL_LIST or GEMINI_MODEL_LIST). - * @returns {Promise} Formatted model list or raw array of model objects. - */ - async getAllAvailableModels(endpointType = null) { - const allModels = []; - - // 获取所有已注册的提供商和号池中的提供商 - const registeredProviders = getRegisteredProviders(); - const allProviderTypes = Array.from(new Set([...registeredProviders])); - - for (const providerType of allProviderTypes) { - if (this.providerStatus[providerType]) { - let models = getProviderModels(providerType); - - // 如果硬编码的模型列表为空,或者该类型的提供商在号池中没有配置节点,尝试从服务获取 - if (models.length === 0) { - try { - // 确定使用的配置:优先使用号池中第一个节点的配置,否则使用全局配置 - let targetConfig = this.globalConfig; - if (this.providerStatus[providerType] && this.providerStatus[providerType].length > 0) { - targetConfig = this.providerStatus[providerType][0].config; - } - - const tempConfig = { - ...this.globalConfig, - ...targetConfig, - MODEL_PROVIDER: providerType - }; - const serviceAdapter = getServiceAdapter(tempConfig); - - if (typeof serviceAdapter.listModels === 'function') { - const nativeModels = await serviceAdapter.listModels(); - // 统一转换为 OpenAI 格式以便提取 ID - const convertedData = convertData(nativeModels, 'modelList', providerType, MODEL_PROVIDER.OPENAI_CUSTOM); - if (convertedData && Array.isArray(convertedData.data)) { - const fetchedModels = convertedData.data.map(m => m.id); - if (fetchedModels.length > 0) { - models = fetchedModels; - } - } - } - } catch (err) { - this._log('debug', `Failed to fetch model list for ${providerType} from service: ${err.message}`); - // 保持原有的 models (可能是硬编码的空列表或 getProviderModels 返回的结果) - } - } - - for (const model of models) { - allModels.push({ - id: `${providerType}:${model}`, - provider: providerType, - model: model - }); - } - } - } - - // 如果没有指定 endpointType,返回原始数组 - if (!endpointType) { - return allModels; - } - - // 根据 endpointType 转换为对应格式 - if (endpointType === ENDPOINT_TYPE.OPENAI_MODEL_LIST) { - // OpenAI 格式聚合 - return { - object: "list", - data: allModels.map(m => ({ - id: m.id, - object: "model", - created: Math.floor(Date.now() / 1000), - owned_by: m.provider - })) - }; - } else if (endpointType === ENDPOINT_TYPE.GEMINI_MODEL_LIST) { - // Gemini 格式聚合 - return { - models: allModels.map(m => ({ - name: `models/${m.id}`, - baseModelId: m.model, - version: "v1", - displayName: `${m.model} (${m.provider})`, - description: `Model ${m.model} provided by ${m.provider}`, - supportedGenerationMethods: ["generateContent", "countTokens"] - })) - }; - } - - // 默认返回空列表 - return { data: [] }; - } - - /** - * 标记提供商需要刷新并推入刷新队列 - * @param {string} providerType - 提供商类型 - * @param {object} providerConfig - 提供商配置(包含 uuid) - */ - markProviderNeedRefresh(providerType, providerConfig) { - - if (!providerConfig?.uuid) { - this._log('error', 'Invalid providerConfig in markProviderNeedRefresh'); - return; - } - - const provider = this._findProvider(providerType, providerConfig.uuid); - if (provider) { - provider.config.needsRefresh = true; - this._log('info', `Marked provider ${providerConfig.uuid} as needsRefresh. Enqueuing...`); - - // 推入异步刷新队列 - this._enqueueRefresh(providerType, provider, true); - - this._debouncedSave(providerType); - } - } - - /** - * Marks a provider as unhealthy (e.g., after an API error). - * @param {string} providerType - The type of the provider. - * @param {object} providerConfig - The configuration of the provider to mark. - * @param {string} [errorMessage] - Optional error message to store. - */ - markProviderUnhealthy(providerType, providerConfig, errorMessage = null) { - if (!providerConfig?.uuid) { - this._log('error', 'Invalid providerConfig in markProviderUnhealthy'); - return; - } - - const provider = this._findProvider(providerType, providerConfig.uuid); - if (provider) { - const wasHealthy = provider.config.isHealthy; - const now = Date.now(); - const lastErrorTime = provider.config.lastErrorTime ? new Date(provider.config.lastErrorTime).getTime() : 0; - const errorWindowMs = 10000; // 10 秒窗口期 - - // 如果距离上次错误超过窗口期,重置错误计数 - if (now - lastErrorTime > errorWindowMs) { - provider.config.errorCount = 1; - } else { - provider.config.errorCount++; - } - - provider.config.lastErrorTime = new Date().toISOString(); - // 更新 lastUsed 时间,避免因 LRU 策略导致失败节点被重复选中 - provider.config.lastUsed = new Date().toISOString(); - - // 保存错误信息 - if (errorMessage) { - provider.config.lastErrorMessage = errorMessage; - } - - if (this.maxErrorCount > 0 && provider.config.errorCount >= this.maxErrorCount) { - provider.config.isHealthy = false; - - // 健康状态变化日志 - if (wasHealthy) { - this._logHealthStatusChange(providerType, provider.config, 'healthy', 'unhealthy', errorMessage); - } - - this._log('warn', `Marked provider as unhealthy: ${providerConfig.uuid} for type ${providerType}. Total errors: ${provider.config.errorCount}`); - } - - this._debouncedSave(providerType); - } - } - - /** - * Marks a provider as unhealthy immediately (without accumulating error count). - * Used for definitive authentication errors like 401/403. - * @param {string} providerType - The type of the provider. - * @param {object} providerConfig - The configuration of the provider to mark. - * @param {string} [errorMessage] - Optional error message to store. - */ - markProviderUnhealthyImmediately(providerType, providerConfig, errorMessage = null) { - if (!providerConfig?.uuid) { - this._log('error', 'Invalid providerConfig in markProviderUnhealthyImmediately'); - return; - } - - const provider = this._findProvider(providerType, providerConfig.uuid); - if (provider) { - const wasHealthy = provider.config.isHealthy; - provider.config.isHealthy = false; - provider.config.errorCount = this.maxErrorCount; // Set to max to indicate definitive failure - provider.config.lastErrorTime = new Date().toISOString(); - provider.config.lastUsed = new Date().toISOString(); - - if (errorMessage) { - provider.config.lastErrorMessage = errorMessage; - } - - // 健康状态变化日志 - if (wasHealthy) { - this._logHealthStatusChange(providerType, provider.config, 'healthy', 'unhealthy', errorMessage); - } - - this._log('warn', `Immediately marked provider as unhealthy: ${providerConfig.uuid} for type ${providerType}. Reason: ${errorMessage || 'Authentication error'}`); - - this._debouncedSave(providerType); - } - } - - /** - * Marks a provider as unhealthy with a scheduled recovery time. - * Used for quota exhaustion errors (402) where the quota will reset at a specific time. - * @param {string} providerType - The type of the provider. - * @param {object} providerConfig - The configuration of the provider to mark. - * @param {string} [errorMessage] - Optional error message to store. - * @param {Date|string} [recoveryTime] - Optional recovery time when the provider should be marked healthy again. - */ - markProviderUnhealthyWithRecoveryTime(providerType, providerConfig, errorMessage = null, recoveryTime = null) { - if (!providerConfig?.uuid) { - this._log('error', 'Invalid providerConfig in markProviderUnhealthyWithRecoveryTime'); - return; - } - - const provider = this._findProvider(providerType, providerConfig.uuid); - if (provider) { - provider.config.isHealthy = false; - provider.config.errorCount = this.maxErrorCount; // Set to max to indicate definitive failure - provider.config.lastErrorTime = new Date().toISOString(); - provider.config.lastUsed = new Date().toISOString(); - - if (errorMessage) { - provider.config.lastErrorMessage = errorMessage; - } - - // Set recovery time if provided - if (recoveryTime) { - const recoveryDate = recoveryTime instanceof Date ? recoveryTime : new Date(recoveryTime); - provider.config.scheduledRecoveryTime = recoveryDate.toISOString(); - this._log('warn', `Marked provider as unhealthy with recovery time: ${providerConfig.uuid} for type ${providerType}. Recovery at: ${recoveryDate.toISOString()}. Reason: ${errorMessage || 'Quota exhausted'}`); - } else { - this._log('warn', `Marked provider as unhealthy: ${providerConfig.uuid} for type ${providerType}. Reason: ${errorMessage || 'Quota exhausted'}`); - } - - this._debouncedSave(providerType); - } - } - - /** - * Marks a provider as healthy. - * @param {string} providerType - The type of the provider. - * @param {object} providerConfig - The configuration of the provider to mark. - * @param {boolean} resetUsageCount - Whether to reset usage count (optional, default: false). - * @param {string} [healthCheckModel] - Optional model name used for health check. - */ - markProviderHealthy(providerType, providerConfig, resetUsageCount = false, healthCheckModel = null) { - if (!providerConfig?.uuid) { - this._log('error', 'Invalid providerConfig in markProviderHealthy'); - return; - } - - const provider = this._findProvider(providerType, providerConfig.uuid); - if (provider) { - const wasHealthy = provider.config.isHealthy; - provider.config.isHealthy = true; - provider.config.errorCount = 0; - provider.config.refreshCount = 0; - provider.config.needsRefresh = false; - provider.config.lastErrorTime = null; - provider.config.lastErrorMessage = null; - provider.config._lastSelectionSeq = 0; - - // 更新健康检测信息 - if (healthCheckModel) { - provider.config.lastHealthCheckTime = new Date().toISOString(); - provider.config.lastHealthCheckModel = healthCheckModel; - } - - // 只有在明确要求重置使用计数时才重置 - if (resetUsageCount) { - provider.config.usageCount = 0; - }else{ - provider.config.usageCount++; - provider.config.lastUsed = new Date().toISOString(); - } - - // 健康状态变化日志 - if (!wasHealthy) { - this._logHealthStatusChange(providerType, provider.config, 'unhealthy', 'healthy', null); - } - - this._log('info', `Marked provider as healthy: ${provider.config.uuid} for type ${providerType}${resetUsageCount ? ' (usage count reset)' : ''}`); - - this._debouncedSave(providerType); - } - } - - /** - * 重置提供商的刷新状态(needsRefresh 和 refreshCount) - * 并将其标记为健康,以便立即投入使用 - * @param {string} providerType - 提供商类型 - * @param {string} uuid - 提供商 UUID - */ - resetProviderRefreshStatus(providerType, uuid) { - if (!providerType || !uuid) { - this._log('error', 'Invalid parameters in resetProviderRefreshStatus'); - return; - } - - const provider = this._findProvider(providerType, uuid); - if (provider) { - provider.config.needsRefresh = false; - provider.config.refreshCount = 0; - // 更新为可用 - provider.config.lastHealthCheckTime = new Date().toISOString(); - // 标记为健康,以便立即投入使用 - this._log('info', `Reset refresh status and marked healthy for provider ${uuid} (${providerType})`); - - this._debouncedSave(providerType); - } - } - - /** - * 重置提供商的计数器(错误计数和使用计数) - * @param {string} providerType - The type of the provider. - * @param {object} providerConfig - The configuration of the provider to mark. - */ - resetProviderCounters(providerType, providerConfig) { - if (!providerConfig?.uuid) { - this._log('error', 'Invalid providerConfig in resetProviderCounters'); - return; - } - - const provider = this._findProvider(providerType, providerConfig.uuid); - if (provider) { - provider.config.errorCount = 0; - provider.config.usageCount = 0; - provider.config._lastSelectionSeq = 0; - this._log('info', `Reset provider counters: ${provider.config.uuid} for type ${providerType}`); - - this._debouncedSave(providerType); - } - } - - /** - * 禁用指定提供商 - * @param {string} providerType - 提供商类型 - * @param {object} providerConfig - 提供商配置 - */ - disableProvider(providerType, providerConfig) { - if (!providerConfig?.uuid) { - this._log('error', 'Invalid providerConfig in disableProvider'); - return; - } - - const provider = this._findProvider(providerType, providerConfig.uuid); - if (provider) { - provider.config.isDisabled = true; - this._log('info', `Disabled provider: ${providerConfig.uuid} for type ${providerType}`); - this._debouncedSave(providerType); - } - } - - /** - * 启用指定提供商 - * @param {string} providerType - 提供商类型 - * @param {object} providerConfig - 提供商配置 - */ - enableProvider(providerType, providerConfig) { - if (!providerConfig?.uuid) { - this._log('error', 'Invalid providerConfig in enableProvider'); - return; - } - - const provider = this._findProvider(providerType, providerConfig.uuid); - if (provider) { - provider.config.isDisabled = false; - this._log('info', `Enabled provider: ${providerConfig.uuid} for type ${providerType}`); - this._debouncedSave(providerType); - } - } - - /** - * 刷新指定提供商的 UUID - * 用于在认证错误(如 401)时更换 UUID,以便重新尝试 - * @param {string} providerType - 提供商类型 - * @param {object} providerConfig - 提供商配置(包含当前 uuid) - * @returns {string|null} 新的 UUID,如果失败则返回 null - */ - refreshProviderUuid(providerType, providerConfig) { - if (!providerConfig?.uuid) { - this._log('error', 'Invalid providerConfig in refreshProviderUuid'); - return null; - } - - const provider = this._findProvider(providerType, providerConfig.uuid); - if (provider) { - const oldUuid = provider.config.uuid; - // 生成新的 UUID - const newUuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { - const r = Math.random() * 16 | 0; - const v = c === 'x' ? r : (r & 0x3 | 0x8); - return v.toString(16); - }); - - // 更新 provider 的 UUID - provider.uuid = newUuid; - provider.config.uuid = newUuid; - - // 同时更新 providerPools 中的原始数据 - const poolArray = this.providerPools[providerType]; - if (poolArray) { - const originalProvider = poolArray.find(p => p.uuid === oldUuid); - if (originalProvider) { - originalProvider.uuid = newUuid; - } - } - - this._log('info', `Refreshed provider UUID: ${oldUuid} -> ${newUuid} for type ${providerType}`); - this._debouncedSave(providerType); - - return newUuid; - } - - this._log('warn', `Provider not found for UUID refresh: ${providerConfig.uuid} in ${providerType}`); - return null; - } - - /** - * 检查并恢复已到恢复时间的提供商 - * @param {string} [providerType] - 可选,指定要检查的提供商类型。如果不提供,检查所有类型 - * @private - */ - _checkAndRecoverScheduledProviders(providerType = null) { - const now = new Date(); - const typesToCheck = providerType ? [providerType] : Object.keys(this.providerStatus); - - for (const type of typesToCheck) { - const providers = this.providerStatus[type] || []; - for (const providerStatus of providers) { - const config = providerStatus.config; - - // 检查是否有 scheduledRecoveryTime 且已到恢复时间 - if (config.scheduledRecoveryTime && !config.isHealthy) { - const recoveryTime = new Date(config.scheduledRecoveryTime); - if (now >= recoveryTime) { - this._log('info', `Auto-recovering provider ${config.uuid} (${type}). Scheduled recovery time reached: ${recoveryTime.toISOString()}`); - - // 恢复健康状态 - config.isHealthy = true; - config.errorCount = 0; - config.lastErrorTime = null; - config.lastErrorMessage = null; - config.scheduledRecoveryTime = null; // 清除恢复时间 - - // 保存更改 - this._debouncedSave(type); - } - } - } - } - } - - /** - * Performs health checks on all providers in the pool. - * This method would typically be called periodically (e.g., via cron job). - */ - async performHealthChecks(isInit = false) { - this._log('info', 'Performing health checks on all providers...'); - const now = new Date(); - - // 首先检查并恢复已到恢复时间的提供商 - this._checkAndRecoverScheduledProviders(); - - for (const providerType in this.providerStatus) { - for (const providerStatus of this.providerStatus[providerType]) { - const providerConfig = providerStatus.config; - - // 如果提供商有 scheduledRecoveryTime 且未到恢复时间,跳过健康检查 - if (providerConfig.scheduledRecoveryTime && !providerConfig.isHealthy) { - const recoveryTime = new Date(providerConfig.scheduledRecoveryTime); - if (now < recoveryTime) { - this._log('debug', `Skipping health check for ${providerConfig.uuid} (${providerType}). Waiting for scheduled recovery at ${recoveryTime.toISOString()}`); - continue; - } - } - - // Only attempt to health check unhealthy providers after a certain interval - if (!providerStatus.config.isHealthy && providerStatus.config.lastErrorTime && - (now.getTime() - new Date(providerStatus.config.lastErrorTime).getTime() < this.healthCheckInterval)) { - this._log('debug', `Skipping health check for ${providerConfig.uuid} (${providerType}). Last error too recent.`); - continue; - } - - try { - // Perform actual health check based on provider type - const healthResult = await this._checkProviderHealth(providerType, providerConfig); - - if (healthResult === null) { - this._log('debug', `Health check for ${providerConfig.uuid} (${providerType}) skipped: Check not implemented.`); - this.resetProviderCounters(providerType, providerConfig); - continue; - } - - if (healthResult.success) { - if (!providerStatus.config.isHealthy) { - // Provider was unhealthy but is now healthy - // 恢复健康时不重置使用计数,保持原有值 - this.markProviderHealthy(providerType, providerConfig, true, healthResult.modelName); - this._log('info', `Health check for ${providerConfig.uuid} (${providerType}): Marked Healthy (actual check)`); - } else { - // Provider was already healthy and still is - // 只在初始化时重置使用计数 - this.markProviderHealthy(providerType, providerConfig, true, healthResult.modelName); - this._log('debug', `Health check for ${providerConfig.uuid} (${providerType}): Still Healthy`); - } - } else { - // Provider is not healthy - this._log('warn', `Health check for ${providerConfig.uuid} (${providerType}) failed: ${healthResult.errorMessage || 'Provider is not responding correctly.'}`); - this.markProviderUnhealthy(providerType, providerConfig, healthResult.errorMessage); - - // 更新健康检测时间和模型(即使失败也记录) - providerStatus.config.lastHealthCheckTime = new Date().toISOString(); - if (healthResult.modelName) { - providerStatus.config.lastHealthCheckModel = healthResult.modelName; - } - } - - } catch (error) { - this._log('error', `Health check for ${providerConfig.uuid} (${providerType}) failed: ${error.message}`); - // If a health check fails, mark it unhealthy, which will update error count and lastErrorTime - this.markProviderUnhealthy(providerType, providerConfig, error.message); - } - } - } - } - - /** - * 构建健康检查请求(返回多种格式用于重试) - * @private - * @returns {Array} 请求格式数组,按优先级排序 - */ - _buildHealthCheckRequests(providerType, modelName) { - const baseMessage = { role: 'user', content: 'Hi' }; - const requests = []; - - // Gemini 使用 contents 格式 - if (providerType.startsWith('gemini')) { - requests.push({ - contents: [{ - role: 'user', - parts: [{ text: baseMessage.content }] - }] - }); - return requests; - } - - // Kiro OAuth 只支持 messages 格式 - if (providerType.startsWith('claude-kiro')) { - requests.push({ - messages: [baseMessage], - model: modelName, - max_tokens: 1 - }); - return requests; - } - - // OpenAI Custom Responses 使用特殊格式 - if (providerType === MODEL_PROVIDER.OPENAI_CUSTOM_RESPONSES) { - requests.push({ - input: [baseMessage], - model: modelName - }); - return requests; - } - - // 其他提供商(OpenAI、Claude、Qwen)使用标准 messages 格式 - requests.push({ - messages: [baseMessage], - model: modelName - }); - - return requests; - } - - /** - * Performs an actual health check for a specific provider. - * @param {string} providerType - The type of the provider. - * @param {object} providerConfig - The configuration of the provider to check. - * @param {boolean} forceCheck - If true, ignore checkHealth config and force the check. - * @returns {Promise<{success: boolean, modelName: string, errorMessage: string}|null>} - Health check result object or null if check not implemented. - */ - async _checkProviderHealth(providerType, providerConfig, forceCheck = false) { - // 如果未启用健康检查且不是强制检查,返回 null(提前返回,避免不必要的计算) - if (!providerConfig.checkHealth && !forceCheck) { - return null; - } - - // 确定健康检查使用的模型名称 - const modelName = providerConfig.checkModelName || - ProviderPoolManager.DEFAULT_HEALTH_CHECK_MODELS[providerType]; - - if (!modelName) { - this._log('warn', `Unknown provider type for health check: ${providerType}. Please check DEFAULT_HEALTH_CHECK_MODELS.`); - return { - success: false, - modelName: null, - errorMessage: `Unknown provider type '${providerType}'. No default health check model configured.` - }; - } - - // ========== 实际 API 健康检查(带超时保护)========== - const tempConfig = { - ...providerConfig, - MODEL_PROVIDER: providerType - }; - const serviceAdapter = getServiceAdapter(tempConfig); - - // 获取所有可能的请求格式 - const healthCheckRequests = this._buildHealthCheckRequests(providerType, modelName); - - // 健康检查超时时间(15秒,避免长时间阻塞) - const healthCheckTimeout = 15000; - let lastError = null; - - // 重试机制:尝试不同的请求格式 - for (let i = 0; i < healthCheckRequests.length; i++) { - const healthCheckRequest = healthCheckRequests[i]; - const abortController = new AbortController(); - const timeoutId = setTimeout(() => abortController.abort(), healthCheckTimeout); - - try { - this._log('debug', `Health check attempt ${i + 1}/${healthCheckRequests.length} for ${modelName}: ${JSON.stringify(healthCheckRequest)}`); - - // 尝试将 signal 注入请求体,供支持的适配器使用 - const requestWithSignal = { - ...healthCheckRequest, - // signal: abortController.signal - }; - - await serviceAdapter.generateContent(modelName, requestWithSignal); - - clearTimeout(timeoutId); - return { success: true, modelName, errorMessage: null }; - } catch (error) { - clearTimeout(timeoutId); - lastError = error; - this._log('debug', `Health check attempt ${i + 1} failed for ${providerType}: ${error.message}`); - } - } - - // 所有尝试都失败 - this._log('error', `Health check failed for ${providerType} after ${healthCheckRequests.length} attempts: ${lastError?.message}`); - return { success: false, modelName, errorMessage: lastError?.message || 'All health check attempts failed' }; - } - - /** - * 优化1: 添加防抖保存方法 - * 延迟保存操作,避免频繁的文件 I/O - * @private - */ - _debouncedSave(providerType) { - // 将待保存的 providerType 添加到集合中 - this.pendingSaves.add(providerType); - - // 清除之前的定时器 - if (this.saveTimer) { - clearTimeout(this.saveTimer); - } - - // 设置新的定时器 - this.saveTimer = setTimeout(() => { - this._flushPendingSaves(); - }, this.saveDebounceTime); - } - - /** - * 批量保存所有待保存的 providerType(优化为单次文件写入) - * @private - */ - async _flushPendingSaves() { - const typesToSave = Array.from(this.pendingSaves); - if (typesToSave.length === 0) return; - - this.pendingSaves.clear(); - this.saveTimer = null; - - try { - const filePath = this.globalConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; - let currentPools = {}; - - // 一次性读取文件 - try { - const fileContent = await fs.promises.readFile(filePath, 'utf8'); - currentPools = JSON.parse(fileContent); - } catch (readError) { - if (readError.code === 'ENOENT') { - this._log('info', 'configs/provider_pools.json does not exist, creating new file.'); - } else { - throw readError; - } - } - - // 更新所有待保存的 providerType - for (const providerType of typesToSave) { - if (this.providerStatus[providerType]) { - currentPools[providerType] = this.providerStatus[providerType].map(p => { - // Convert Date objects to ISOString if they exist - const config = { ...p.config }; - if (config.lastUsed instanceof Date) { - config.lastUsed = config.lastUsed.toISOString(); - } - if (config.lastErrorTime instanceof Date) { - config.lastErrorTime = config.lastErrorTime.toISOString(); - } - if (config.lastHealthCheckTime instanceof Date) { - config.lastHealthCheckTime = config.lastHealthCheckTime.toISOString(); - } - return config; - }); - } else { - this._log('warn', `Attempted to save unknown providerType: ${providerType}`); - } - } - - // 一次性写入文件 - await fs.promises.writeFile(filePath, JSON.stringify(currentPools, null, 2), 'utf8'); - this._log('info', `configs/provider_pools.json updated successfully for types: ${typesToSave.join(', ')}`); - } catch (error) { - this._log('error', `Failed to write provider_pools.json: ${error.message}`); - } - } - -} - diff --git a/src/scripts/kiro-idc-token-refresh.js b/src/scripts/kiro-idc-token-refresh.js deleted file mode 100644 index 66c0be7f57c0747dc5a06b4a38589fe822196d6d..0000000000000000000000000000000000000000 --- a/src/scripts/kiro-idc-token-refresh.js +++ /dev/null @@ -1,281 +0,0 @@ -/** - * Kiro IDC Token Refresh Tool - * 通过 refreshToken + clientId + clientSecret 获取 accessToken (基于 AWS OIDC/IDC) - * - * 使用方法: - * 1. 位置参数模式: - * node src/kiro-idc-token-refresh.js [authMethod] [provider] - * 2. JSON 文件模式: - * node src/kiro-idc-token-refresh.js ./config.json - * 3. JSON 字符串模式: - * node src/kiro-idc-token-refresh.js '{"refreshToken": "...", "clientId": "...", "clientSecret": "..."}' - * - * 参数: - * refreshToken - Kiro 的 refresh token - * clientId - AWS OIDC client ID - * clientSecret - AWS OIDC client secret - * authMethod - 认证方法 (可选,默认: IdC) - * provider - 提供商 (可选,默认: BuilderId) - * - * 输出格式: - * { - * "accessToken": "aoaAAAA", - * "refreshToken": "aorAAAAAGnTpTMP_mR", - * "expiresAt": "2026-01-06T14:22:16.130Z", - * "authMethod": "IdC", - * "provider": "BuilderId", - * "clientId": "e8pqSrALVjvbqaW", - * "clientSecret": "eyJraWQiOiJrZXktMTU2NDAy", - * "region": "us-east-1" - * } - */ - -import axios from 'axios'; -import fs from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; - -// 获取当前脚本所在目录 -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -const KIRO_IDC_CONSTANTS = { - REFRESH_IDC_URL: 'https://oidc.{{region}}.amazonaws.com/token', - CONTENT_TYPE_JSON: 'application/json', - DEFAULT_AUTH_METHOD: 'IdC', - DEFAULT_PROVIDER: 'BuilderId', - DEFAULT_REGION: 'us-east-1', - AXIOS_TIMEOUT: 30000, // 30 seconds timeout -}; - -/** - * 通过 IDC (AWS OIDC) 刷新 token - * @param {string} refreshToken - Kiro 的 refresh token - * @param {string} clientId - AWS OIDC client ID - * @param {string} clientSecret - AWS OIDC client secret - * @param {Object} options - 可选参数 - * @param {string} options.authMethod - 认证方法 (默认: IdC) - * @param {string} options.provider - 提供商 (默认: BuilderId) - * @param {string} options.region - AWS 区域 (默认: us-east-1) - * @returns {Promise} 包含 accessToken 等信息的对象 - */ -async function refreshKiroIdcToken(refreshToken, clientId, clientSecret, options = {}) { - const authMethod = options.authMethod || KIRO_IDC_CONSTANTS.DEFAULT_AUTH_METHOD; - const provider = options.provider || KIRO_IDC_CONSTANTS.DEFAULT_PROVIDER; - const region = options.region || KIRO_IDC_CONSTANTS.DEFAULT_REGION; - - const refreshUrl = KIRO_IDC_CONSTANTS.REFRESH_IDC_URL.replace('{{region}}', region); - - // IDC/OIDC 使用 form-urlencoded 格式 - const requestBody = { - grantType: 'refresh_token', - refreshToken: refreshToken, - clientId: clientId, - clientSecret: clientSecret, - }; - - const axiosConfig = { - timeout: KIRO_IDC_CONSTANTS.AXIOS_TIMEOUT, - headers: { - 'Content-Type': KIRO_IDC_CONSTANTS.CONTENT_TYPE_JSON, - 'User-Agent': 'KiroIDE' - }, - }; - - try { - console.log(`[Kiro IDC Token Refresh] 正在请求: ${refreshUrl}`); - const response = await axios.post(refreshUrl, requestBody, axiosConfig); - - // AWS OIDC 返回格式: { access_token, refresh_token, expires_in, token_type } - if (response.data && response.data.accessToken) { - const expiresIn = response.data.expiresIn; - const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString(); - - const result = { - accessToken: response.data.accessToken, - refreshToken: response.data.refreshToken || refreshToken, - expiresAt: expiresAt, - authMethod: authMethod, - provider: provider, - clientId: clientId, - clientSecret: clientSecret, - region: region, - }; - - return result; - } else { - throw new Error('Invalid refresh response: Missing access_token'); - } - } catch (error) { - if (error.response) { - console.error(`[Kiro IDC Token Refresh] 请求失败: HTTP ${error.response.status}`); - console.error(`[Kiro IDC Token Refresh] 响应内容:`, error.response.data); - } else if (error.request) { - console.error(`[Kiro IDC Token Refresh] 请求失败: 无响应`); - } else { - console.error(`[Kiro IDC Token Refresh] 请求失败:`, error.message); - } - throw error; - } -} - -/** - * 主函数 - 命令行入口 - */ -async function main() { - const args = process.argv.slice(2); - - let refreshToken, clientId, clientSecret, authMethod, provider; - - // 1. 尝试解析第一个参数为 JSON 文件路径 - if (args.length === 1 && args[0].toLowerCase().endsWith('.json')) { - try { - const jsonPath = path.isAbsolute(args[0]) ? args[0] : path.resolve(process.cwd(), args[0]); - if (fs.existsSync(jsonPath)) { - console.log(`[Kiro IDC Token Refresh] 正在从文件读取配置: ${jsonPath}`); - const fileContent = fs.readFileSync(jsonPath, 'utf-8'); - const parsed = JSON.parse(fileContent); - refreshToken = parsed.refreshToken; - clientId = parsed.clientId; - clientSecret = parsed.clientSecret; - authMethod = parsed.authMethod; - provider = parsed.provider; - } else { - console.error(`错误: 找不到文件 ${jsonPath}`); - process.exit(1); - } - } catch (e) { - console.error(`错误: 读取或解析 JSON 文件失败: ${e.message}`); - process.exit(1); - } - } - // 2. 尝试解析第一个参数为 JSON 字符串 - else if (args.length === 1 && args[0].trim().startsWith('{')) { - try { - const parsed = JSON.parse(args[0]); - refreshToken = parsed.refreshToken; - clientId = parsed.clientId; - clientSecret = parsed.clientSecret; - authMethod = parsed.authMethod; - provider = parsed.provider; - } catch (e) { - // JSON 解析失败,将回退到位置参数处理 - } - } - - // 如果没有通过 JSON 成功获取参数,则尝试位置参数 - if (!refreshToken) { - if (args.length === 0 || args.length < 3) { - console.log('Kiro IDC Token Refresh Tool'); - console.log('============================'); - console.log(''); - console.log('使用方法:'); - console.log(' 1. 位置参数模式:'); - console.log(' node src/kiro-idc-token-refresh.js [authMethod] [provider]'); - console.log(' 2. JSON 文件模式:'); - console.log(' node src/kiro-idc-token-refresh.js ./config.json'); - console.log(' 3. JSON 字符串模式:'); - console.log(' node src/kiro-idc-token-refresh.js \'{"refreshToken": "...", "clientId": "...", "clientSecret": "...", "authMethod": "...", "provider": "..."}\''); - console.log(''); - console.log('参数:'); - console.log(' refreshToken - Kiro 的 refresh token (必需)'); - console.log(' clientId - AWS OIDC client ID (必需)'); - console.log(' clientSecret - AWS OIDC client secret (必需)'); - console.log(' authMethod - 认证方法 (可选,默认: IdC)'); - console.log(' provider - 提供商 (可选,默认: BuilderId)'); - console.log(''); - console.log('示例:'); - console.log(' node src/kiro-idc-token-refresh.js aorAxxxxxxxx e8pqSrALVjvbqaW eyJraWQiOiJrZXktMTU2NDAy'); - console.log(' node src/kiro-idc-token-refresh.js aorAxxxxxxxx e8pqSrALVjvbqaW eyJraWQiOiJrZXktMTU2NDAy IdC Enterprise'); - console.log(''); - console.log('输出格式:'); - console.log(JSON.stringify({ - accessToken: "aoaAAAA...", - refreshToken: "aorAAAAAGnTpTMP_mR...", - expiresAt: "2026-01-06T14:22:16.130Z", - authMethod: "IdC", - provider: "BuilderId", - clientId: "e8pqSrALVjvbqaW", - clientSecret: "eyJraWQiOiJrZXktMTU2NDAy", - region: "us-east-1" - }, null, 2)); - process.exit(0); - } - - refreshToken = args[0]; - clientId = args[1]; - clientSecret = args[2]; - authMethod = args[3]; - provider = args[4]; - } - - // 设置默认值 - authMethod = authMethod || KIRO_IDC_CONSTANTS.DEFAULT_AUTH_METHOD; - provider = provider || KIRO_IDC_CONSTANTS.DEFAULT_PROVIDER; - - if (!refreshToken) { - console.error('错误: 请提供 refreshToken'); - process.exit(1); - } - - if (!clientId) { - console.error('错误: 请提供 clientId'); - process.exit(1); - } - - if (!clientSecret) { - console.error('错误: 请提供 clientSecret'); - process.exit(1); - } - - try { - console.log(`[Kiro IDC Token Refresh] 开始刷新 token...`); - console.log(`[Kiro IDC Token Refresh] 认证方法: ${authMethod}`); - console.log(`[Kiro IDC Token Refresh] 提供商: ${provider}`); - console.log(`[Kiro IDC Token Refresh] Client ID: ${clientId.substring(0, 8)}...`); - - const result = await refreshKiroIdcToken(refreshToken, clientId, clientSecret, { - authMethod, - provider, - region: KIRO_IDC_CONSTANTS.DEFAULT_REGION - }); - - console.log(''); - console.log('=== Token 刷新成功 ==='); - console.log(''); - console.log(JSON.stringify(result, null, 2)); - - // 输出过期时间信息 - const expiresDate = new Date(result.expiresAt); - const now = new Date(); - const diffMs = expiresDate - now; - const diffMins = Math.floor(diffMs / 60000); - const diffHours = Math.floor(diffMins / 60); - - console.log(''); - console.log(`[Kiro IDC Token Refresh] Token 将在 ${diffHours} 小时 ${diffMins % 60} 分钟后过期`); - console.log(`[Kiro IDC Token Refresh] 过期时间: ${result.expiresAt}`); - - // 写入 JSON 文件到脚本执行目录 - const timestamp = Date.now(); - const outputFileName = `kiro-idc-${timestamp}-auth-token.json`; - const outputFilePath = path.join(__dirname, outputFileName); - - fs.writeFileSync(outputFilePath, JSON.stringify(result, null, 2), 'utf-8'); - - console.log(''); - console.log(`[Kiro IDC Token Refresh] Token 已保存到文件: ${outputFilePath}`); - - } catch (error) { - console.error(''); - console.error('=== Token 刷新失败 ==='); - console.error(`错误: ${error.message}`); - process.exit(1); - } -} - -// 导出函数供其他模块使用 -export { refreshKiroIdcToken }; - -// 如果直接运行此脚本,执行主函数 -main(); \ No newline at end of file diff --git a/src/scripts/kiro-token-refresh.js b/src/scripts/kiro-token-refresh.js deleted file mode 100644 index 88c735ed1d59701e28d29d1b26baebe04d73854a..0000000000000000000000000000000000000000 --- a/src/scripts/kiro-token-refresh.js +++ /dev/null @@ -1,184 +0,0 @@ -/** - * Kiro Token Refresh Tool - * 通过 refreshToken 获取 accessToken 并转换为指定格式 - * - * 使用方法: - * node src/kiro-token-refresh.js [region] - * - * 参数: - * refreshToken - Kiro 的 refresh token - * region - AWS 区域 (可选,默认: us-east-1) - * - * 输出格式: - * { - * "accessToken": "aoaAAAAAGlfTyA8C4c", - * "refreshToken": "aorA", - * "profileArn": "arn:aws:codewhisperer:us-east-1:699475941385:profile/EHGA3GRVQMUK", - * "expiresAt": "2026-01-08T06:30:59.065Z", - * "authMethod": "social", - * "provider": "Google" - * } - */ - -import axios from 'axios'; -import fs from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; - -// 获取当前脚本所在目录 -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -const KIRO_CONSTANTS = { - REFRESH_URL: 'https://prod.{{region}}.auth.desktop.kiro.dev/refreshToken', - CONTENT_TYPE_JSON: 'application/json', - AUTH_METHOD_SOCIAL: 'social', - DEFAULT_PROVIDER: 'Google', - AXIOS_TIMEOUT: 30000, // 30 seconds timeout -}; - -/** - * 通过 refreshToken 获取 accessToken - * @param {string} refreshToken - Kiro 的 refresh token - * @param {string} region - AWS 区域 (默认: us-east-1) - * @returns {Promise} 包含 accessToken 等信息的对象 - */ -async function refreshKiroToken(refreshToken, region = 'us-east-1') { - const refreshUrl = KIRO_CONSTANTS.REFRESH_URL.replace('{{region}}', region); - - const requestBody = { - refreshToken: refreshToken, - }; - - const axiosConfig = { - timeout: KIRO_CONSTANTS.AXIOS_TIMEOUT, - headers: { - 'Content-Type': KIRO_CONSTANTS.CONTENT_TYPE_JSON, - }, - }; - - try { - console.log(`[Kiro Token Refresh] 正在请求: ${refreshUrl}`); - const response = await axios.post(refreshUrl, requestBody, axiosConfig); - - if (response.data && response.data.accessToken) { - const expiresIn = response.data.expiresIn; - const expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString(); - - const result = { - accessToken: response.data.accessToken, - refreshToken: response.data.refreshToken || refreshToken, - profileArn: response.data.profileArn || '', - expiresAt: expiresAt, - authMethod: KIRO_CONSTANTS.AUTH_METHOD_SOCIAL, - provider: KIRO_CONSTANTS.DEFAULT_PROVIDER, - }; - - // 如果响应中包含 region 信息,添加到结果中 - if (region) { - result.region = region; - } - - return result; - } else { - throw new Error('Invalid refresh response: Missing accessToken'); - } - } catch (error) { - if (error.response) { - console.error(`[Kiro Token Refresh] 请求失败: HTTP ${error.response.status}`); - console.error(`[Kiro Token Refresh] 响应内容:`, error.response.data); - } else if (error.request) { - console.error(`[Kiro Token Refresh] 请求失败: 无响应`); - } else { - console.error(`[Kiro Token Refresh] 请求失败:`, error.message); - } - throw error; - } -} - -/** - * 主函数 - 命令行入口 - */ -async function main() { - const args = process.argv.slice(2); - - if (args.length === 0) { - console.log('Kiro Token Refresh Tool'); - console.log('========================'); - console.log(''); - console.log('使用方法:'); - console.log(' node src/kiro-token-refresh.js [region]'); - console.log(''); - console.log('参数:'); - console.log(' refreshToken - Kiro 的 refresh token (必需)'); - console.log(' region - AWS 区域 (可选,默认: us-east-1)'); - console.log(''); - console.log('示例:'); - console.log(' node src/kiro-token-refresh.js aorAxxxxxxxx'); - console.log(' node src/kiro-token-refresh.js aorAxxxxxxxx us-west-2'); - console.log(''); - console.log('输出格式:'); - console.log(JSON.stringify({ - accessToken: "aoaAAAAAGlfTyA8C4c...", - refreshToken: "aorA...", - profileArn: "arn:aws:codewhisperer:us-east-1:699475941385:profile/EHGA3GRVQMUK", - expiresAt: "2026-01-08T06:30:59.065Z", - authMethod: "social", - provider: "Google" - }, null, 2)); - process.exit(0); - } - - const refreshToken = args[0]; - const region = args[1] || 'us-east-1'; - - if (!refreshToken) { - console.error('错误: 请提供 refreshToken'); - process.exit(1); - } - - try { - console.log(`[Kiro Token Refresh] 开始刷新 token...`); - console.log(`[Kiro Token Refresh] 区域: ${region}`); - - const result = await refreshKiroToken(refreshToken, region); - - console.log(''); - console.log('=== Token 刷新成功 ==='); - console.log(''); - console.log(JSON.stringify(result, null, 2)); - - // 输出过期时间信息 - const expiresDate = new Date(result.expiresAt); - const now = new Date(); - const diffMs = expiresDate - now; - const diffMins = Math.floor(diffMs / 60000); - const diffHours = Math.floor(diffMins / 60); - - console.log(''); - console.log(`[Kiro Token Refresh] Token 将在 ${diffHours} 小时 ${diffMins % 60} 分钟后过期`); - console.log(`[Kiro Token Refresh] 过期时间: ${result.expiresAt}`); - - // 写入 JSON 文件到脚本执行目录 - const timestamp = Date.now(); - const outputFileName = `kiro-${timestamp}-auth-token.json`; - const outputFilePath = path.join(__dirname, outputFileName); - - fs.writeFileSync(outputFilePath, JSON.stringify(result, null, 2), 'utf-8'); - - console.log(''); - console.log(`[Kiro Token Refresh] Token 已保存到文件: ${outputFilePath}`); - - } catch (error) { - console.error(''); - console.error('=== Token 刷新失败 ==='); - console.error(`错误: ${error.message}`); - process.exit(1); - } -} - -// 导出函数供其他模块使用 -export { refreshKiroToken }; - -// 如果直接运行此脚本,执行主函数 -main(); \ No newline at end of file diff --git a/src/scripts/merge-json-files.js b/src/scripts/merge-json-files.js deleted file mode 100644 index b0f8416c2531b76e624e1b41fbc9313b93cbb551..0000000000000000000000000000000000000000 --- a/src/scripts/merge-json-files.js +++ /dev/null @@ -1,125 +0,0 @@ -/** - * JSON File Merger Tool - * 解析当前或指定目录的 .json 文件,合并为一个 JSON 对象,并保存到执行脚本的目录下。 - * - * 功能: - * 1. 扫描目录下的所有 .json 文件。 - * 2. 读取并解析每个文件。 - * 3. 过滤掉非对象 JSON 内容。 - * 4. 将所有对象属性合并到一个大对象中。 - * 5. 特殊处理: 如果合并后的对象中包含 clientSecret 字段,则移除 expiresAt 字段。 - * 6. 输出文件名为: merge-kiro-<时间戳>-auth-token.json - * - * 使用方法: - * node src/merge-json-files.js [directory] - * - * 参数: - * directory - 要扫描的目录路径 (可选,默认: 当前脚本执行目录) - */ - -import fs from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; - -// 获取当前脚本所在目录 -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -/** - * 主函数 - */ -async function main() { - // 获取命令行参数中的目录,如果未提供则使用当前工作目录 (process.cwd()) - // 注意:用户需求是"解析当前或指定目录",这里的"当前"通常指用户运行命令时的目录 - const args = process.argv.slice(2); - const targetDir = args[0] ? path.resolve(process.cwd(), args[0]) : process.cwd(); - - console.log(`[JSON Merger] 扫描目录: ${targetDir}`); - - if (!fs.existsSync(targetDir)) { - console.error(`错误: 目录不存在 ${targetDir}`); - process.exit(1); - } - - try { - const files = fs.readdirSync(targetDir); - const jsonFiles = files.filter(file => file.toLowerCase().endsWith('.json')); - - if (jsonFiles.length === 0) { - console.log('[JSON Merger] 未找到 JSON 文件。'); - process.exit(0); - } - - console.log(`[JSON Merger] 找到 ${jsonFiles.length} 个 JSON 文件`); - - let mergedData = {}; - let successCount = 0; - let skipCount = 0; - - for (const file of jsonFiles) { - const filePath = path.join(targetDir, file); - - // 跳过自身生成的合并文件,防止递归合并垃圾数据 (简单的名字检查) - if (file.startsWith('merge-kiro-') && file.endsWith('-auth-token.json')) { - console.log(`[JSON Merger] 跳过之前的合并文件: ${file}`); - skipCount++; - continue; - } - - try { - const content = fs.readFileSync(filePath, 'utf-8'); - const jsonData = JSON.parse(content); - - // 处理逻辑: - // 仅处理对象类型,如果是数组则跳过或尝试合并数组中的对象(通常合并对象意味着所有字段平铺到一个对象中) - // 鉴于用户要求合并为一个 JSON 对象,假设所有文件内容都是部分配置,需要合并到一起。 - - if (typeof jsonData === 'object' && jsonData !== null && !Array.isArray(jsonData)) { - Object.assign(mergedData, jsonData); - successCount++; - } else { - console.log(`[JSON Merger] 文件 ${file} 内容格式不符合要求 (非纯对象),跳过`); - skipCount++; - continue; - } - - } catch (error) { - console.warn(`[JSON Merger] 解析文件 ${file} 失败: ${error.message}`); - skipCount++; - } - } - - // 特殊处理: 如果包含 clientSecret,移除 expiresAt - // 注意:这是在合并后的对象上进行处理,因为 clientSecret 和 expiresAt 可能来自不同文件,或者合并后才决定 - if (mergedData.clientSecret && mergedData.expiresAt) { - delete mergedData.expiresAt; - } - - if (Object.keys(mergedData).length === 0) { - console.log('[JSON Merger] 没有有效的数据需要合并。'); - process.exit(0); - } - - // 生成输出文件名 - const timestamp = Date.now(); - const outputFileName = `merge-kiro-${timestamp}-auth-token.json`; - // 用户需求: "保存到执行脚本的目录下" -> 即 __dirname - const outputFilePath = path.join(__dirname, outputFileName); - - fs.writeFileSync(outputFilePath, JSON.stringify(mergedData, null, 2), 'utf-8'); - - console.log(''); - console.log('=== 合并完成 ==='); - console.log(`扫描文件数: ${jsonFiles.length}`); - console.log(`成功处理: ${successCount}`); - console.log(`跳过/失败: ${skipCount}`); - console.log(`合并字段数: ${Object.keys(mergedData).length}`); - console.log(`输出文件: ${outputFilePath}`); - - } catch (error) { - console.error(`[JSON Merger] 处理过程中发生错误: ${error.message}`); - process.exit(1); - } -} - -main(); \ No newline at end of file diff --git a/src/services/api-manager.js b/src/services/api-manager.js deleted file mode 100644 index d9cb526a556047c0512760a84f5aa21c84383c11..0000000000000000000000000000000000000000 --- a/src/services/api-manager.js +++ /dev/null @@ -1,116 +0,0 @@ -import { - handleModelListRequest, - handleContentGenerationRequest, - API_ACTIONS, - ENDPOINT_TYPE -} from '../utils/common.js'; -import { getProviderPoolManager } from './service-manager.js'; -import logger from '../utils/logger.js'; -/** - * Handle API authentication and routing - * @param {string} method - The HTTP method - * @param {string} path - The request path - * @param {http.IncomingMessage} req - The HTTP request object - * @param {http.ServerResponse} res - The HTTP response object - * @param {Object} currentConfig - The current configuration object - * @param {Object} apiService - The API service instance - * @param {Object} providerPoolManager - The provider pool manager instance - * @param {string} promptLogFilename - The prompt log filename - * @returns {Promise} - True if the request was handled by API - */ -export async function handleAPIRequests(method, path, req, res, currentConfig, apiService, providerPoolManager, promptLogFilename) { - - - // Route model list requests - if (method === 'GET') { - if (path === '/v1/models') { - await handleModelListRequest(req, res, apiService, ENDPOINT_TYPE.OPENAI_MODEL_LIST, currentConfig, providerPoolManager, currentConfig.uuid); - return true; - } - if (path === '/v1beta/models') { - await handleModelListRequest(req, res, apiService, ENDPOINT_TYPE.GEMINI_MODEL_LIST, currentConfig, providerPoolManager, currentConfig.uuid); - return true; - } - } - - // Route content generation requests - if (method === 'POST') { - if (path === '/v1/chat/completions') { - await handleContentGenerationRequest(req, res, apiService, ENDPOINT_TYPE.OPENAI_CHAT, currentConfig, promptLogFilename, providerPoolManager, currentConfig.uuid, path); - return true; - } - if (path === '/v1/responses') { - await handleContentGenerationRequest(req, res, apiService, ENDPOINT_TYPE.OPENAI_RESPONSES, currentConfig, promptLogFilename, providerPoolManager, currentConfig.uuid, path); - return true; - } - const geminiUrlPattern = new RegExp(`/v1beta/models/(.+?):(${API_ACTIONS.GENERATE_CONTENT}|${API_ACTIONS.STREAM_GENERATE_CONTENT})`); - if (geminiUrlPattern.test(path)) { - await handleContentGenerationRequest(req, res, apiService, ENDPOINT_TYPE.GEMINI_CONTENT, currentConfig, promptLogFilename, providerPoolManager, currentConfig.uuid, path); - return true; - } - if (path === '/v1/messages') { - await handleContentGenerationRequest(req, res, apiService, ENDPOINT_TYPE.CLAUDE_MESSAGE, currentConfig, promptLogFilename, providerPoolManager, currentConfig.uuid, path); - return true; - } - } - - return false; -} - -/** - * Initialize API management features - * @param {Object} services - The initialized services - * @returns {Function} - The heartbeat and token refresh function - */ -export function initializeAPIManagement(services) { - const providerPoolManager = getProviderPoolManager(); - return async function heartbeatAndRefreshToken() { - logger.info(`[Heartbeat] Server is running. Current time: ${new Date().toLocaleString()}`, Object.keys(services)); - // 循环遍历所有已初始化的服务适配器,并尝试刷新令牌 - // if (getProviderPoolManager()) { - // await getProviderPoolManager().performHealthChecks(); // 定期执行健康检查 - // } - for (const providerKey in services) { - const serviceAdapter = services[providerKey]; - try { - // For pooled providers, refreshToken should be handled by individual instances - // For single instances, this remains relevant - if (serviceAdapter.config?.uuid && providerPoolManager) { - providerPoolManager._enqueueRefresh(serviceAdapter.config.MODEL_PROVIDER, { - config: serviceAdapter.config, - uuid: serviceAdapter.config.uuid - }); - } else { - await serviceAdapter.refreshToken(); - } - // logger.info(`[Token Refresh] Refreshed token for ${providerKey}`); - } catch (error) { - logger.error(`[Token Refresh Error] Failed to refresh token for ${providerKey}: ${error.message}`); - // 如果是号池中的某个实例刷新失败,这里需要捕获并更新其状态 - // 现有的 serviceInstances 存储的是每个配置对应的单例,而非池中的成员 - // 这意味着如果一个池成员的 token 刷新失败,需要找到它并更新其在 poolManager 中的状态 - // 暂时通过捕获错误日志来发现问题,更精细的控制需要在 refreshToken 中抛出更多信息 - } - } - }; -} - -/** - * Helper function to read request body - * @param {http.IncomingMessage} req The HTTP request object. - * @returns {Promise} The request body as string. - */ -export function readRequestBody(req) { - return new Promise((resolve, reject) => { - let body = ''; - req.on('data', chunk => { - body += chunk.toString(); - }); - req.on('end', () => { - resolve(body); - }); - req.on('error', err => { - reject(err); - }); - }); -} \ No newline at end of file diff --git a/src/services/api-server.js b/src/services/api-server.js deleted file mode 100644 index f3b6360fe4ab2e8abd158162bc3c8fdca1068fa8..0000000000000000000000000000000000000000 --- a/src/services/api-server.js +++ /dev/null @@ -1,384 +0,0 @@ -import logger from '../utils/logger.js'; -import * as http from 'http'; -import { initializeConfig, CONFIG } from '../core/config-manager.js'; -import { initApiService, autoLinkProviderConfigs } from './service-manager.js'; -import { initializeUIManagement } from './ui-manager.js'; -import { initializeAPIManagement } from './api-manager.js'; -import { createRequestHandler } from '../handlers/request-handler.js'; -import { discoverPlugins, getPluginManager } from '../core/plugin-manager.js'; -import { getTLSSidecar } from '../utils/tls-sidecar.js'; - -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - * - * 描述 / Description: - * (最终生产就绪版本 / Final Production Ready Version) - * 此脚本创建一个独立的 Node.js HTTP 服务器,作为 Google Cloud Code Assist API 的本地代理。 - * 此版本包含所有功能和错误修复,设计为健壮、灵活且易于通过全面可控的日志系统进行监控。 - * - * This script creates a standalone Node.js HTTP server that acts as a local proxy for the Google Cloud Code Assist API. - * This version includes all features and bug fixes, designed to be robust, flexible, and easy to monitor through a comprehensive and controllable logging system. - * - * 主要功能 / Key Features: - * - OpenAI & Gemini & Claude 多重兼容性:无缝桥接使用 OpenAI API 格式的客户端与 Google Gemini API。支持原生 Gemini API (`/v1beta`) 和 OpenAI 兼容 (`/v1`) 端点。 - * OpenAI & Gemini & Claude Dual Compatibility: Seamlessly bridges clients using the OpenAI API format with the Google Gemini API. Supports both native Gemini API (`/v1beta`) and OpenAI-compatible (`/v1`) endpoints. - * - * - 强大的身份验证管理:支持多种身份验证方法,包括通过 Base64 字符串、文件路径或自动发现本地凭据的 OAuth 2.0 配置。能够自动刷新过期令牌以确保服务持续运行。 - * Robust Authentication Management: Supports multiple authentication methods, including OAuth 2.0 configuration via Base64 strings, file paths, or automatic discovery of local credentials. Capable of automatically refreshing expired tokens to ensure continuous service operation. - * - * - 灵活的 API 密钥验证:支持三种 API 密钥验证方法:`Authorization: Bearer ` 请求头、`x-goog-api-key` 请求头和 `?key=` URL 查询参数,可通过 `--api-key` 启动参数配置。 - * Flexible API Key Validation: Supports three API key validation methods: `Authorization: Bearer ` request header, `x-goog-api-key` request header, and `?key=` URL query parameter, configurable via the `--api-key` startup parameter. - * - * - 动态系统提示管理 / Dynamic System Prompt Management: - * - 文件注入:通过 `--system-prompt-file` 从外部文件加载系统提示,并通过 `--system-prompt-mode` 控制其行为(覆盖或追加)。 - * File Injection: Loads system prompts from external files via `--system-prompt-file` and controls their behavior (overwrite or append) with `--system-prompt-mode`. - * - 实时同步:能够将请求中包含的系统提示实时写入 `configs/fetch_system_prompt.txt` 文件,便于开发者观察和调试。 - * Real-time Synchronization: Capable of writing system prompts included in requests to the `fetch_system_prompt.txt` file in real-time, facilitating developer observation and debugging. - * - * - 智能请求转换和修复:自动将 OpenAI 格式的请求转换为 Gemini 格式,包括角色映射(`assistant` -> `model`)、合并来自同一角色的连续消息以及修复缺失的 `role` 字段。 - * Intelligent Request Conversion and Repair: Automatically converts OpenAI-formatted requests to Gemini format, including role mapping (`assistant` -> `model`), merging consecutive messages from the same role, and fixing missing `role` fields. - * - * - 全面可控的日志系统:提供两种日志模式(控制台或文件),详细记录每个请求的输入和输出、剩余令牌有效性等信息,用于监控和调试。 - * Comprehensive and Controllable Logging System: Provides two logging modes (console or file), detailing input and output of each request, remaining token validity, and other information for monitoring and debugging. - * - * - 高度可配置的启动:支持通过命令行参数配置服务监听地址、端口、项目 ID、API 密钥和日志模式。 - * Highly Configurable Startup: Supports configuring service listening address, port, project ID, API key, and logging mode via command-line parameters. - * - * 使用示例 / Usage Examples: - * - * 基本用法 / Basic Usage: - * node src/api-server.js - * - * 服务器配置 / Server Configuration: - * node src/api-server.js --host 0.0.0.0 --port 8080 --api-key your-secret-key - * - * OpenAI 提供商 / OpenAI Provider: - * node src/api-server.js --model-provider openai-custom --openai-api-key sk-xxx --openai-base-url https://api.openai.com/v1 - * - * Claude 提供商 / Claude Provider: - * node src/api-server.js --model-provider claude-custom --claude-api-key sk-ant-xxx --claude-base-url https://api.anthropic.com - * - * Gemini 提供商(使用 Base64 凭据的 OAuth)/ Gemini Provider (OAuth with Base64 credentials): - * node src/api-server.js --model-provider gemini-cli --gemini-oauth-creds-base64 eyJ0eXBlIjoi... --project-id your-project-id - * - * Gemini 提供商(使用凭据文件的 OAuth)/ Gemini Provider (OAuth with credentials file): - * node src/api-server.js --model-provider gemini-cli --gemini-oauth-creds-file /path/to/credentials.json --project-id your-project-id - * - * 系统提示管理 / System Prompt Management: - * node src/api-server.js --system-prompt-file custom-prompt.txt --system-prompt-mode append - * - * 日志配置 / Logging Configuration: - * node src/api-server.js --log-prompts console - * node src/api-server.js --log-prompts file --prompt-log-base-name my-logs - * - * 完整示例 / Complete Example: - * node src/api-server.js \ - * --host 0.0.0.0 \ - * --port 3000 \ - * --api-key my-secret-key \ - * --model-provider gemini-cli-oauth \ - * --project-id my-gcp-project \ - * --gemini-oauth-creds-file ./credentials.json \ - * --system-prompt-file ./custom-system-prompt.txt \ - * --system-prompt-mode overwrite \ - * --log-prompts file \ - * --prompt-log-base-name api-logs - * - * 命令行参数 / Command Line Parameters: - * --host
服务器监听地址 / Server listening address (default: 0.0.0.0) - * --port 服务器监听端口 / Server listening port (default: 3000) - * --api-key 身份验证所需的 API 密钥 / Required API key for authentication (default: 123456) - * --model-provider AI 模型提供商 / AI model provider: openai-custom, claude-custom, gemini-cli-oauth, claude-kiro-oauth - * --openai-api-key OpenAI API 密钥 / OpenAI API key (for openai-custom provider) - * --openai-base-url OpenAI API 基础 URL / OpenAI API base URL (for openai-custom provider) - * --claude-api-key Claude API 密钥 / Claude API key (for claude-custom provider) - * --claude-base-url Claude API 基础 URL / Claude API base URL (for claude-custom provider) - * --gemini-oauth-creds-base64 Gemini OAuth 凭据的 Base64 字符串 / Gemini OAuth credentials as Base64 string - * --gemini-oauth-creds-file Gemini OAuth 凭据 JSON 文件路径 / Path to Gemini OAuth credentials JSON file - * --kiro-oauth-creds-base64 Kiro OAuth 凭据的 Base64 字符串 / Kiro OAuth credentials as Base64 string - * --kiro-oauth-creds-file Kiro OAuth 凭据 JSON 文件路径 / Path to Kiro OAuth credentials JSON file - * --qwen-oauth-creds-file Qwen OAuth 凭据 JSON 文件路径 / Path to Qwen OAuth credentials JSON file - * --project-id Google Cloud 项目 ID / Google Cloud Project ID (for gemini-cli provider) - * --system-prompt-file 系统提示文件路径 / Path to system prompt file (default: configs/input_system_prompt.txt) - * --system-prompt-mode 系统提示模式 / System prompt mode: overwrite or append (default: overwrite) - * --log-prompts 提示日志模式 / Prompt logging mode: console, file, or none (default: none) - * --prompt-log-base-name 提示日志文件基础名称 / Base name for prompt log files (default: prompt_log) - * --request-max-retries API 请求失败时,自动重试的最大次数。 / Max retries for API requests on failure (default: 3) - * --request-base-delay 自动重试之间的基础延迟时间(毫秒)。每次重试后延迟会增加。 / Base delay in milliseconds between retries, increases with each retry (default: 1000) - * --cron-near-minutes OAuth 令牌刷新任务计划的间隔时间(分钟)。 / Interval for OAuth token refresh task in minutes (default: 15) - * --cron-refresh-token 是否开启 OAuth 令牌自动刷新任务 / Whether to enable automatic OAuth token refresh task (default: true) - * --provider-pools-file 提供商号池配置文件路径 / Path to provider pools configuration file (default: null) - * - */ - -import 'dotenv/config'; // Import dotenv and configure it -import '../converters/register-converters.js'; // 注册所有转换器 -import { getProviderPoolManager } from './service-manager.js'; -import { isRetryableNetworkError } from '../utils/common.js'; - -// 检测是否作为子进程运行 -const IS_WORKER_PROCESS = process.env.IS_WORKER_PROCESS === 'true'; - -// 存储服务器实例,用于优雅关闭 -let serverInstance = null; - -/** - * 发送消息给主进程 - * @param {Object} message - 消息对象 - */ -function sendToMaster(message) { - if (IS_WORKER_PROCESS && process.send) { - process.send(message); - } -} - -/** - * 设置子进程通信处理 - */ -function setupWorkerCommunication() { - if (!IS_WORKER_PROCESS) return; - - // 监听来自主进程的消息 - process.on('message', (message) => { - if (!message || !message.type) return; - - logger.info('[Worker] Received message from master:', message.type); - - switch (message.type) { - case 'shutdown': - logger.info('[Worker] Shutdown requested by master'); - gracefulShutdown(); - break; - case 'status': - sendToMaster({ - type: 'status', - data: { - pid: process.pid, - uptime: process.uptime(), - memoryUsage: process.memoryUsage() - } - }); - break; - default: - logger.info('[Worker] Unknown message type:', message.type); - } - }); - - // 监听断开连接 - process.on('disconnect', () => { - logger.info('[Worker] Disconnected from master, shutting down...'); - gracefulShutdown(); - }); -} - -/** - * 优雅关闭服务器 - */ -async function gracefulShutdown() { - logger.info('[Server] Initiating graceful shutdown...'); - - // 停止 TLS sidecar - try { - await getTLSSidecar().stop(); - } catch { /* ignore */ } - - if (serverInstance) { - serverInstance.close(() => { - logger.info('[Server] HTTP server closed'); - process.exit(0); - }); - - // 设置超时,防止无限等待 - setTimeout(() => { - logger.info('[Server] Shutdown timeout, forcing exit...'); - process.exit(1); - }, 10000); - } else { - process.exit(0); - } -} - -/** - * 设置进程信号处理 - */ -function setupSignalHandlers() { - process.on('SIGTERM', () => { - logger.info('[Server] Received SIGTERM'); - gracefulShutdown(); - }); - - process.on('SIGINT', () => { - logger.info('[Server] Received SIGINT'); - gracefulShutdown(); - }); - - process.on('uncaughtException', (error) => { - logger.error('[Server] Uncaught exception:', error); - - // 检查是否为可重试的网络错误 - if (isRetryableNetworkError(error)) { - logger.warn('[Server] Network error detected, continuing operation...'); - return; // 不退出程序,继续运行 - } - - // 对于其他严重错误,执行优雅关闭 - logger.error('[Server] Fatal error detected, initiating shutdown...'); - gracefulShutdown(); - }); - - process.on('unhandledRejection', (reason, promise) => { - logger.error('[Server] Unhandled rejection at:', promise, 'reason:', reason); - - // 检查是否为可重试的网络错误 - if (reason && isRetryableNetworkError(reason)) { - logger.warn('[Server] Network error in promise rejection, continuing operation...'); - return; // 不退出程序,继续运行 - } - }); -} - -// --- Server Initialization --- -async function startServer() { - // Initialize configuration - await initializeConfig(process.argv.slice(2), 'configs/config.json'); - - // 自动关联 configs 目录中的配置文件到对应的提供商 - // logger.info('[Initialization] Checking for unlinked provider configs...'); - // await autoLinkProviderConfigs(CONFIG); - - // Start TLS sidecar if enabled - if (CONFIG.TLS_SIDECAR_ENABLED) { - const sidecar = getTLSSidecar(); - const started = await sidecar.start({ - port: CONFIG.TLS_SIDECAR_PORT, - binaryPath: CONFIG.TLS_SIDECAR_BINARY_PATH || undefined, - }); - if (started) { - logger.info('[Initialization] TLS sidecar started successfully'); - } else { - logger.warn('[Initialization] TLS sidecar failed to start, falling back to Node.js TLS'); - } - } - - // Initialize plugin system - logger.info('[Initialization] Discovering and initializing plugins...'); - await discoverPlugins(); - const pluginManager = getPluginManager(); - await pluginManager.initAll(CONFIG); - - // Log loaded plugins - const pluginList = pluginManager.getPluginList(); - if (pluginList.length > 0) { - logger.info(`[Plugins] Loaded ${pluginList.length} plugin(s):`); - pluginList.forEach(p => { - const status = p.enabled ? '✓' : '✗'; - logger.info(` ${status} ${p.name} v${p.version} - ${p.description}`); - }); - } - - // Initialize API services - const services = await initApiService(CONFIG, true); - - // Initialize UI management features - initializeUIManagement(CONFIG); - - // Initialize API management and get heartbeat function - const heartbeatAndRefreshToken = initializeAPIManagement(services); - - // Create request handler - const requestHandlerInstance = createRequestHandler(CONFIG, getProviderPoolManager()); - - serverInstance = http.createServer({ - // 设置服务器级别的超时 - requestTimeout: 0, // 禁用请求超时(流式响应需要) - headersTimeout: 60000, // 头部超时 60 秒 - keepAliveTimeout: 65000 // Keep-alive 超时 - }, requestHandlerInstance); - - // 设置服务器的最大连接数 - serverInstance.maxConnections = 1000; - serverInstance.listen(CONFIG.SERVER_PORT, CONFIG.HOST, async () => { - logger.info(`--- Unified API Server Configuration ---`); - const configuredProviders = Array.isArray(CONFIG.DEFAULT_MODEL_PROVIDERS) && CONFIG.DEFAULT_MODEL_PROVIDERS.length > 0 - ? CONFIG.DEFAULT_MODEL_PROVIDERS - : [CONFIG.MODEL_PROVIDER]; - const uniqueProviders = [...new Set(configuredProviders)]; - logger.info(` Primary Model Provider: ${CONFIG.MODEL_PROVIDER}`); - if (uniqueProviders.length > 1) { - logger.info(` Additional Model Providers: ${uniqueProviders.slice(1).join(', ')}`); - } - logger.info(` System Prompt File: ${CONFIG.SYSTEM_PROMPT_FILE_PATH || 'Default'}`); - logger.info(` System Prompt Mode: ${CONFIG.SYSTEM_PROMPT_MODE}`); - logger.info(` Host: ${CONFIG.HOST}`); - logger.info(` Port: ${CONFIG.SERVER_PORT}`); - logger.info(` Required API Key: ${CONFIG.REQUIRED_API_KEY}`); - logger.info(` Prompt Logging: ${CONFIG.PROMPT_LOG_MODE}${CONFIG.PROMPT_LOG_FILENAME ? ` (to ${CONFIG.PROMPT_LOG_FILENAME})` : ''}`); - logger.info(`------------------------------------------`); - logger.info(`\nUnified API Server running on http://${CONFIG.HOST}:${CONFIG.SERVER_PORT}`); - logger.info(`Supports multiple API formats:`); - logger.info(` • OpenAI-compatible: /v1/chat/completions, /v1/responses, /v1/models`); - logger.info(` • Gemini-compatible: /v1beta/models, /v1beta/models/{model}:generateContent`); - logger.info(` • Claude-compatible: /v1/messages`); - logger.info(` • Health check: /health`); - logger.info(` • UI Management Console: http://${CONFIG.HOST}:${CONFIG.SERVER_PORT}/`); - - // Auto-open browser to UI (only if host is 0.0.0.0 or 127.0.0.1) - // if (CONFIG.HOST === '0.0.0.0' || CONFIG.HOST === '127.0.0.1') { - try { - const open = (await import('open')).default; - // 作为子进程启动时,需要更长的延迟确保服务完全就绪 - const openDelay = IS_WORKER_PROCESS ? 3000 : 1000; - setTimeout(() => { - let openUrl = `http://${CONFIG.HOST}:${CONFIG.SERVER_PORT}/login.html`; - if(CONFIG.HOST === '0.0.0.0'){ - openUrl = `http://localhost:${CONFIG.SERVER_PORT}/login.html`; - } - open(openUrl) - .then(() => { - logger.info('[UI] Opened login page in default browser'); - }) - .catch(err => { - logger.info('[UI] Please open manually: http://' + CONFIG.HOST + ':' + CONFIG.SERVER_PORT + '/login.html'); - }); - }, openDelay); - } catch (err) { - logger.info(`[UI] Login page available at: http://${CONFIG.HOST}:${CONFIG.SERVER_PORT}/login.html`); - } - // } - - if (CONFIG.CRON_REFRESH_TOKEN) { - logger.info(` • Cron Near Minutes: ${CONFIG.CRON_NEAR_MINUTES}`); - logger.info(` • Cron Refresh Token: ${CONFIG.CRON_REFRESH_TOKEN}`); - // 每 CRON_NEAR_MINUTES 分钟执行一次心跳日志和令牌刷新 - setInterval(heartbeatAndRefreshToken, CONFIG.CRON_NEAR_MINUTES * 60 * 1000); - } - // 服务器完全启动后,执行初始健康检查 - const poolManager = getProviderPoolManager(); - if (poolManager) { - logger.info('[Initialization] Performing initial health checks for provider pools...'); - poolManager.performHealthChecks(true); - } - - // 如果是子进程,通知主进程已就绪 - if (IS_WORKER_PROCESS) { - sendToMaster({ type: 'ready', pid: process.pid }); - } - }); - return serverInstance; // Return the server instance for testing purposes -} - -// 设置信号处理 -setupSignalHandlers(); - -// 设置子进程通信 -setupWorkerCommunication(); - -startServer().catch(err => { - logger.error("[Server] Failed to start server:", err.message); - process.exit(1); -}); - -// 导出用于外部调用 -export { gracefulShutdown, sendToMaster }; diff --git a/src/services/service-manager.js b/src/services/service-manager.js deleted file mode 100644 index 747518f4e949d9ee9886955d1b002cb8c415e682..0000000000000000000000000000000000000000 --- a/src/services/service-manager.js +++ /dev/null @@ -1,621 +0,0 @@ -import { getServiceAdapter, serviceInstances } from '../providers/adapter.js'; -import logger from '../utils/logger.js'; -import { ProviderPoolManager } from '../providers/provider-pool-manager.js'; -import deepmerge from 'deepmerge'; -import * as fs from 'fs'; -import { promises as pfs } from 'fs'; -import * as path from 'path'; -import { - PROVIDER_MAPPINGS, - createProviderConfig, - addToUsedPaths, - isPathUsed, - getFileName, - formatSystemPath -} from '../utils/provider-utils.js'; -import { MODEL_PROVIDER } from '../utils/common.js'; - -// 存储 ProviderPoolManager 实例 -let providerPoolManager = null; - -/** - * 扫描 configs 目录并自动关联未关联的配置文件到对应的提供商 - * @param {Object} config - 服务器配置对象 - * @param {Object} options - 可选参数 - * @param {boolean} options.onlyCurrentCred - 为 true 时,只自动关联当前凭证 - * @param {string} options.credPath - 当前凭证的路径(当 onlyCurrentCred 为 true 时必需) - * @returns {Promise} 更新后的 providerPools 对象 - */ -export async function autoLinkProviderConfigs(config, options = {}) { - // 确保 providerPools 对象存在 - if (!config.providerPools) { - config.providerPools = {}; - } - - let totalNewProviders = 0; - const allNewProviders = {}; - - // 如果只关联当前凭证 - if (options.onlyCurrentCred && options.credPath) { - const result = await linkSingleCredential(config, options.credPath); - if (result) { - totalNewProviders = 1; - allNewProviders[result.displayName] = [result.provider]; - } - } else { - // 遍历所有提供商映射 - for (const mapping of PROVIDER_MAPPINGS) { - const configsPath = path.join(process.cwd(), 'configs', mapping.dirName); - const { providerType, credPathKey, defaultCheckModel, displayName, needsProjectId } = mapping; - - // 确保提供商类型数组存在 - if (!config.providerPools[providerType]) { - config.providerPools[providerType] = []; - } - - // 检查目录是否存在 - if (!fs.existsSync(configsPath)) { - continue; - } - - // 获取已关联的配置文件路径集合 - const linkedPaths = new Set(); - for (const provider of config.providerPools[providerType]) { - if (provider[credPathKey]) { - // 使用公共方法添加路径的所有变体格式 - addToUsedPaths(linkedPaths, provider[credPathKey]); - } - } - - // 递归扫描目录 - const newProviders = []; - await scanProviderDirectory(configsPath, linkedPaths, newProviders, { - credPathKey, - defaultCheckModel, - needsProjectId - }); - - // 如果有新的配置文件需要关联 - if (newProviders.length > 0) { - config.providerPools[providerType].push(...newProviders); - totalNewProviders += newProviders.length; - allNewProviders[displayName] = newProviders; - } - } - } - - // 如果有新的配置文件需要关联,保存更新后的 provider_pools.json - if (totalNewProviders > 0) { - const filePath = config.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; - try { - await pfs.writeFile(filePath, JSON.stringify(config.providerPools, null, 2), 'utf8'); - logger.info(`[Auto-Link] Added ${totalNewProviders} new config(s) to provider pools:`); - for (const [displayName, providers] of Object.entries(allNewProviders)) { - logger.info(` ${displayName}: ${providers.length} config(s)`); - providers.forEach(p => { - // 获取凭据路径键(支持 _CREDS_FILE_PATH 和 _TOKEN_FILE_PATH 两种格式) - const credKey = Object.keys(p).find(k => - k.endsWith('_CREDS_FILE_PATH') || k.endsWith('_TOKEN_FILE_PATH') - ); - if (credKey) { - logger.info(` - ${p[credKey]}`); - } - }); - } - } catch (error) { - logger.error(`[Auto-Link] Failed to save provider_pools.json: ${error.message}`); - } - } else { - logger.info('[Auto-Link] No new configs to link'); - } - - // Update provider pool manager if available - if (providerPoolManager) { - providerPoolManager.providerPools = config.providerPools; - providerPoolManager.initializeProviderStatus(); - } - return config.providerPools; -} - -/** - * 关联单个凭证文件到对应的提供商 - * @param {Object} config - 服务器配置对象 - * @param {string} credPath - 凭证文件路径(相对或绝对路径) - * @returns {Promise} 返回关联结果或 null - */ -async function linkSingleCredential(config, credPath) { - try { - // 规范化路径 - const absolutePath = path.isAbsolute(credPath) ? credPath : path.join(process.cwd(), credPath); - const relativePath = path.relative(process.cwd(), absolutePath); - - // 检查文件是否存在 - if (!fs.existsSync(absolutePath)) { - logger.warn(`[Auto-Link] Credential file not found: ${relativePath}`); - return null; - } - - // 检查文件扩展名 - const ext = path.extname(absolutePath).toLowerCase(); - if (ext !== '.json') { - logger.warn(`[Auto-Link] Only JSON files are supported: ${relativePath}`); - return null; - } - - // 根据文件路径确定提供商类型 - let matchedMapping = null; - for (const mapping of PROVIDER_MAPPINGS) { - const configsPath = path.join(process.cwd(), 'configs', mapping.dirName); - // 检查文件是否在该提供商的配置目录下 - if (absolutePath.startsWith(configsPath)) { - matchedMapping = mapping; - break; - } - } - - if (!matchedMapping) { - logger.warn(`[Auto-Link] Could not determine provider type for: ${relativePath}`); - return null; - } - - const { providerType, credPathKey, defaultCheckModel, displayName, needsProjectId } = matchedMapping; - - // 确保提供商类型数组存在 - if (!config.providerPools[providerType]) { - config.providerPools[providerType] = []; - } - - // 检查是否已关联 - const linkedPaths = new Set(); - for (const provider of config.providerPools[providerType]) { - if (provider[credPathKey]) { - addToUsedPaths(linkedPaths, provider[credPathKey]); - } - } - - const fileName = getFileName(absolutePath); - const isLinked = isPathUsed(relativePath, fileName, linkedPaths); - - if (isLinked) { - logger.info(`[Auto-Link] Credential already linked: ${relativePath}`); - return null; - } - - // 创建新的提供商配置 - const newProvider = createProviderConfig({ - credPathKey, - credPath: formatSystemPath(relativePath), - defaultCheckModel, - needsProjectId - }); - - // 添加到配置 - config.providerPools[providerType].push(newProvider); - - logger.info(`[Auto-Link] Successfully linked credential: ${relativePath} to ${displayName}`); - - return { - provider: newProvider, - displayName, - providerType - }; - } catch (error) { - logger.error(`[Auto-Link] Failed to link credential ${credPath}: ${error.message}`); - return null; - } -} - -/** - * 递归扫描提供商配置目录 - * @param {string} dirPath - 目录路径 - * @param {Set} linkedPaths - 已关联的路径集合 - * @param {Array} newProviders - 新提供商配置数组 - * @param {Object} options - 配置选项 - * @param {string} options.credPathKey - 凭据路径键名 - * @param {string} options.defaultCheckModel - 默认检测模型 - * @param {boolean} options.needsProjectId - 是否需要 PROJECT_ID - */ -async function scanProviderDirectory(dirPath, linkedPaths, newProviders, options) { - const { credPathKey, defaultCheckModel, needsProjectId } = options; - - try { - const files = await pfs.readdir(dirPath, { withFileTypes: true }); - - for (const file of files) { - const fullPath = path.join(dirPath, file.name); - - if (file.isFile()) { - const ext = path.extname(file.name).toLowerCase(); - // 只处理 JSON 文件 - if (ext === '.json') { - const relativePath = path.relative(process.cwd(), fullPath); - const fileName = getFileName(fullPath); - - // 使用与 ui-manager.js 相同的 isPathUsed 函数检查是否已关联 - const isLinked = isPathUsed(relativePath, fileName, linkedPaths); - - if (!isLinked) { - // 使用公共方法创建新的提供商配置 - const newProvider = createProviderConfig({ - credPathKey, - credPath: formatSystemPath(relativePath), - defaultCheckModel, - needsProjectId - }); - - newProviders.push(newProvider); - } - } - } else if (file.isDirectory()) { - // 递归扫描子目录(限制深度为 3 层) - const relativePath = path.relative(process.cwd(), fullPath); - const depth = relativePath.split(path.sep).length; - if (depth < 5) { // configs/{provider}/subfolder/subsubfolder - await scanProviderDirectory(fullPath, linkedPaths, newProviders, options); - } - } - } - } catch (error) { - logger.warn(`[Auto-Link] Failed to scan directory ${dirPath}: ${error.message}`); - } -} - -// 注意:isValidOAuthCredentials 已移至 provider-utils.js 公共模块 - -/** - * Initialize API services and provider pool manager - * @param {Object} config - The server configuration - * @returns {Promise} The initialized services - */ -export async function initApiService(config, isReady = false) { - - if (config.providerPools && Object.keys(config.providerPools).length > 0) { - providerPoolManager = new ProviderPoolManager(config.providerPools, { - globalConfig: config, - maxErrorCount: config.MAX_ERROR_COUNT ?? 3, - providerFallbackChain: config.providerFallbackChain || {}, - }); - logger.info('[Initialization] ProviderPoolManager initialized with configured pools.'); - - if(isReady){ - // --- V2: 触发系统预热 --- - // 预热逻辑是异步的,不会阻塞服务器启动 - providerPoolManager.warmupNodes().catch(err => { - logger.error(`[Initialization] Warmup failed: ${err.message}`); - }); - - // 检查并刷新即将过期的节点(异步调用,不阻塞启动) - providerPoolManager.checkAndRefreshExpiringNodes().catch(err => { - logger.error(`[Initialization] Check and refresh expiring nodes failed: ${err.message}`); - }); - } - - // 健康检查将在服务器完全启动后执行 - } else { - logger.info('[Initialization] No provider pools configured. Using single provider mode.'); - } - - // Initialize all provider pool nodes at startup - // 初始化号池中所有提供商的所有节点,以避免首个请求的额外延迟 - if (config.providerPools && Object.keys(config.providerPools).length > 0) { - let totalInitialized = 0; - let totalFailed = 0; - - for (const [providerType, providerConfigs] of Object.entries(config.providerPools)) { - // 验证提供商类型是否在 DEFAULT_MODEL_PROVIDERS 中 - if (config.DEFAULT_MODEL_PROVIDERS && Array.isArray(config.DEFAULT_MODEL_PROVIDERS)) { - if (!config.DEFAULT_MODEL_PROVIDERS.includes(providerType)) { - logger.info(`[Initialization] Skipping provider type '${providerType}' (not in DEFAULT_MODEL_PROVIDERS).`); - continue; - } - } - - if (!Array.isArray(providerConfigs) || providerConfigs.length === 0) { - continue; - } - - logger.info(`[Initialization] Initializing ${providerConfigs.length} node(s) for provider '${providerType}'...`); - - // 初始化该提供商类型的所有节点 - for (const providerConfig of providerConfigs) { - // 跳过已禁用的节点 - if (providerConfig.isDisabled) { - continue; - } - - try { - // 合并全局配置和节点配置 - const nodeConfig = deepmerge(config, { - ...providerConfig, - MODEL_PROVIDER: providerType - }); - delete nodeConfig.providerPools; // 移除 providerPools 避免递归 - - // 初始化服务适配器 - getServiceAdapter(nodeConfig); - totalInitialized++; - - const identifier = providerConfig.customName || providerConfig.uuid || 'unknown'; - logger.info(` ✓ Initialized node: ${identifier}`); - } catch (error) { - totalFailed++; - const identifier = providerConfig.customName || providerConfig.uuid || 'unknown'; - logger.warn(` ✗ Failed to initialize node ${identifier}: ${error.message}`); - } - } - } - - logger.info(`[Initialization] Provider pool initialization complete: ${totalInitialized} succeeded, ${totalFailed} failed.`); - } else { - logger.info('[Initialization] No provider pools configured. Skipping node initialization.'); - } - return serviceInstances; // Return the collection of initialized service instances -} - -/** - * [路由解析层] 负责前置处理前缀和 AUTO 模式转换 - * @private - * @returns {Promise} { effectiveProvider, actualModelName } - */ -async function _resolveEffectiveRouting(config, requestedModel) { - let effectiveProvider = config.MODEL_PROVIDER; - let actualModelName = requestedModel; - - // 1. 处理显式前缀 (无论是否是 AUTO 模式都支持) - if (requestedModel && requestedModel.includes(':')) { - const [prefix, ...modelParts] = requestedModel.split(':'); - const modelSuffix = modelParts.join(':'); - // 检查前缀是否是有效的提供商标识 - if (providerPoolManager && (providerPoolManager.providerStatus[prefix] || config.providerPools?.[prefix])) { - effectiveProvider = prefix; - actualModelName = modelSuffix; - logger.info(`[Routing] Prefix resolved: ${prefix}:${modelSuffix}`); - } - } - - // 2. 严格性检查:在 AUTO 模式下,如果到这里还没解析出具体提供商,则报错 (除非是列出模型场景) - if (effectiveProvider === MODEL_PROVIDER.AUTO && requestedModel) { - throw new Error(`[API Service] Auto-routing failed: Model name must include a provider prefix (e.g., 'provider:model'). Received: '${requestedModel}'`); - } - - return { effectiveProvider, actualModelName }; -} - -/** - * Get API service adapter, considering provider pools - * @param {Object} config - The current request configuration - * @param {string} [requestedModel] - Optional. The model name to filter providers by. - * @param {Object} [options] - Optional. Additional options. - * @param {boolean} [options.skipUsageCount] - Optional. If true, skip incrementing usage count. - * @returns {Promise} The API service adapter - */ -export async function getApiService(config, requestedModel = null, options = {}) { - // 1. 前置路由解析 - const { effectiveProvider, actualModelName } = await _resolveEffectiveRouting(config, requestedModel); - config.MODEL_PROVIDER = effectiveProvider; - - // 模型列表特殊场景:AUTO 且无模型名 - if (effectiveProvider === MODEL_PROVIDER.AUTO && !actualModelName) return null; - - let serviceConfig = config; - if (providerPoolManager && config.providerPools && config.providerPools[config.MODEL_PROVIDER]) { - // 如果有号池管理器,并且当前模型提供者类型有对应的号池,则从号池中选择一个提供者配置 - // selectProvider 现在是异步的,使用链式锁确保并发安全 - const selectedProviderConfig = await providerPoolManager.selectProvider(config.MODEL_PROVIDER, actualModelName, { ...options, skipUsageCount: true }); - if (selectedProviderConfig) { - // 合并选中的提供者配置到当前请求的 config 中 - serviceConfig = deepmerge(config, selectedProviderConfig); - delete serviceConfig.providerPools; // 移除 providerPools 属性 - config.uuid = serviceConfig.uuid; - config.customName = serviceConfig.customName; - const customNameDisplay = serviceConfig.customName ? ` (${serviceConfig.customName})` : ''; - logger.info(`[API Service] Using pooled configuration for ${config.MODEL_PROVIDER}: ${serviceConfig.uuid}${customNameDisplay}${actualModelName ? ` (model: ${actualModelName})` : ''}`); - } else { - const errorMsg = `[API Service] No healthy provider found in pool for ${config.MODEL_PROVIDER}${actualModelName ? ` supporting model: ${actualModelName}` : ''}`; - logger.error(errorMsg); - throw new Error(errorMsg); - } - } else if (effectiveProvider === MODEL_PROVIDER.AUTO && actualModelName) { - // 如果在 AUTO 模式下依然没能解析出具体提供商,则报错 - throw new Error(`[API Service] Auto-routing failed: Model name must include a provider prefix (e.g., 'provider:model'). Received: '${actualModelName}'`); - } - return getServiceAdapter(serviceConfig); -} - -/** - * Get API service adapter with fallback support and return detailed result - * @param {Object} config - The current request configuration - * @param {string} [requestedModel] - Optional. The model name to filter providers by. - * @param {Object} [options] - Optional. Additional options. - * @returns {Promise} Object containing service adapter and metadata - */ -export async function getApiServiceWithFallback(config, requestedModel = null, options = {}) { - // 1. 前置路由解析 - const { effectiveProvider, actualModelName } = await _resolveEffectiveRouting(config, requestedModel); - config.MODEL_PROVIDER = effectiveProvider; - - // 模型列表特殊场景:AUTO 且无模型名 - if (effectiveProvider === MODEL_PROVIDER.AUTO && !actualModelName) { - return { service: null, serviceConfig: config, actualProviderType: effectiveProvider, isFallback: false, uuid: null, actualModel: null }; - } - - let serviceConfig = config; - let actualProviderType = config.MODEL_PROVIDER; - let isFallback = false; - let selectedUuid = null; - let actualModel = actualModelName; - - if (providerPoolManager && config.providerPools && config.providerPools[config.MODEL_PROVIDER]) { - // selectProviderWithFallback 现在是异步的,使用链式锁确保并发安全 - // 如果开启了并发限制,则使用 acquireSlot 进行选择和占位 - const useAcquire = options.acquireSlot === true; - let selectedResult; - - if (useAcquire) { - // 我们需要一个支持 Fallback 的 acquireSlot - selectedResult = await providerPoolManager.acquireSlotWithFallback( - config.MODEL_PROVIDER, - actualModelName, - options - ); - } else { - selectedResult = await providerPoolManager.selectProviderWithFallback( - config.MODEL_PROVIDER, - actualModelName, - { ...options, skipUsageCount: true } - ); - } - - if (selectedResult) { - const { config: selectedProviderConfig, actualProviderType: selectedType, isFallback: fallbackUsed, actualModel: fallbackModel } = selectedResult; - - // 合并选中的提供者配置到当前请求的 config 中 - serviceConfig = deepmerge(config, selectedProviderConfig); - delete serviceConfig.providerPools; - - actualProviderType = selectedType; - isFallback = fallbackUsed; - selectedUuid = selectedProviderConfig.uuid; - actualModel = fallbackModel || actualModelName; - - // 如果发生了 fallback,需要更新 MODEL_PROVIDER - if (isFallback) { - serviceConfig.MODEL_PROVIDER = actualProviderType; - } - } else { - const errorMsg = `[API Service] No healthy provider found in pool (including fallback) for ${config.MODEL_PROVIDER}${actualModelName ? ` supporting model: ${actualModelName}` : ''}`; - logger.error(errorMsg); - throw new Error(errorMsg); - } - } else if (effectiveProvider === MODEL_PROVIDER.AUTO && actualModelName) { - // 如果在 AUTO 模式下依然没能解析出具体提供商,则报错 - throw new Error(`[API Service] Auto-routing failed: Model name must include a provider prefix (e.g., 'provider:model'). Received: '${actualModelName}'`); - } - - const service = getServiceAdapter(serviceConfig); - - return { - service, - serviceConfig, - actualProviderType, - isFallback, - uuid: selectedUuid, - actualModel - }; -} - -/** - * Get the provider pool manager instance - * @returns {Object} The provider pool manager - */ -export function getProviderPoolManager() { - return providerPoolManager; -} - -/** - * Mark provider as unhealthy - * @param {string} provider - The model provider - * @param {Object} providerInfo - Provider information including uuid - */ -export function markProviderUnhealthy(provider, providerInfo) { - if (providerPoolManager) { - providerPoolManager.markProviderUnhealthy(provider, providerInfo); - } -} - -/** - * Get providers status - * @param {Object} config - The current request configuration - * @param {Object} [options] - Optional. Additional options. - * @param {boolean} [options.provider] - Optional.provider filter by provider type - * @param {boolean} [options.customName] - Optional.customName filter by customName - * @returns {Promise} The API service adapter - */ -export async function getProviderStatus(config, options = {}) { - let providerPools = {}; - const filePath = config.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; - try { - if (providerPoolManager && providerPoolManager.providerPools) { - providerPools = providerPoolManager.providerPools; - } else if (filePath && fs.existsSync(filePath)) { - const poolsData = JSON.parse(fs.readFileSync(filePath, 'utf-8')); - providerPools = poolsData; - } - } catch (error) { - logger.warn('[API Service] Failed to load provider pools:', error.message); - } - - // providerPoolsSlim 只保留顶级 key 及部分字段,过滤 isDisabled 为 true 的元素 - const slimFields = [ - 'customName', - 'isHealthy', - 'lastErrorTime', - 'lastErrorMessage' - ]; - // identify 字段映射表 - const identifyFieldMap = { - 'openai-custom': 'OPENAI_BASE_URL', - 'openaiResponses-custom': 'OPENAI_BASE_URL', - 'gemini-cli-oauth': 'GEMINI_OAUTH_CREDS_FILE_PATH', - 'claude-custom': 'CLAUDE_BASE_URL', - 'claude-kiro-oauth': 'KIRO_OAUTH_CREDS_FILE_PATH', - 'openai-qwen-oauth': 'QWEN_OAUTH_CREDS_FILE_PATH', - 'gemini-antigravity': 'ANTIGRAVITY_OAUTH_CREDS_FILE_PATH', - 'openai-iflow': 'IFLOW_TOKEN_FILE_PATH', - 'forward-api': 'FORWARD_BASE_URL', - 'grok-custom': 'GROK_COOKIE_TOKEN' - }; - let providerPoolsSlim = []; - let unhealthyProvideIdentifyList = []; - let count = 0; - let unhealthyCount = 0; - let unhealthyRatio = 0; - const filterProvider = options && options.provider; - const filterCustomName = options && options.customName; - for (const key of Object.keys(providerPools)) { - if (!Array.isArray(providerPools[key])) continue; - if (filterProvider && key !== filterProvider) continue; - const identifyField = identifyFieldMap[key] || null; - const slimArr = providerPools[key] - .filter(item => { - if (item.isDisabled) return false; - if (filterCustomName && item.customName !== filterCustomName) return false; - return true; - }) - .map(item => { - const slim = {}; - for (const f of slimFields) { - slim[f] = item.hasOwnProperty(f) ? item[f] : null; - } - // identify 字段 - if (identifyField && item.hasOwnProperty(identifyField)) { - let tmpCustomName = item.customName ? `${item.customName}` : 'NoCustomName'; - let identifyStr = `${tmpCustomName}::${key}::${item[identifyField]}`; - slim.identify = identifyStr; - } else { - slim.identify = null; - } - slim.provider = key; - // 统计 - count++; - if (slim.isHealthy === false) { - unhealthyCount++; - if (slim.identify) unhealthyProvideIdentifyList.push(slim.identify); - } - return slim; - }); - providerPoolsSlim.push(...slimArr); - } - if (count > 0) { - unhealthyRatio = Number((unhealthyCount / count).toFixed(2)); - } - let unhealthySummeryMessage = unhealthyProvideIdentifyList.join('\n'); - if (unhealthySummeryMessage === '') unhealthySummeryMessage = null; - return { - providerPoolsSlim, - unhealthySummeryMessage, - count, - unhealthyCount, - unhealthyRatio - }; -} diff --git a/src/services/ui-manager.js b/src/services/ui-manager.js deleted file mode 100644 index 91db6328abf1b1228f7141817c499d39243ea4db..0000000000000000000000000000000000000000 --- a/src/services/ui-manager.js +++ /dev/null @@ -1,351 +0,0 @@ -import { existsSync, readFileSync } from 'fs'; -import path from 'path'; - -// Import UI modules -import * as auth from '../ui-modules/auth.js'; -import * as configApi from '../ui-modules/config-api.js'; -import * as providerApi from '../ui-modules/provider-api.js'; -import * as usageApi from '../ui-modules/usage-api.js'; -import * as pluginApi from '../ui-modules/plugin-api.js'; -import * as uploadConfigApi from '../ui-modules/upload-config-api.js'; -import * as systemApi from '../ui-modules/system-api.js'; -import * as updateApi from '../ui-modules/update-api.js'; -import * as oauthApi from '../ui-modules/oauth-api.js'; -import * as eventBroadcast from '../ui-modules/event-broadcast.js'; - -// Re-export from event-broadcast module -export { broadcastEvent, initializeUIManagement, handleUploadOAuthCredentials, upload } from '../ui-modules/event-broadcast.js'; - -/** - * Serve static files for the UI - * @param {string} path - The request path - * @param {http.ServerResponse} res - The HTTP response object - */ -export async function serveStaticFiles(pathParam, res) { - const filePath = path.join(process.cwd(), 'static', pathParam === '/' || pathParam === '/index.html' ? 'index.html' : pathParam.replace('/static/', '')); - - if (existsSync(filePath)) { - const ext = path.extname(filePath); - const contentType = { - '.html': 'text/html', - '.css': 'text/css', - '.js': 'application/javascript', - '.png': 'image/png', - '.jpg': 'image/jpeg', - '.ico': 'image/x-icon' - }[ext] || 'text/plain'; - - res.writeHead(200, { 'Content-Type': contentType }); - res.end(readFileSync(filePath)); - return true; - } - return false; -} - -/** - * Handle UI management API requests - * @param {string} method - The HTTP method - * @param {string} path - The request path - * @param {http.IncomingMessage} req - The HTTP request object - * @param {http.ServerResponse} res - The HTTP response object - * @param {Object} currentConfig - The current configuration object - * @param {Object} providerPoolManager - The provider pool manager instance - * @returns {Promise} - True if the request was handled by UI API - */ -export async function handleUIApiRequests(method, pathParam, req, res, currentConfig, providerPoolManager) { - // 处理登录接口 - if (method === 'POST' && pathParam === '/api/login') { - return await auth.handleLoginRequest(req, res); - } - - // 健康检查接口(用于前端token验证) - if (method === 'GET' && pathParam === '/api/health') { - return await systemApi.handleHealthCheck(req, res); - } - - // Handle UI management API requests (需要token验证,除了登录接口、健康检查和Events接口) - if (pathParam.startsWith('/api/') && pathParam !== '/api/login' && pathParam !== '/api/health' && pathParam !== '/api/events' && pathParam !== '/api/grok/assets') { - // 检查token验证 - const isAuth = await auth.checkAuth(req); - if (!isAuth) { - res.writeHead(401, { - 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization' - }); - res.end(JSON.stringify({ - error: { - message: 'Unauthorized access, please login first', - code: 'UNAUTHORIZED' - } - })); - return true; - } - } - - // 文件上传API - if (method === 'POST' && pathParam === '/api/upload-oauth-credentials') { - return await eventBroadcast.handleUploadOAuthCredentials(req, res); - } - - // Update admin password - if (method === 'POST' && pathParam === '/api/admin-password') { - return await configApi.handleUpdateAdminPassword(req, res); - } - - // Get configuration - if (method === 'GET' && pathParam === '/api/config') { - return await configApi.handleGetConfig(req, res, currentConfig); - } - - // Update configuration - if (method === 'POST' && pathParam === '/api/config') { - return await configApi.handleUpdateConfig(req, res, currentConfig); - } - - // Get system information - if (method === 'GET' && pathParam === '/api/system') { - return await systemApi.handleGetSystem(req, res); - } - - // Download today's log file - if (method === 'GET' && pathParam === '/api/system/download-log') { - return await systemApi.handleDownloadTodayLog(req, res); - } - - // Clear today's log file - if (method === 'POST' && pathParam === '/api/system/clear-log') { - return await systemApi.handleClearTodayLog(req, res); - } - - // Get provider pools summary - if (method === 'GET' && pathParam === '/api/providers') { - return await providerApi.handleGetProviders(req, res, currentConfig, providerPoolManager); - } - - // Get supported provider types based on registered adapters - if (method === 'GET' && pathParam === '/api/providers/supported') { - return await providerApi.handleGetSupportedProviders(req, res); - } - - // Get specific provider type details - const providerTypeMatch = pathParam.match(/^\/api\/providers\/([^\/]+)$/); - if (method === 'GET' && providerTypeMatch) { - const providerType = decodeURIComponent(providerTypeMatch[1]); - return await providerApi.handleGetProviderType(req, res, currentConfig, providerPoolManager, providerType); - } - - // Get available models for all providers or specific provider type - if (method === 'GET' && pathParam === '/api/provider-models') { - return await providerApi.handleGetProviderModels(req, res); - } - - // Get available models for a specific provider type - const providerModelsMatch = pathParam.match(/^\/api\/provider-models\/([^\/]+)$/); - if (method === 'GET' && providerModelsMatch) { - const providerType = decodeURIComponent(providerModelsMatch[1]); - return await providerApi.handleGetProviderTypeModels(req, res, providerType); - } - - // Add new provider configuration - if (method === 'POST' && pathParam === '/api/providers') { - return await providerApi.handleAddProvider(req, res, currentConfig, providerPoolManager); - } - - // Reset all providers health status for a specific provider type - // NOTE: This must be before the generic /{providerType}/{uuid} route to avoid matching 'reset-health' as UUID - const resetHealthMatch = pathParam.match(/^\/api\/providers\/([^\/]+)\/reset-health$/); - if (method === 'POST' && resetHealthMatch) { - const providerType = decodeURIComponent(resetHealthMatch[1]); - return await providerApi.handleResetProviderHealth(req, res, currentConfig, providerPoolManager, providerType); - } - - // Perform health check for all providers of a specific type - // NOTE: This must be before the generic /{providerType}/{uuid} route to avoid matching 'health-check' as UUID - const healthCheckMatch = pathParam.match(/^\/api\/providers\/([^\/]+)\/health-check$/); - if (method === 'POST' && healthCheckMatch) { - const providerType = decodeURIComponent(healthCheckMatch[1]); - return await providerApi.handleHealthCheck(req, res, currentConfig, providerPoolManager, providerType); - } - - // Delete all unhealthy providers for a specific type - // NOTE: This must be before the generic /{providerType}/{uuid} route to avoid matching 'delete-unhealthy' as UUID - const deleteUnhealthyMatch = pathParam.match(/^\/api\/providers\/([^\/]+)\/delete-unhealthy$/); - if (method === 'DELETE' && deleteUnhealthyMatch) { - const providerType = decodeURIComponent(deleteUnhealthyMatch[1]); - return await providerApi.handleDeleteUnhealthyProviders(req, res, currentConfig, providerPoolManager, providerType); - } - - // Refresh UUIDs for all unhealthy providers of a specific type - // NOTE: This must be before the generic /{providerType}/{uuid} route to avoid matching 'refresh-unhealthy-uuids' as UUID - const refreshUnhealthyUuidsMatch = pathParam.match(/^\/api\/providers\/([^\/]+)\/refresh-unhealthy-uuids$/); - if (method === 'POST' && refreshUnhealthyUuidsMatch) { - const providerType = decodeURIComponent(refreshUnhealthyUuidsMatch[1]); - return await providerApi.handleRefreshUnhealthyUuids(req, res, currentConfig, providerPoolManager, providerType); - } - - // Disable/Enable specific provider configuration - const disableEnableProviderMatch = pathParam.match(/^\/api\/providers\/([^\/]+)\/([^\/]+)\/(disable|enable)$/); - if (disableEnableProviderMatch) { - const providerType = decodeURIComponent(disableEnableProviderMatch[1]); - const providerUuid = disableEnableProviderMatch[2]; - const action = disableEnableProviderMatch[3]; - return await providerApi.handleDisableEnableProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid, action); - } - - // Refresh UUID for specific provider configuration - const refreshUuidMatch = pathParam.match(/^\/api\/providers\/([^\/]+)\/([^\/]+)\/refresh-uuid$/); - if (method === 'POST' && refreshUuidMatch) { - const providerType = decodeURIComponent(refreshUuidMatch[1]); - const providerUuid = refreshUuidMatch[2]; - return await providerApi.handleRefreshProviderUuid(req, res, currentConfig, providerPoolManager, providerType, providerUuid); - } - - // Update specific provider configuration - // NOTE: This generic route must be after all specific routes like /reset-health, /health-check, /delete-unhealthy - const updateProviderMatch = pathParam.match(/^\/api\/providers\/([^\/]+)\/([^\/]+)$/); - if (method === 'PUT' && updateProviderMatch) { - const providerType = decodeURIComponent(updateProviderMatch[1]); - const providerUuid = updateProviderMatch[2]; - return await providerApi.handleUpdateProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid); - } - - // Delete specific provider configuration - if (method === 'DELETE' && updateProviderMatch) { - const providerType = decodeURIComponent(updateProviderMatch[1]); - const providerUuid = updateProviderMatch[2]; - return await providerApi.handleDeleteProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid); - } - - // Generate OAuth authorization URL for providers - const generateAuthUrlMatch = pathParam.match(/^\/api\/providers\/([^\/]+)\/generate-auth-url$/); - if (method === 'POST' && generateAuthUrlMatch) { - const providerType = decodeURIComponent(generateAuthUrlMatch[1]); - return await oauthApi.handleGenerateAuthUrl(req, res, currentConfig, providerType); - } - - // Handle manual OAuth callback - if (method === 'POST' && pathParam === '/api/oauth/manual-callback') { - return await oauthApi.handleManualOAuthCallback(req, res); - } - - // Server-Sent Events for real-time updates - if (method === 'GET' && pathParam === '/api/events') { - return await eventBroadcast.handleEvents(req, res); - } - - // Get upload configuration files list - if (method === 'GET' && pathParam === '/api/upload-configs') { - return await uploadConfigApi.handleGetUploadConfigs(req, res, currentConfig, providerPoolManager); - } - - // View specific configuration file - const viewConfigMatch = pathParam.match(/^\/api\/upload-configs\/view\/(.+)$/); - if (method === 'GET' && viewConfigMatch) { - const filePath = decodeURIComponent(viewConfigMatch[1]); - return await uploadConfigApi.handleViewConfigFile(req, res, filePath); - } - - // Download specific configuration file - const downloadConfigMatch = pathParam.match(/^\/api\/upload-configs\/download\/(.+)$/); - if (method === 'GET' && downloadConfigMatch) { - const filePath = decodeURIComponent(downloadConfigMatch[1]); - return await uploadConfigApi.handleDownloadConfigFile(req, res, filePath); - } - - // Delete specific configuration file - const deleteConfigMatch = pathParam.match(/^\/api\/upload-configs\/delete\/(.+)$/); - if (method === 'DELETE' && deleteConfigMatch) { - const filePath = decodeURIComponent(deleteConfigMatch[1]); - return await uploadConfigApi.handleDeleteConfigFile(req, res, filePath); - } - - // Download all configs as zip - if (method === 'GET' && pathParam === '/api/upload-configs/download-all') { - return await uploadConfigApi.handleDownloadAllConfigs(req, res); - } - - // Delete all unbound config files - if (method === 'DELETE' && pathParam === '/api/upload-configs/delete-unbound') { - return await uploadConfigApi.handleDeleteUnboundConfigs(req, res, currentConfig, providerPoolManager); - } - - // Quick link config to corresponding provider based on directory - if (method === 'POST' && pathParam === '/api/quick-link-provider') { - return await providerApi.handleQuickLinkProvider(req, res, currentConfig, providerPoolManager); - } - - // Get usage limits for all providers - if (method === 'GET' && pathParam === '/api/usage') { - return await usageApi.handleGetUsage(req, res, currentConfig, providerPoolManager); - } - - // Get supported providers for usage query - if (method === 'GET' && pathParam === '/api/usage/supported-providers') { - return await usageApi.handleGetSupportedProviders(req, res); - } - - // Get usage limits for a specific provider type - const usageProviderMatch = pathParam.match(/^\/api\/usage\/([^\/]+)$/); - if (method === 'GET' && usageProviderMatch) { - const providerType = decodeURIComponent(usageProviderMatch[1]); - return await usageApi.handleGetProviderUsage(req, res, currentConfig, providerPoolManager, providerType); - } - - // Check for updates - compare local VERSION with latest git tag - if (method === 'GET' && pathParam === '/api/check-update') { - return await updateApi.handleCheckUpdate(req, res); - } - - // Perform update - git fetch and checkout to latest tag - if (method === 'POST' && pathParam === '/api/update') { - return await updateApi.handlePerformUpdate(req, res); - } - - // Reload configuration files - if (method === 'POST' && pathParam === '/api/reload-config') { - return await configApi.handleReloadConfig(req, res, providerPoolManager); - } - - // Restart service (worker process) - if (method === 'POST' && pathParam === '/api/restart-service') { - return await systemApi.handleRestartService(req, res); - } - - // Get service mode information - if (method === 'GET' && pathParam === '/api/service-mode') { - return await systemApi.handleGetServiceMode(req, res); - } - - // Batch import Kiro refresh tokens with SSE (real-time progress) - if (method === 'POST' && pathParam === '/api/kiro/batch-import-tokens') { - return await oauthApi.handleBatchImportKiroTokens(req, res); - } - - if (method === 'POST' && pathParam === '/api/gemini/batch-import-tokens') { - return await oauthApi.handleBatchImportGeminiTokens(req, res); - } - - if (method === 'POST' && pathParam === '/api/codex/batch-import-tokens') { - return await oauthApi.handleBatchImportCodexTokens(req, res); - } - - // Import AWS SSO credentials for Kiro - if (method === 'POST' && pathParam === '/api/kiro/import-aws-credentials') { - return await oauthApi.handleImportAwsCredentials(req, res); - } - - // Get plugins list - if (method === 'GET' && pathParam === '/api/plugins') { - return await pluginApi.handleGetPlugins(req, res); - } - - // Toggle plugin status - const togglePluginMatch = pathParam.match(/^\/api\/plugins\/(.+)\/toggle$/); - if (method === 'POST' && togglePluginMatch) { - const pluginName = decodeURIComponent(togglePluginMatch[1]); - return await pluginApi.handleTogglePlugin(req, res, pluginName); - } - - return false; -} \ No newline at end of file diff --git a/src/services/usage-service.js b/src/services/usage-service.js deleted file mode 100644 index e4603470f164b74a83dea18a449730d9411288a9..0000000000000000000000000000000000000000 --- a/src/services/usage-service.js +++ /dev/null @@ -1,705 +0,0 @@ -/** - * 用量查询服务 - * 用于处理各个提供商的授权文件用量查询 - */ - -import { getProviderPoolManager } from './service-manager.js'; -import { serviceInstances } from '../providers/adapter.js'; -import { MODEL_PROVIDER } from '../utils/common.js'; - -/** - * 用量查询服务类 - * 提供统一的接口来查询各提供商的用量信息 - */ -export class UsageService { - constructor() { - this.providerHandlers = { - [MODEL_PROVIDER.KIRO_API]: this.getKiroUsage.bind(this), - [MODEL_PROVIDER.GEMINI_CLI]: this.getGeminiUsage.bind(this), - [MODEL_PROVIDER.ANTIGRAVITY]: this.getAntigravityUsage.bind(this), - [MODEL_PROVIDER.CODEX_API]: this.getCodexUsage.bind(this), - [MODEL_PROVIDER.GROK_CUSTOM]: this.getGrokUsage.bind(this), - }; - } - - - /** - * 获取指定提供商的用量信息 - * @param {string} providerType - 提供商类型 - * @param {string} [uuid] - 可选的提供商实例 UUID - * @returns {Promise} 用量信息 - */ - async getUsage(providerType, uuid = null) { - const handler = this.providerHandlers[providerType]; - if (!handler) { - throw new Error(`不支持的提供商类型: ${providerType}`); - } - return handler(uuid); - } - - /** - * 获取所有提供商的用量信息 - * @returns {Promise} 所有提供商的用量信息 - */ - async getAllUsage() { - const results = {}; - const poolManager = getProviderPoolManager(); - - for (const [providerType, handler] of Object.entries(this.providerHandlers)) { - try { - // 检查是否有号池配置 - if (poolManager) { - const pools = poolManager.getProviderPools(providerType); - if (pools && pools.length > 0) { - results[providerType] = []; - for (const pool of pools) { - try { - const usage = await handler(pool.uuid); - results[providerType].push({ - uuid: pool.uuid, - usage - }); - } catch (error) { - results[providerType].push({ - uuid: pool.uuid, - error: error.message - }); - } - } - } - } - - // 如果没有号池配置,尝试获取单个实例的用量 - if (!results[providerType] || results[providerType].length === 0) { - const usage = await handler(null); - results[providerType] = [{ uuid: 'default', usage }]; - } - } catch (error) { - results[providerType] = [{ uuid: 'default', error: error.message }]; - } - } - - return results; - } - - /** - * 获取 Kiro 提供商的用量信息 - * @param {string} [uuid] - 可选的提供商实例 UUID - * @returns {Promise} Kiro 用量信息 - */ - async getKiroUsage(uuid = null) { - const providerKey = uuid ? MODEL_PROVIDER.KIRO_API + uuid : MODEL_PROVIDER.KIRO_API; - const adapter = serviceInstances[providerKey]; - - if (!adapter) { - throw new Error(`Kiro 服务实例未找到: ${providerKey}`); - } - - // 使用适配器的 getUsageLimits 方法 - if (typeof adapter.getUsageLimits === 'function') { - return adapter.getUsageLimits(); - } - - // 兼容直接访问 kiroApiService 的情况 - if (adapter.kiroApiService && typeof adapter.kiroApiService.getUsageLimits === 'function') { - return adapter.kiroApiService.getUsageLimits(); - } - - throw new Error(`Kiro 服务实例不支持用量查询: ${providerKey}`); - } - - /** - * 获取 Gemini CLI 提供商的用量信息 - * @param {string} [uuid] - 可选的提供商实例 UUID - * @returns {Promise} Gemini 用量信息 - */ - async getGeminiUsage(uuid = null) { - const providerKey = uuid ? MODEL_PROVIDER.GEMINI_CLI + uuid : MODEL_PROVIDER.GEMINI_CLI; - const adapter = serviceInstances[providerKey]; - - if (!adapter) { - throw new Error(`Gemini CLI 服务实例未找到: ${providerKey}`); - } - - // 使用适配器的 getUsageLimits 方法 - if (typeof adapter.getUsageLimits === 'function') { - return adapter.getUsageLimits(); - } - - // 兼容直接访问 geminiApiService 的情况 - if (adapter.geminiApiService && typeof adapter.geminiApiService.getUsageLimits === 'function') { - return adapter.geminiApiService.getUsageLimits(); - } - - throw new Error(`Gemini CLI 服务实例不支持用量查询: ${providerKey}`); - } - - /** - * 获取 Antigravity 提供商的用量信息 - * @param {string} [uuid] - 可选的提供商实例 UUID - * @returns {Promise} Antigravity 用量信息 - */ - async getAntigravityUsage(uuid = null) { - const providerKey = uuid ? MODEL_PROVIDER.ANTIGRAVITY + uuid : MODEL_PROVIDER.ANTIGRAVITY; - const adapter = serviceInstances[providerKey]; - - if (!adapter) { - throw new Error(`Antigravity 服务实例未找到: ${providerKey}`); - } - - // 使用适配器的 getUsageLimits 方法 - if (typeof adapter.getUsageLimits === 'function') { - return adapter.getUsageLimits(); - } - - // 兼容直接访问 antigravityApiService 的情况 - if (adapter.antigravityApiService && typeof adapter.antigravityApiService.getUsageLimits === 'function') { - return adapter.antigravityApiService.getUsageLimits(); - } - - throw new Error(`Antigravity 服务实例不支持用量查询: ${providerKey}`); - } - - /** - * 获取 Codex 提供商的用量信息 - * @param {string} [uuid] - 可选的提供商实例 UUID - * @returns {Promise} Codex 用量信息 - */ - async getCodexUsage(uuid = null) { - const providerKey = uuid ? MODEL_PROVIDER.CODEX_API + uuid : MODEL_PROVIDER.CODEX_API; - const adapter = serviceInstances[providerKey]; - - if (!adapter) { - throw new Error(`Codex 服务实例未找到: ${providerKey}`); - } - - // 使用适配器的 getUsageLimits 方法 - if (typeof adapter.getUsageLimits === 'function') { - return adapter.getUsageLimits(); - } - - // 兼容直接访问 codexApiService 的情况 - if (adapter.codexApiService && typeof adapter.codexApiService.getUsageLimits === 'function') { - return adapter.codexApiService.getUsageLimits(); - } - - throw new Error(`Codex 服务实例不支持用量查询: ${providerKey}`); - } - - /** - * 获取 Grok 提供商的用量信息 - * @param {string} [uuid] - 可选的提供商实例 UUID - * @returns {Promise} Grok 用量信息 - */ - async getGrokUsage(uuid = null) { - const providerKey = uuid ? MODEL_PROVIDER.GROK_CUSTOM + uuid : MODEL_PROVIDER.GROK_CUSTOM; - const adapter = serviceInstances[providerKey]; - - if (!adapter) { - throw new Error(`Grok 服务实例未找到: ${providerKey}`); - } - - // 使用适配器的 getUsageLimits 方法 - if (typeof adapter.getUsageLimits === 'function') { - const rawUsage = await adapter.getUsageLimits(); - return formatGrokUsage(rawUsage); - } - - throw new Error(`Grok 服务实例不支持用量查询: ${providerKey}`); - } - - /** - * 获取支持用量查询的提供商列表 - - * @returns {Array} 支持的提供商类型列表 - */ - getSupportedProviders() { - return Object.keys(this.providerHandlers); - } -} - -// 导出单例实例 -export const usageService = new UsageService(); - -/** - * 格式化 Kiro 用量信息为易读格式 - * @param {Object} usageData - 原始用量数据 - * @returns {Object} 格式化后的用量信息 - */ -export function formatKiroUsage(usageData) { - if (!usageData) { - return null; - } - - const result = { - // 基本信息 - daysUntilReset: usageData.daysUntilReset, - nextDateReset: usageData.nextDateReset ? new Date(usageData.nextDateReset * 1000).toISOString() : null, - - // 订阅信息 - subscription: null, - - // 用户信息 - user: null, - - // 用量明细 - usageBreakdown: [] - }; - - // 解析订阅信息 - if (usageData.subscriptionInfo) { - result.subscription = { - title: usageData.subscriptionInfo.subscriptionTitle, - type: usageData.subscriptionInfo.type, - upgradeCapability: usageData.subscriptionInfo.upgradeCapability, - overageCapability: usageData.subscriptionInfo.overageCapability - }; - } - - // 解析用户信息 - if (usageData.userInfo) { - result.user = { - email: usageData.userInfo.email, - userId: usageData.userInfo.userId - }; - } - - // 解析用量明细 - if (usageData.usageBreakdownList && Array.isArray(usageData.usageBreakdownList)) { - for (const breakdown of usageData.usageBreakdownList) { - const item = { - resourceType: breakdown.resourceType, - displayName: breakdown.displayName, - displayNamePlural: breakdown.displayNamePlural, - unit: breakdown.unit, - currency: breakdown.currency, - - // 当前用量 - currentUsage: breakdown.currentUsageWithPrecision ?? breakdown.currentUsage, - usageLimit: breakdown.usageLimitWithPrecision ?? breakdown.usageLimit, - - // 超额信息 - currentOverages: breakdown.currentOveragesWithPrecision ?? breakdown.currentOverages, - overageCap: breakdown.overageCapWithPrecision ?? breakdown.overageCap, - overageRate: breakdown.overageRate, - overageCharges: breakdown.overageCharges, - - // 下次重置时间 - nextDateReset: breakdown.nextDateReset ? new Date(breakdown.nextDateReset * 1000).toISOString() : null, - - // 免费试用信息 - freeTrial: null, - - // 奖励信息 - bonuses: [] - }; - - // 解析免费试用信息 - if (breakdown.freeTrialInfo) { - item.freeTrial = { - status: breakdown.freeTrialInfo.freeTrialStatus, - currentUsage: breakdown.freeTrialInfo.currentUsageWithPrecision ?? breakdown.freeTrialInfo.currentUsage, - usageLimit: breakdown.freeTrialInfo.usageLimitWithPrecision ?? breakdown.freeTrialInfo.usageLimit, - expiresAt: breakdown.freeTrialInfo.freeTrialExpiry - ? new Date(breakdown.freeTrialInfo.freeTrialExpiry * 1000).toISOString() - : null - }; - } - - // 解析奖励信息 - if (breakdown.bonuses && Array.isArray(breakdown.bonuses)) { - for (const bonus of breakdown.bonuses) { - item.bonuses.push({ - code: bonus.bonusCode, - displayName: bonus.displayName, - description: bonus.description, - status: bonus.status, - currentUsage: bonus.currentUsage, - usageLimit: bonus.usageLimit, - redeemedAt: bonus.redeemedAt ? new Date(bonus.redeemedAt * 1000).toISOString() : null, - expiresAt: bonus.expiresAt ? new Date(bonus.expiresAt * 1000).toISOString() : null - }); - } - } - - result.usageBreakdown.push(item); - } - } - - return result; -} - -/** - * 格式化 Gemini 用量信息为易读格式(映射到 Kiro 数据结构) - * @param {Object} usageData - 原始用量数据 - * @returns {Object} 格式化后的用量信息 - */ -export function formatGeminiUsage(usageData) { - if (!usageData) { - return null; - } - - const result = { - // 基本信息 - 映射到 Kiro 结构 - daysUntilReset: null, - nextDateReset: null, - - // 订阅信息 - subscription: { - title: 'Gemini CLI OAuth', - type: 'gemini-cli-oauth', - upgradeCapability: null, - overageCapability: null - }, - - // 用户信息 - user: { - email: null, - userId: null - }, - - // 用量明细 - usageBreakdown: [] - }; - - // 解析配额信息 - if (usageData.quotaInfo) { - result.subscription.title = usageData.quotaInfo.currentTier || 'Gemini CLI OAuth'; - if (usageData.quotaInfo.quotaResetTime) { - result.nextDateReset = usageData.quotaInfo.quotaResetTime; - // 计算距离重置的天数 - const resetDate = new Date(usageData.quotaInfo.quotaResetTime); - const now = new Date(); - const diffTime = resetDate.getTime() - now.getTime(); - result.daysUntilReset = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); - } - } - - // 解析模型配额信息 - if (usageData.models && typeof usageData.models === 'object') { - for (const [modelKey, modelInfo] of Object.entries(usageData.models)) { - // Gemini 返回的数据结构:{ remaining, resetTime, resetTimeRaw, tokenType } - // remaining 是 0-1 之间的比例值,表示剩余配额百分比 - const remainingPercent = typeof modelInfo.remaining === 'number' ? modelInfo.remaining : 1; - const usedPercent = 1 - remainingPercent; - - // 解析 modelKey (modelId:tokenType) - const [modelId, tokenType] = modelKey.split(':'); - const displayName = tokenType ? `${modelId} (${tokenType})` : modelId; - - const item = { - resourceType: 'MODEL_USAGE', - displayName: displayName, - displayNamePlural: displayName, - unit: 'quota', - currency: null, - - // 当前用量 - Gemini 返回的是剩余比例,转换为已用比例(百分比形式) - currentUsage: Math.round(usedPercent * 100), - usageLimit: 100, // 以百分比表示,总量为 100% - - // 超额信息 - currentOverages: 0, - overageCap: 0, - overageRate: null, - overageCharges: 0, - - // 下次重置时间 - nextDateReset: modelInfo.resetTimeRaw ? new Date(modelInfo.resetTimeRaw).toISOString() : - (modelInfo.resetTime ? new Date(modelInfo.resetTime).toISOString() : null), - - // 免费试用信息 - freeTrial: null, - - // 奖励信息 - bonuses: [], - - // 额外的 Gemini 特有信息 - modelName: modelId, - tokenType: tokenType, - inputTokenLimit: modelInfo.inputTokenLimit || 0, - outputTokenLimit: modelInfo.outputTokenLimit || 0, - remaining: remainingPercent, - remainingPercent: Math.round(remainingPercent * 100), // 剩余百分比 - resetTime: modelInfo.resetTime || '--', - resetTimeRaw: modelInfo.resetTimeRaw || modelInfo.resetTime || null - }; - - result.usageBreakdown.push(item); - } - } - - return result; -} - -/** - * 格式化 Antigravity 用量信息为易读格式(映射到 Kiro 数据结构) - * @param {Object} usageData - 原始用量数据 - * @returns {Object} 格式化后的用量信息 - */ -export function formatAntigravityUsage(usageData) { - if (!usageData) { - return null; - } - - const result = { - // 基本信息 - 映射到 Kiro 结构 - daysUntilReset: null, - nextDateReset: null, - - // 订阅信息 - subscription: { - title: 'Gemini Antigravity', - type: 'gemini-antigravity', - upgradeCapability: null, - overageCapability: null - }, - - // 用户信息 - user: { - email: null, - userId: null - }, - - // 用量明细 - usageBreakdown: [] - }; - - // 解析配额信息 - if (usageData.quotaInfo) { - result.subscription.title = usageData.quotaInfo.currentTier || 'Gemini Antigravity'; - if (usageData.quotaInfo.quotaResetTime) { - result.nextDateReset = usageData.quotaInfo.quotaResetTime; - // 计算距离重置的天数 - const resetDate = new Date(usageData.quotaInfo.quotaResetTime); - const now = new Date(); - const diffTime = resetDate.getTime() - now.getTime(); - result.daysUntilReset = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); - } - } - - // 解析模型配额信息 - if (usageData.models && typeof usageData.models === 'object') { - for (const [modelName, modelInfo] of Object.entries(usageData.models)) { - // Antigravity 返回的数据结构:{ remaining, resetTime, resetTimeRaw } - // remaining 是 0-1 之间的比例值,表示剩余配额百分比 - const remainingPercent = typeof modelInfo.remaining === 'number' ? modelInfo.remaining : 1; - const usedPercent = 1 - remainingPercent; - - // 优先使用模型自己的重置时间,如果没有则使用全局重置时间 - const resetTimeRaw = modelInfo.resetTimeRaw || (usageData.quotaInfo ? usageData.quotaInfo.quotaResetTime : null); - const resetTimeFormatted = modelInfo.resetTime || '--'; - - const item = { - resourceType: 'MODEL_USAGE', - displayName: modelInfo.displayName || modelName, - displayNamePlural: modelInfo.displayName || modelName, - unit: 'quota', - currency: null, - - // 当前用量 - Antigravity 返回的是剩余比例,转换为已用比例(百分比形式) - currentUsage: Math.round(usedPercent * 100 * 100) / 100, - usageLimit: 100, // 以百分比表示,总量为 100% - - // 超额信息 - currentOverages: 0, - overageCap: 0, - overageRate: null, - overageCharges: 0, - - // 下次重置时间 - nextDateReset: resetTimeRaw ? (typeof resetTimeRaw === 'number' ? new Date(resetTimeRaw * 1000).toISOString() : new Date(resetTimeRaw).toISOString()) : null, - - // 免费试用信息 - freeTrial: null, - - // 奖励信息 - bonuses: [], - - // 额外的 Antigravity 特有信息 - modelName: modelName, - inputTokenLimit: modelInfo.inputTokenLimit || 0, - outputTokenLimit: modelInfo.outputTokenLimit || 0, - remaining: remainingPercent, - remainingPercent: Math.round(remainingPercent * 100 * 100) / 100, // 剩余百分比 - resetTime: resetTimeFormatted, - resetTimeRaw: resetTimeRaw - }; - - result.usageBreakdown.push(item); - } - } - - return result; -} - -/** - * 格式化 Grok 用量信息为易读格式(映射到 Kiro 数据结构) - * @param {Object} usageData - 原始用量数据 - * @returns {Object} 格式化后的用量信息 - */ -export function formatGrokUsage(usageData) { - if (!usageData) { - return null; - } - - const result = { - // 基本信息 - 映射到 Kiro 结构 - daysUntilReset: null, - nextDateReset: null, - - // 订阅信息 - subscription: { - title: 'Grok Custom', - type: 'grok-custom', - upgradeCapability: null, - overageCapability: null - }, - - // 用户信息 - user: { - email: null, - userId: null - }, - - // 用量明细 - usageBreakdown: [] - }; - - // Grok 返回的数据结构已在 core 中预处理:{ remainingTokens, remainingQueries, totalQueries, totalLimit, usedQueries, unit, ... } - if (usageData.totalLimit !== undefined && usageData.usedQueries !== undefined) { - const isTokens = usageData.unit === 'tokens'; - const item = { - resourceType: 'TOKEN_USAGE', - displayName: isTokens ? 'Remaining Tokens' : 'Remaining Queries', - displayNamePlural: isTokens ? 'Remaining Tokens' : 'Remaining Queries', - unit: usageData.unit || 'queries', - currency: null, - - // 使用从 core 传出的计算好的值 - currentUsage: usageData.usedQueries, - usageLimit: usageData.totalLimit, - - nextDateReset: null, - freeTrial: null, - bonuses: [] - }; - - result.usageBreakdown.push(item); - } else if (usageData.remainingTokens !== undefined) { - const item = { - resourceType: 'TOKEN_USAGE', - displayName: 'Remaining Tokens', - displayNamePlural: 'Remaining Tokens', - unit: 'tokens', - currency: null, - - currentUsage: 0, - usageLimit: usageData.remainingTokens, - - nextDateReset: null, - freeTrial: null, - bonuses: [] - }; - - result.usageBreakdown.push(item); - } - - return result; -} - -/* - * @param {Object} usageData - 原始用量数据 - * @returns {Object} 格式化后的用量信息 - */ -export function formatCodexUsage(usageData) { - if (!usageData) { - return null; - } - - const result = { - // 基本信息 - 映射到 Kiro 结构 - daysUntilReset: null, - nextDateReset: null, - - // 订阅信息 - subscription: { - title: usageData.raw?.planType ? `Codex (${usageData.raw.planType})` : 'Codex OAuth', - type: 'openai-codex-oauth', - upgradeCapability: null, - overageCapability: null - }, - - // 用户信息 - user: { - email: null, - userId: null - }, - - // 用量明细 - usageBreakdown: [] - }; - - // 从 raw.rateLimit 提取重置时间 - if (usageData.raw?.rateLimit?.primaryWindow?.resetAt) { - const resetTimestamp = usageData.raw.rateLimit.primaryWindow.resetAt; - result.nextDateReset = new Date(resetTimestamp * 1000).toISOString(); - // 计算距离重置的天数 - const resetDate = new Date(resetTimestamp * 1000); - const now = new Date(); - const diffTime = resetDate.getTime() - now.getTime(); - result.daysUntilReset = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); - } - - // 解析模型配额信息 - if (usageData.models && typeof usageData.models === 'object') { - for (const [modelName, modelInfo] of Object.entries(usageData.models)) { - // Codex 返回的数据结构:{ remaining, resetTime, resetTimeRaw } - // remaining 是 0-1 之间的比例值,表示剩余配额百分比 - const remainingPercent = typeof modelInfo.remaining === 'number' ? modelInfo.remaining : 1; - const usedPercent = 1 - remainingPercent; - - const item = { - resourceType: 'MODEL_USAGE', - displayName: modelInfo.displayName || modelName, - displayNamePlural: modelInfo.displayName || modelName, - unit: 'quota', - currency: null, - - // 当前用量 - Codex 返回的是剩余比例,转换为已用比例(百分比形式) - currentUsage: Math.round(usedPercent * 100), - usageLimit: 100, // 以百分比表示,总量为 100% - - // 超额信息 - currentOverages: 0, - overageCap: 0, - overageRate: null, - overageCharges: 0, - - // 下次重置时间 - nextDateReset: modelInfo.resetTimeRaw ? new Date(modelInfo.resetTimeRaw * 1000).toISOString() : - (modelInfo.resetTime ? new Date(modelInfo.resetTime).toISOString() : null), - - // 免费试用信息 - freeTrial: null, - - // 奖励信息 - bonuses: [], - - // 额外的 Codex 特有信息 - modelName: modelName, - remaining: remainingPercent, - remainingPercent: Math.round(remainingPercent * 100), // 剩余百分比 - resetTime: modelInfo.resetTime || '--', - resetTimeRaw: modelInfo.resetTimeRaw || modelInfo.resetTime || null, - - // 注入 raw 窗口信息以便前端使用 - rateLimit: usageData.raw?.rateLimit - }; - - result.usageBreakdown.push(item); - } - } - - return result; -} diff --git a/src/ui-modules/auth.js b/src/ui-modules/auth.js deleted file mode 100644 index df3a6a6204a8f72792d8e2a1d59c802e5af2978d..0000000000000000000000000000000000000000 --- a/src/ui-modules/auth.js +++ /dev/null @@ -1,401 +0,0 @@ -import { existsSync } from 'fs'; -import logger from '../utils/logger.js'; -import { promises as fs } from 'fs'; -import path from 'path'; -import crypto from 'crypto'; -import { CONFIG } from '../core/config-manager.js'; -import { getClientIp } from '../utils/common.js'; - -// Token存储到本地文件中 -const TOKEN_STORE_FILE = path.join(process.cwd(), 'configs', 'token-store.json'); - -/** - * 默认密码(当pwd文件不存在时使用) - */ -const DEFAULT_PASSWORD = 'admin123'; - -/** - * 读取密码文件内容 - * 如果文件不存在或读取失败,返回默认密码 - */ -export async function readPasswordFile() { - const pwdFilePath = path.join(process.cwd(), 'configs', 'pwd'); - try { - // 使用异步方式检查文件是否存在并读取,避免竞态条件 - const password = await fs.readFile(pwdFilePath, 'utf8'); - const trimmedPassword = password.trim(); - // 如果密码文件为空,使用默认密码 - if (!trimmedPassword) { - logger.info('[Auth] Password file is empty, using default password: ' + DEFAULT_PASSWORD); - return DEFAULT_PASSWORD; - } - logger.info('[Auth] Successfully read password file'); - return trimmedPassword; - } catch (error) { - // ENOENT means file does not exist, which is normal - if (error.code === 'ENOENT') { - logger.info('[Auth] Password file does not exist, using default password: ' + DEFAULT_PASSWORD); - } else { - logger.error('[Auth] Failed to read password file:', error.code || error.message); - logger.info('[Auth] Using default password: ' + DEFAULT_PASSWORD); - } - return DEFAULT_PASSWORD; - } -} - -/** - * 验证登录凭据 - */ -export async function validateCredentials(password) { - const storedPassword = await readPasswordFile(); - logger.info('[Auth] Validating password, stored password length:', storedPassword ? storedPassword.length : 0, ', input password length:', password ? password.length : 0); - const isValid = storedPassword && password === storedPassword; - logger.info('[Auth] Password validation result:', isValid); - return isValid; -} - -/** - * 解析请求体JSON - */ -function parseRequestBody(req) { - return new Promise((resolve, reject) => { - let body = ''; - req.on('data', chunk => { - body += chunk.toString(); - }); - req.on('end', () => { - try { - if (!body.trim()) { - resolve({}); - } else { - resolve(JSON.parse(body)); - } - } catch (error) { - reject(new Error('Invalid JSON format')); - } - }); - req.on('error', reject); - }); -} - -/** - * 生成简单的token - */ -function generateToken() { - return crypto.randomBytes(32).toString('hex'); -} - - /** - * 生成token过期时间 - */ -function getExpiryTime() { - const now = Date.now(); - const expiry = (CONFIG.LOGIN_EXPIRY || 3600) * 1000; // 使用配置的过期时间,默认1小时 - return now + expiry; -} - - -/** - * 读取token存储文件 - */ -async function readTokenStore() { - try { - if (existsSync(TOKEN_STORE_FILE)) { - const content = await fs.readFile(TOKEN_STORE_FILE, 'utf8'); - return JSON.parse(content); - } else { - // 如果文件不存在,创建一个默认的token store - await writeTokenStore({ tokens: {} }); - return { tokens: {} }; - } - } catch (error) { - logger.error('[Token Store] Failed to read token store file:', error); - return { tokens: {} }; - } -} - -/** - * 写入token存储文件 - */ -async function writeTokenStore(tokenStore) { - try { - await fs.writeFile(TOKEN_STORE_FILE, JSON.stringify(tokenStore, null, 2), 'utf8'); - } catch (error) { - logger.error('[Token Store] Failed to write token store file:', error); - } -} - -/** - * 验证简单token - */ -export async function verifyToken(token) { - const tokenStore = await readTokenStore(); - const tokenInfo = tokenStore.tokens[token]; - if (!tokenInfo) { - return null; - } - - // 检查是否过期 - if (Date.now() > tokenInfo.expiryTime) { - await deleteToken(token); - return null; - } - - return tokenInfo; -} - -/** - * 保存token到本地文件 - */ -async function saveToken(token, tokenInfo) { - const tokenStore = await readTokenStore(); - tokenStore.tokens[token] = tokenInfo; - await writeTokenStore(tokenStore); -} - -/** - * 删除token - */ -async function deleteToken(token) { - const tokenStore = await readTokenStore(); - if (tokenStore.tokens[token]) { - delete tokenStore.tokens[token]; - await writeTokenStore(tokenStore); - } -} - -/** - * 管理登录尝试频率和锁定 - */ -class LoginAttemptManager { - constructor() { - this.attempts = new Map(); // IP -> { count, lastAttempt, lockoutUntil } - } - - /** - * 获取 IP 的状态 - */ - getIpStatus(ip) { - if (!this.attempts.has(ip)) { - this.attempts.set(ip, { count: 0, lastAttempt: 0, lockoutUntil: 0 }); - } - return this.attempts.get(ip); - } - - /** - * 检查是否被锁定 - */ - isLockedOut(ip) { - const status = this.getIpStatus(ip); - if (status.lockoutUntil > Date.now()) { - return { - locked: true, - remainingTime: Math.ceil((status.lockoutUntil - Date.now()) / 1000) - }; - } - // 如果锁定时间已过,重置失败次数 - if (status.lockoutUntil > 0 && status.lockoutUntil <= Date.now()) { - status.count = 0; - status.lockoutUntil = 0; - } - return { locked: false }; - } - - /** - * 检查是否请求过于频繁 - */ - isTooFrequent(ip) { - const status = this.getIpStatus(ip); - const minInterval = CONFIG.LOGIN_MIN_INTERVAL || 1000; - const now = Date.now(); - if (now - status.lastAttempt < minInterval) { - return true; - } - status.lastAttempt = now; - return false; - } - - /** - * 记录一次失败 - */ - recordFailure(ip) { - const status = this.getIpStatus(ip); - status.count++; - const maxAttempts = CONFIG.LOGIN_MAX_ATTEMPTS || 5; - const lockoutDuration = (CONFIG.LOGIN_LOCKOUT_DURATION || 1800) * 1000; - - if (status.count >= maxAttempts) { - status.lockoutUntil = Date.now() + lockoutDuration; - logger.warn(`[Auth] IP ${ip} locked out due to too many failed login attempts (${status.count})`); - return true; - } - return false; - } - - /** - * 成功后重置 - */ - reset(ip) { - this.attempts.delete(ip); - } -} - -const loginAttemptManager = new LoginAttemptManager(); - -/** - * 清理过期的token - */ -export async function cleanupExpiredTokens() { - const tokenStore = await readTokenStore(); - const now = Date.now(); - let hasChanges = false; - - for (const token in tokenStore.tokens) { - if (now > tokenStore.tokens[token].expiryTime) { - delete tokenStore.tokens[token]; - hasChanges = true; - } - } - - if (hasChanges) { - await writeTokenStore(tokenStore); - } -} - -/** - * 检查token验证 - */ -export async function checkAuth(req) { - const authHeader = req.headers.authorization; - - if (!authHeader || !authHeader.startsWith('Bearer ')) { - return false; - } - - const token = authHeader.substring(7); - const tokenInfo = await verifyToken(token); - - return tokenInfo !== null; -} - -/** - * 处理登录请求 - */ -export async function handleLoginRequest(req, res) { - if (req.method !== 'POST') { - res.writeHead(405, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: false, - message: 'Only POST requests are supported', - messageCode: 'login.error.postOnly' - })); - return true; - } - - const ip = getClientIp(req); - - // 1. 检查锁定状态 - const lockout = loginAttemptManager.isLockedOut(ip); - if (lockout.locked) { - logger.warn(`[Auth] Login attempt from locked IP: ${ip}, reason: account_locked, remaining: ${lockout.remainingTime}s`); - res.writeHead(429, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: false, - message: `Account temporarily locked due to too many failed attempts. Please try again in ${lockout.remainingTime} seconds.`, - messageCode: 'login.error.locked', - messageParams: { time: lockout.remainingTime } - })); - return true; - } - - // 2. 频率限制 - if (loginAttemptManager.isTooFrequent(ip)) { - logger.warn(`[Auth] Login attempt too frequent from IP: ${ip}, reason: rate_limit`); - res.writeHead(429, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: false, - message: 'Too many requests, please slow down.', - messageCode: 'login.error.tooFrequent' - })); - return true; - } - - try { - const requestData = await parseRequestBody(req); - const { password } = requestData; - - if (!password) { - logger.warn(`[Auth] Login failed from IP: ${ip}, reason: empty_password`); - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: false, - message: 'Password cannot be empty', - messageCode: 'login.error.empty' - })); - return true; - } - - const isValid = await validateCredentials(password); - - if (isValid) { - logger.info(`[Auth] Login successful from IP: ${ip}`); - // 登录成功,重置计数 - loginAttemptManager.reset(ip); - - // Generate simple token - const token = generateToken(); - const loginExpiry = CONFIG.LOGIN_EXPIRY || 3600; - const expiryTime = Date.now() + (loginExpiry * 1000); - - // Store token info to local file - await saveToken(token, { - username: 'admin', - loginTime: Date.now(), - expiryTime - }); - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: true, - message: 'Login successful', - token, - expiresIn: `${loginExpiry} seconds` - })); - } else { - // 登录失败,记录 - const isLocked = loginAttemptManager.recordFailure(ip); - const status = loginAttemptManager.getIpStatus(ip); - const maxAttempts = CONFIG.LOGIN_MAX_ATTEMPTS || 5; - const remaining = maxAttempts - status.count; - const lockoutDuration = CONFIG.LOGIN_LOCKOUT_DURATION || 1800; - - logger.warn(`[Auth] Login failed from IP: ${ip}, reason: incorrect_password, remaining_attempts: ${Math.max(0, remaining)}${isLocked ? ', result: locked' : ''}`); - - res.writeHead(401, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: false, - message: isLocked - ? `Incorrect password. Account locked for ${Math.ceil(lockoutDuration / 60)} minutes.` - : `Incorrect password. ${remaining} attempts remaining.`, - messageCode: isLocked ? 'login.error.incorrectWithLock' : 'login.error.incorrectWithRemaining', - messageParams: isLocked ? { time: Math.ceil(lockoutDuration / 60) } : { count: remaining } - })); - } - - } catch (error) { - logger.error('[Auth] Login processing error:', error); - const isJsonError = error.message === 'Invalid JSON format'; - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: false, - message: error.message || 'Server error', - messageCode: isJsonError ? 'login.error.invalidJson' : undefined - })); - } - return true; -} - -// 定时清理过期token -setInterval(cleanupExpiredTokens, 5 * 60 * 1000); // 每5分钟清理一次 - - diff --git a/src/ui-modules/config-api.js b/src/ui-modules/config-api.js deleted file mode 100644 index eb43add7227eb23e246418f28fe525ac138c9252..0000000000000000000000000000000000000000 --- a/src/ui-modules/config-api.js +++ /dev/null @@ -1,299 +0,0 @@ -import { existsSync, readFileSync, writeFileSync } from 'fs'; -import logger from '../utils/logger.js'; -import { promises as fs } from 'fs'; -import path from 'path'; -import { CONFIG } from '../core/config-manager.js'; -import { serviceInstances } from '../providers/adapter.js'; -import { initApiService } from '../services/service-manager.js'; -import { getRequestBody } from '../utils/common.js'; -import { broadcastEvent } from '../ui-modules/event-broadcast.js'; - -/** - * 重载配置文件 - * 动态导入config-manager并重新初始化配置 - * @returns {Promise} 返回重载后的配置对象 - */ -export async function reloadConfig(providerPoolManager) { - try { - // Import config manager dynamically - const { initializeConfig } = await import('../core/config-manager.js'); - - // Reload main config - const newConfig = await initializeConfig(process.argv.slice(2), 'configs/config.json'); - // Update provider pool manager if available - if (providerPoolManager) { - providerPoolManager.providerPools = newConfig.providerPools; - providerPoolManager.initializeProviderStatus(); - } - - // Update global CONFIG - Object.assign(CONFIG, newConfig); - logger.info('[UI API] Configuration reloaded:'); - - // Update initApiService - 清空并重新初始化服务实例 - Object.keys(serviceInstances).forEach(key => delete serviceInstances[key]); - initApiService(CONFIG); - - logger.info('[UI API] Configuration reloaded successfully'); - - return newConfig; - } catch (error) { - logger.error('[UI API] Failed to reload configuration:', error); - throw error; - } -} - -/** - * 获取配置 - */ -export async function handleGetConfig(req, res, currentConfig) { - let systemPrompt = ''; - - if (currentConfig.SYSTEM_PROMPT_FILE_PATH && existsSync(currentConfig.SYSTEM_PROMPT_FILE_PATH)) { - try { - systemPrompt = readFileSync(currentConfig.SYSTEM_PROMPT_FILE_PATH, 'utf-8'); - } catch (e) { - logger.warn('[UI API] Failed to read system prompt file:', e.message); - } - } - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - ...currentConfig, - systemPrompt - })); - return true; -} - -/** - * 更新配置 - */ -export async function handleUpdateConfig(req, res, currentConfig) { - try { - const body = await getRequestBody(req); - const newConfig = body; - - // Update config values in memory - if (newConfig.REQUIRED_API_KEY !== undefined) currentConfig.REQUIRED_API_KEY = newConfig.REQUIRED_API_KEY; - if (newConfig.HOST !== undefined) currentConfig.HOST = newConfig.HOST; - if (newConfig.SERVER_PORT !== undefined) currentConfig.SERVER_PORT = newConfig.SERVER_PORT; - if (newConfig.MODEL_PROVIDER !== undefined) currentConfig.MODEL_PROVIDER = newConfig.MODEL_PROVIDER; - if (newConfig.SYSTEM_PROMPT_FILE_PATH !== undefined) currentConfig.SYSTEM_PROMPT_FILE_PATH = newConfig.SYSTEM_PROMPT_FILE_PATH; - if (newConfig.SYSTEM_PROMPT_MODE !== undefined) currentConfig.SYSTEM_PROMPT_MODE = newConfig.SYSTEM_PROMPT_MODE; - if (newConfig.PROMPT_LOG_BASE_NAME !== undefined) currentConfig.PROMPT_LOG_BASE_NAME = newConfig.PROMPT_LOG_BASE_NAME; - if (newConfig.PROMPT_LOG_MODE !== undefined) currentConfig.PROMPT_LOG_MODE = newConfig.PROMPT_LOG_MODE; - if (newConfig.REQUEST_MAX_RETRIES !== undefined) currentConfig.REQUEST_MAX_RETRIES = newConfig.REQUEST_MAX_RETRIES; - if (newConfig.REQUEST_BASE_DELAY !== undefined) currentConfig.REQUEST_BASE_DELAY = newConfig.REQUEST_BASE_DELAY; - if (newConfig.CREDENTIAL_SWITCH_MAX_RETRIES !== undefined) currentConfig.CREDENTIAL_SWITCH_MAX_RETRIES = newConfig.CREDENTIAL_SWITCH_MAX_RETRIES; - if (newConfig.CRON_NEAR_MINUTES !== undefined) currentConfig.CRON_NEAR_MINUTES = newConfig.CRON_NEAR_MINUTES; - if (newConfig.CRON_REFRESH_TOKEN !== undefined) currentConfig.CRON_REFRESH_TOKEN = newConfig.CRON_REFRESH_TOKEN; - if (newConfig.LOGIN_EXPIRY !== undefined) currentConfig.LOGIN_EXPIRY = newConfig.LOGIN_EXPIRY; - if (newConfig.PROVIDER_POOLS_FILE_PATH !== undefined) currentConfig.PROVIDER_POOLS_FILE_PATH = newConfig.PROVIDER_POOLS_FILE_PATH; - if (newConfig.MAX_ERROR_COUNT !== undefined) currentConfig.MAX_ERROR_COUNT = newConfig.MAX_ERROR_COUNT; - if (newConfig.WARMUP_TARGET !== undefined) currentConfig.WARMUP_TARGET = newConfig.WARMUP_TARGET; - if (newConfig.REFRESH_CONCURRENCY_PER_PROVIDER !== undefined) currentConfig.REFRESH_CONCURRENCY_PER_PROVIDER = newConfig.REFRESH_CONCURRENCY_PER_PROVIDER; - if (newConfig.providerFallbackChain !== undefined) currentConfig.providerFallbackChain = newConfig.providerFallbackChain; - if (newConfig.modelFallbackMapping !== undefined) currentConfig.modelFallbackMapping = newConfig.modelFallbackMapping; - - // Proxy settings - if (newConfig.PROXY_URL !== undefined) currentConfig.PROXY_URL = newConfig.PROXY_URL; - if (newConfig.PROXY_ENABLED_PROVIDERS !== undefined) currentConfig.PROXY_ENABLED_PROVIDERS = newConfig.PROXY_ENABLED_PROVIDERS; - - // TLS Sidecar settings - if (newConfig.TLS_SIDECAR_ENABLED !== undefined) currentConfig.TLS_SIDECAR_ENABLED = newConfig.TLS_SIDECAR_ENABLED; - if (newConfig.TLS_SIDECAR_ENABLED_PROVIDERS !== undefined) currentConfig.TLS_SIDECAR_ENABLED_PROVIDERS = newConfig.TLS_SIDECAR_ENABLED_PROVIDERS; - if (newConfig.TLS_SIDECAR_PORT !== undefined) currentConfig.TLS_SIDECAR_PORT = newConfig.TLS_SIDECAR_PORT; - if (newConfig.TLS_SIDECAR_PROXY_URL !== undefined) currentConfig.TLS_SIDECAR_PROXY_URL = newConfig.TLS_SIDECAR_PROXY_URL; - - // Log settings - if (newConfig.LOG_ENABLED !== undefined) currentConfig.LOG_ENABLED = newConfig.LOG_ENABLED; - if (newConfig.LOG_OUTPUT_MODE !== undefined) currentConfig.LOG_OUTPUT_MODE = newConfig.LOG_OUTPUT_MODE; - if (newConfig.LOG_LEVEL !== undefined) currentConfig.LOG_LEVEL = newConfig.LOG_LEVEL; - if (newConfig.LOG_DIR !== undefined) currentConfig.LOG_DIR = newConfig.LOG_DIR; - if (newConfig.LOG_INCLUDE_REQUEST_ID !== undefined) currentConfig.LOG_INCLUDE_REQUEST_ID = newConfig.LOG_INCLUDE_REQUEST_ID; - if (newConfig.LOG_INCLUDE_TIMESTAMP !== undefined) currentConfig.LOG_INCLUDE_TIMESTAMP = newConfig.LOG_INCLUDE_TIMESTAMP; - if (newConfig.LOG_MAX_FILE_SIZE !== undefined) currentConfig.LOG_MAX_FILE_SIZE = newConfig.LOG_MAX_FILE_SIZE; - if (newConfig.LOG_MAX_FILES !== undefined) currentConfig.LOG_MAX_FILES = newConfig.LOG_MAX_FILES; - - // Handle system prompt update - if (newConfig.systemPrompt !== undefined) { - const promptPath = currentConfig.SYSTEM_PROMPT_FILE_PATH || 'configs/input_system_prompt.txt'; - try { - const relativePath = path.relative(process.cwd(), promptPath); - writeFileSync(promptPath, newConfig.systemPrompt, 'utf-8'); - - // 广播更新事件 - broadcastEvent('config_update', { - action: 'update', - filePath: relativePath, - type: 'system_prompt', - timestamp: new Date().toISOString() - }); - - logger.info('[UI API] System prompt updated'); - } catch (e) { - logger.warn('[UI API] Failed to write system prompt:', e.message); - } - } - - // Update config.json file - try { - const configPath = 'configs/config.json'; - - // Create a clean config object for saving (exclude runtime-only properties) - const configToSave = { - REQUIRED_API_KEY: currentConfig.REQUIRED_API_KEY, - SERVER_PORT: currentConfig.SERVER_PORT, - HOST: currentConfig.HOST, - MODEL_PROVIDER: currentConfig.MODEL_PROVIDER, - SYSTEM_PROMPT_FILE_PATH: currentConfig.SYSTEM_PROMPT_FILE_PATH, - SYSTEM_PROMPT_MODE: currentConfig.SYSTEM_PROMPT_MODE, - PROMPT_LOG_BASE_NAME: currentConfig.PROMPT_LOG_BASE_NAME, - PROMPT_LOG_MODE: currentConfig.PROMPT_LOG_MODE, - REQUEST_MAX_RETRIES: currentConfig.REQUEST_MAX_RETRIES, - REQUEST_BASE_DELAY: currentConfig.REQUEST_BASE_DELAY, - CREDENTIAL_SWITCH_MAX_RETRIES: currentConfig.CREDENTIAL_SWITCH_MAX_RETRIES, - CRON_NEAR_MINUTES: currentConfig.CRON_NEAR_MINUTES, - CRON_REFRESH_TOKEN: currentConfig.CRON_REFRESH_TOKEN, - LOGIN_EXPIRY: currentConfig.LOGIN_EXPIRY, - PROVIDER_POOLS_FILE_PATH: currentConfig.PROVIDER_POOLS_FILE_PATH, - MAX_ERROR_COUNT: currentConfig.MAX_ERROR_COUNT, - WARMUP_TARGET: currentConfig.WARMUP_TARGET, - REFRESH_CONCURRENCY_PER_PROVIDER: currentConfig.REFRESH_CONCURRENCY_PER_PROVIDER, - providerFallbackChain: currentConfig.providerFallbackChain, - modelFallbackMapping: currentConfig.modelFallbackMapping, - PROXY_URL: currentConfig.PROXY_URL, - PROXY_ENABLED_PROVIDERS: currentConfig.PROXY_ENABLED_PROVIDERS, - LOG_ENABLED: currentConfig.LOG_ENABLED, - LOG_OUTPUT_MODE: currentConfig.LOG_OUTPUT_MODE, - LOG_LEVEL: currentConfig.LOG_LEVEL, - LOG_DIR: currentConfig.LOG_DIR, - LOG_INCLUDE_REQUEST_ID: currentConfig.LOG_INCLUDE_REQUEST_ID, - LOG_INCLUDE_TIMESTAMP: currentConfig.LOG_INCLUDE_TIMESTAMP, - LOG_MAX_FILE_SIZE: currentConfig.LOG_MAX_FILE_SIZE, - LOG_MAX_FILES: currentConfig.LOG_MAX_FILES, - TLS_SIDECAR_ENABLED: currentConfig.TLS_SIDECAR_ENABLED, - TLS_SIDECAR_ENABLED_PROVIDERS: currentConfig.TLS_SIDECAR_ENABLED_PROVIDERS, - TLS_SIDECAR_PORT: currentConfig.TLS_SIDECAR_PORT, - TLS_SIDECAR_PROXY_URL: currentConfig.TLS_SIDECAR_PROXY_URL - }; - - writeFileSync(configPath, JSON.stringify(configToSave, null, 2), 'utf-8'); - logger.info('[UI API] Configuration saved to configs/config.json'); - - // 广播更新事件 - broadcastEvent('config_update', { - action: 'update', - filePath: 'configs/config.json', - type: 'main_config', - timestamp: new Date().toISOString() - }); - } catch (error) { - logger.error('[UI API] Failed to save configuration to file:', error.message); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: 'Failed to save configuration to file: ' + error.message, - partial: true // Indicate that memory config was updated but not saved - } - })); - return true; - } - - // Update the global CONFIG object to reflect changes immediately - Object.assign(CONFIG, currentConfig); - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: true, - message: 'Configuration updated successfully', - details: 'Configuration has been updated in both memory and config.json file' - })); - return true; - } catch (error) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: error.message } })); - return true; - } -} - -/** - * 重载配置文件 - */ -export async function handleReloadConfig(req, res, providerPoolManager) { - try { - // 调用重载配置函数 - const newConfig = await reloadConfig(providerPoolManager); - - // 广播更新事件 - broadcastEvent('config_update', { - action: 'reload', - filePath: 'configs/config.json', - providerPoolsPath: newConfig.PROVIDER_POOLS_FILE_PATH || null, - timestamp: new Date().toISOString() - }); - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: true, - message: 'Configuration files reloaded successfully', - details: { - configReloaded: true, - configPath: 'configs/config.json', - providerPoolsPath: newConfig.PROVIDER_POOLS_FILE_PATH || null - } - })); - return true; - } catch (error) { - logger.error('[UI API] Failed to reload config files:', error); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: 'Failed to reload configuration files: ' + error.message - } - })); - return true; - } -} - -/** - * 更新管理员密码 - */ -export async function handleUpdateAdminPassword(req, res) { - try { - const body = await getRequestBody(req); - const { password } = body; - - if (!password || password.trim() === '') { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: 'Password cannot be empty' - } - })); - return true; - } - - // 写入密码到 pwd 文件 - const pwdFilePath = path.join(process.cwd(), 'configs', 'pwd'); - await fs.writeFile(pwdFilePath, password.trim(), 'utf-8'); - - logger.info('[UI API] Admin password updated successfully'); - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: true, - message: 'Admin password updated successfully' - })); - return true; - } catch (error) { - logger.error('[UI API] Failed to update admin password:', error); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: 'Failed to update password: ' + error.message - } - })); - return true; - } -} \ No newline at end of file diff --git a/src/ui-modules/config-scanner.js b/src/ui-modules/config-scanner.js deleted file mode 100644 index ea4358727d0e8a34e4420b332a8083f8d7c0763a..0000000000000000000000000000000000000000 --- a/src/ui-modules/config-scanner.js +++ /dev/null @@ -1,438 +0,0 @@ -import { existsSync } from 'fs'; -import logger from '../utils/logger.js'; -import { promises as fs } from 'fs'; -import path from 'path'; -import { addToUsedPaths, isPathUsed, pathsEqual } from '../utils/provider-utils.js'; - -/** - * 扫描和分析配置文件 - * @param {Object} currentConfig - The current configuration object - * @param {Object} providerPoolManager - Provider pool manager instance - * @returns {Promise} Array of configuration file objects - */ -export async function scanConfigFiles(currentConfig, providerPoolManager) { - const configFiles = []; - - // 只扫描configs目录 - const configsPath = path.join(process.cwd(), 'configs'); - - if (!existsSync(configsPath)) { - // logger.info('[Config Scanner] configs directory not found, creating empty result'); - return configFiles; - } - - const usedPaths = new Set(); // 存储已使用的路径,用于判断关联状态 - - // 从配置中提取所有OAuth凭据文件路径 - 标准化路径格式 - addToUsedPaths(usedPaths, currentConfig.GEMINI_OAUTH_CREDS_FILE_PATH); - addToUsedPaths(usedPaths, currentConfig.KIRO_OAUTH_CREDS_FILE_PATH); - addToUsedPaths(usedPaths, currentConfig.QWEN_OAUTH_CREDS_FILE_PATH); - addToUsedPaths(usedPaths, currentConfig.ANTIGRAVITY_OAUTH_CREDS_FILE_PATH); - addToUsedPaths(usedPaths, currentConfig.IFLOW_TOKEN_FILE_PATH); - addToUsedPaths(usedPaths, currentConfig.CODEX_OAUTH_CREDS_FILE_PATH); - - // 使用最新的提供商池数据 - let providerPools = currentConfig.providerPools; - if (providerPoolManager && providerPoolManager.providerPools) { - providerPools = providerPoolManager.providerPools; - } - - // 检查提供商池文件中的所有OAuth凭据路径 - 标准化路径格式 - if (providerPools) { - for (const [providerType, providers] of Object.entries(providerPools)) { - for (const provider of providers) { - addToUsedPaths(usedPaths, provider.GEMINI_OAUTH_CREDS_FILE_PATH); - addToUsedPaths(usedPaths, provider.KIRO_OAUTH_CREDS_FILE_PATH); - addToUsedPaths(usedPaths, provider.QWEN_OAUTH_CREDS_FILE_PATH); - addToUsedPaths(usedPaths, provider.ANTIGRAVITY_OAUTH_CREDS_FILE_PATH); - addToUsedPaths(usedPaths, provider.IFLOW_TOKEN_FILE_PATH); - addToUsedPaths(usedPaths, provider.CODEX_OAUTH_CREDS_FILE_PATH); - } - } - } - - try { - // 扫描configs目录下的所有子目录和文件 - const configsFiles = await scanOAuthDirectory(configsPath, usedPaths, currentConfig); - configFiles.push(...configsFiles); - } catch (error) { - logger.warn(`[Config Scanner] Failed to scan configs directory:`, error.message); - } - - return configFiles; -} - -/** - * 分析 OAuth 配置文件并返回元数据 - * @param {string} filePath - Full path to the file - * @param {Set} usedPaths - Set of paths currently in use - * @returns {Promise} OAuth file information object - */ -async function analyzeOAuthFile(filePath, usedPaths, currentConfig) { - try { - const stats = await fs.stat(filePath); - const ext = path.extname(filePath).toLowerCase(); - const filename = path.basename(filePath); - const relativePath = path.relative(process.cwd(), filePath); - - // 读取文件内容进行分析 - let content = ''; - let type = 'oauth'; // 默认为 oauth 类型 - let isValid = true; - let errorMessage = ''; - let oauthProvider = 'unknown'; - let usageInfo = getFileUsageInfo(relativePath, filename, usedPaths, currentConfig); - - // 从路径预检测提供商 - const normalizedPath = relativePath.replace(/\\/g, '/').toLowerCase(); - if (normalizedPath.includes('/kiro/')) oauthProvider = 'kiro'; - else if (normalizedPath.includes('/gemini/')) oauthProvider = 'gemini'; - else if (normalizedPath.includes('/qwen/')) oauthProvider = 'qwen'; - else if (normalizedPath.includes('/antigravity/')) oauthProvider = 'antigravity'; - else if (normalizedPath.includes('/codex/')) oauthProvider = 'codex'; - else if (normalizedPath.includes('/iflow/')) oauthProvider = 'iflow'; - - try { - content = await fs.readFile(filePath, 'utf8'); - - // 1. 首先尝试根据文件名识别特定类型的配置 (最高优先级) - const lowerFilename = filename.toLowerCase(); - if (lowerFilename === 'provider_pools.json' || lowerFilename === 'provider-pools.json') { - type = 'provider-pool'; - } else if (lowerFilename.includes('system_prompt') || lowerFilename.includes('system-prompt')) { - type = 'system-prompt'; - } else if (lowerFilename === 'plugins.json') { - type = 'plugins'; - } else if (lowerFilename === 'usage-cache.json') { - type = 'usage'; - } else if (lowerFilename === 'config.json') { - type = 'config'; - } else if (lowerFilename.includes('potluck-keys')) { - type = 'api-key'; - } else if (lowerFilename.includes('potluck-data')) { - type = 'database'; - } else if (lowerFilename === 'token-store.json') { - type = 'oauth'; - } - - // 2. 根据内容进一步识别和完善信息 - if (ext === '.json') { - try { - const jsonData = JSON.parse(content); - - // 如果文件名没识别出类型,尝试从内容识别 - if (type === 'oauth') { - if (jsonData.providerPools || jsonData.provider_pools) { - type = 'provider-pool'; - } else if (jsonData.apiKey || jsonData.api_key) { - type = 'api-key'; - } - } - - // 识别具体的提供商/认证方式 - if (jsonData.client_id || jsonData.client_secret) { - if (oauthProvider === 'unknown') oauthProvider = 'oauth2'; - } else if (jsonData.access_token || jsonData.refresh_token) { - if (oauthProvider === 'unknown') oauthProvider = 'token_based'; - } else if (jsonData.credentials) { - if (oauthProvider === 'unknown') oauthProvider = 'service_account'; - } else if (jsonData.apiKey || jsonData.api_key) { - if (oauthProvider === 'unknown') oauthProvider = 'api_key'; - } - - if (jsonData.base_url || jsonData.endpoint) { - const baseUrl = (jsonData.base_url || jsonData.endpoint).toLowerCase(); - if (baseUrl.includes('openai.com')) { - oauthProvider = 'openai'; - } else if (baseUrl.includes('anthropic.com')) { - oauthProvider = 'claude'; - } else if (baseUrl.includes('googleapis.com')) { - oauthProvider = 'gemini'; - } - } - } catch (jsonErr) { - isValid = false; - errorMessage = `JSON Parse Error: ${jsonErr.message}`; - } - } else { - // 处理非 JSON 文件 - if (ext === '.key' || ext === '.pem') { - if (content.includes('-----BEGIN') && content.includes('PRIVATE KEY-----')) { - oauthProvider = 'private_key'; - } - } else if (ext === '.txt') { - if (content.includes('api_key') || content.includes('apikey')) { - if (type === 'oauth') type = 'api-key'; - if (oauthProvider === 'unknown') oauthProvider = 'api_key'; - } - } else if (ext === '.oauth' || ext === '.creds') { - if (oauthProvider === 'unknown') oauthProvider = 'oauth_credentials'; - } - } - } catch (readError) { - isValid = false; - errorMessage = `Unable to read file: ${readError.message}`; - } - - return { - name: filename, - path: relativePath, - size: stats.size, - type: type, // 用于前端图标显示的关键字段 - provider: oauthProvider, - extension: ext, - modified: stats.mtime.toISOString(), - isValid: isValid, - errorMessage: errorMessage, - isUsed: isPathUsed(relativePath, filename, usedPaths), - usageInfo: usageInfo, - preview: content.substring(0, 100) + (content.length > 100 ? '...' : '') - }; - } catch (error) { - logger.warn(`[OAuth Analyzer] Failed to analyze file ${filePath}:`, error.message); - return null; - } -} - -/** - * Get detailed usage information for a file - * @param {string} relativePath - Relative file path - * @param {string} fileName - File name - * @param {Set} usedPaths - Set of used paths - * @param {Object} currentConfig - Current configuration - * @returns {Object} Usage information object - */ -function getFileUsageInfo(relativePath, fileName, usedPaths, currentConfig) { - const usageInfo = { - isUsed: false, - usageType: null, - usageDetails: [] - }; - - // 检查是否被使用 - const isUsed = isPathUsed(relativePath, fileName, usedPaths); - if (!isUsed) { - return usageInfo; - } - - usageInfo.isUsed = true; - - // 检查主要配置中的使用情况 - if (currentConfig.GEMINI_OAUTH_CREDS_FILE_PATH && - (pathsEqual(relativePath, currentConfig.GEMINI_OAUTH_CREDS_FILE_PATH) || - pathsEqual(relativePath, currentConfig.GEMINI_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/')))) { - usageInfo.usageType = 'main_config'; - usageInfo.usageDetails.push({ - type: 'Main Config', - location: 'Gemini OAuth credentials file path', - configKey: 'GEMINI_OAUTH_CREDS_FILE_PATH' - }); - } - - if (currentConfig.KIRO_OAUTH_CREDS_FILE_PATH && - (pathsEqual(relativePath, currentConfig.KIRO_OAUTH_CREDS_FILE_PATH) || - pathsEqual(relativePath, currentConfig.KIRO_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/')))) { - usageInfo.usageType = 'main_config'; - usageInfo.usageDetails.push({ - type: 'Main Config', - location: 'Kiro OAuth credentials file path', - configKey: 'KIRO_OAUTH_CREDS_FILE_PATH' - }); - } - - if (currentConfig.QWEN_OAUTH_CREDS_FILE_PATH && - (pathsEqual(relativePath, currentConfig.QWEN_OAUTH_CREDS_FILE_PATH) || - pathsEqual(relativePath, currentConfig.QWEN_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/')))) { - usageInfo.usageType = 'main_config'; - usageInfo.usageDetails.push({ - type: 'Main Config', - location: 'Qwen OAuth credentials file path', - configKey: 'QWEN_OAUTH_CREDS_FILE_PATH' - }); - } - - if (currentConfig.IFLOW_TOKEN_FILE_PATH && - (pathsEqual(relativePath, currentConfig.IFLOW_TOKEN_FILE_PATH) || - pathsEqual(relativePath, currentConfig.IFLOW_TOKEN_FILE_PATH.replace(/\\/g, '/')))) { - usageInfo.usageType = 'main_config'; - usageInfo.usageDetails.push({ - type: 'Main Config', - location: 'iFlow Token file path', - configKey: 'IFLOW_TOKEN_FILE_PATH' - }); - } - - if (currentConfig.CODEX_OAUTH_CREDS_FILE_PATH && - (pathsEqual(relativePath, currentConfig.CODEX_OAUTH_CREDS_FILE_PATH) || - pathsEqual(relativePath, currentConfig.CODEX_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/')))) { - usageInfo.usageType = 'main_config'; - usageInfo.usageDetails.push({ - type: 'Main Config', - location: 'Codex OAuth credentials file path', - configKey: 'CODEX_OAUTH_CREDS_FILE_PATH' - }); - } - - // 检查提供商池中的使用情况 - if (currentConfig.providerPools) { - // 使用 flatMap 将双重循环优化为单层循环 O(n) - const allProviders = Object.entries(currentConfig.providerPools).flatMap( - ([providerType, providers]) => - providers.map((provider, index) => ({ provider, providerType, index })) - ); - - for (const { provider, providerType, index } of allProviders) { - const providerUsages = []; - - if (provider.GEMINI_OAUTH_CREDS_FILE_PATH && - (pathsEqual(relativePath, provider.GEMINI_OAUTH_CREDS_FILE_PATH) || - pathsEqual(relativePath, provider.GEMINI_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/')))) { - providerUsages.push({ - type: 'Provider Pool', - location: `Gemini OAuth credentials (node ${index + 1})`, - providerType: providerType, - providerIndex: index, - nodeName: provider.customName, - uuid: provider.uuid, - isHealthy: provider.isHealthy !== false, - isDisabled: provider.isDisabled === true, - configKey: 'GEMINI_OAUTH_CREDS_FILE_PATH' - }); - } - - if (provider.KIRO_OAUTH_CREDS_FILE_PATH && - (pathsEqual(relativePath, provider.KIRO_OAUTH_CREDS_FILE_PATH) || - pathsEqual(relativePath, provider.KIRO_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/')))) { - providerUsages.push({ - type: 'Provider Pool', - location: `Kiro OAuth credentials (node ${index + 1})`, - providerType: providerType, - providerIndex: index, - nodeName: provider.customName, - uuid: provider.uuid, - isHealthy: provider.isHealthy !== false, - isDisabled: provider.isDisabled === true, - configKey: 'KIRO_OAUTH_CREDS_FILE_PATH' - }); - } - - if (provider.QWEN_OAUTH_CREDS_FILE_PATH && - (pathsEqual(relativePath, provider.QWEN_OAUTH_CREDS_FILE_PATH) || - pathsEqual(relativePath, provider.QWEN_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/')))) { - providerUsages.push({ - type: 'Provider Pool', - location: `Qwen OAuth credentials (node ${index + 1})`, - providerType: providerType, - providerIndex: index, - nodeName: provider.customName, - uuid: provider.uuid, - isHealthy: provider.isHealthy !== false, - isDisabled: provider.isDisabled === true, - configKey: 'QWEN_OAUTH_CREDS_FILE_PATH' - }); - } - - if (provider.ANTIGRAVITY_OAUTH_CREDS_FILE_PATH && - (pathsEqual(relativePath, provider.ANTIGRAVITY_OAUTH_CREDS_FILE_PATH) || - pathsEqual(relativePath, provider.ANTIGRAVITY_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/')))) { - providerUsages.push({ - type: 'Provider Pool', - location: `Antigravity OAuth credentials (node ${index + 1})`, - providerType: providerType, - providerIndex: index, - nodeName: provider.customName, - uuid: provider.uuid, - isHealthy: provider.isHealthy !== false, - isDisabled: provider.isDisabled === true, - configKey: 'ANTIGRAVITY_OAUTH_CREDS_FILE_PATH' - }); - } - - if (provider.IFLOW_TOKEN_FILE_PATH && - (pathsEqual(relativePath, provider.IFLOW_TOKEN_FILE_PATH) || - pathsEqual(relativePath, provider.IFLOW_TOKEN_FILE_PATH.replace(/\\/g, '/')))) { - providerUsages.push({ - type: 'Provider Pool', - location: `iFlow Token (node ${index + 1})`, - providerType: providerType, - providerIndex: index, - nodeName: provider.customName, - uuid: provider.uuid, - isHealthy: provider.isHealthy !== false, - isDisabled: provider.isDisabled === true, - configKey: 'IFLOW_TOKEN_FILE_PATH' - }); - } - - if (provider.CODEX_OAUTH_CREDS_FILE_PATH && - (pathsEqual(relativePath, provider.CODEX_OAUTH_CREDS_FILE_PATH) || - pathsEqual(relativePath, provider.CODEX_OAUTH_CREDS_FILE_PATH.replace(/\\/g, '/')))) { - providerUsages.push({ - type: 'Provider Pool', - location: `Codex OAuth credentials (node ${index + 1})`, - providerType: providerType, - providerIndex: index, - nodeName: provider.customName, - uuid: provider.uuid, - isHealthy: provider.isHealthy !== false, - isDisabled: provider.isDisabled === true, - configKey: 'CODEX_OAUTH_CREDS_FILE_PATH' - }); - } - - if (providerUsages.length > 0) { - usageInfo.usageType = 'provider_pool'; - usageInfo.usageDetails.push(...providerUsages); - } - } - } - - // 如果有多个使用位置,标记为多种用途 - if (usageInfo.usageDetails.length > 1) { - usageInfo.usageType = 'multiple'; - } - - return usageInfo; -} - -/** - * Scan OAuth directory for credential files - * @param {string} dirPath - Directory path to scan - * @param {Set} usedPaths - Set of used paths - * @param {Object} currentConfig - Current configuration - * @returns {Promise} Array of OAuth configuration file objects - */ -async function scanOAuthDirectory(dirPath, usedPaths, currentConfig) { - const oauthFiles = []; - - try { - const files = await fs.readdir(dirPath, { withFileTypes: true }); - - for (const file of files) { - const fullPath = path.join(dirPath, file.name); - - if (file.isFile()) { - const ext = path.extname(file.name).toLowerCase(); - // 只关注OAuth相关的文件类型 - if (['.json', '.oauth', '.creds', '.key', '.pem', '.txt'].includes(ext)) { - const fileInfo = await analyzeOAuthFile(fullPath, usedPaths, currentConfig); - if (fileInfo) { - oauthFiles.push(fileInfo); - } - } - } else if (file.isDirectory()) { - // 递归扫描子目录(限制深度) - const relativePath = path.relative(process.cwd(), fullPath); - // 最大深度4层,以支持 configs/kiro/{subfolder}/file.json 这样的结构 - if (relativePath.split(path.sep).length < 4) { - const subFiles = await scanOAuthDirectory(fullPath, usedPaths, currentConfig); - oauthFiles.push(...subFiles); - } - } - } - } catch (error) { - logger.warn(`[OAuth Scanner] Failed to scan directory ${dirPath}:`, error.message); - } - - return oauthFiles; -} \ No newline at end of file diff --git a/src/ui-modules/event-broadcast.js b/src/ui-modules/event-broadcast.js deleted file mode 100644 index af54ad4a0456472dcf573b50390429c5d1bd47ea..0000000000000000000000000000000000000000 --- a/src/ui-modules/event-broadcast.js +++ /dev/null @@ -1,278 +0,0 @@ -import { existsSync, readFileSync } from 'fs'; -import { promises as fs } from 'fs'; -import path from 'path'; -import multer from 'multer'; -import logger from '../utils/logger.js'; - -// Token存储到本地文件中 -const TOKEN_STORE_FILE = path.join(process.cwd(), 'configs', 'token-store.json'); - -// 用量缓存文件路径 -const USAGE_CACHE_FILE = path.join(process.cwd(), 'configs', 'usage-cache.json'); - -/** - * Helper function to broadcast events to UI clients - * @param {string} eventType - The type of event - * @param {any} data - The data to broadcast - */ -export function broadcastEvent(eventType, data) { - if (global.eventClients && global.eventClients.length > 0) { - const payload = typeof data === 'string' ? data : JSON.stringify(data); - global.eventClients.forEach(client => { - client.write(`event: ${eventType}\n`); - client.write(`data: ${payload}\n\n`); - }); - } -} - -/** - * Server-Sent Events for real-time updates - */ -export async function handleEvents(req, res) { - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - 'Access-Control-Allow-Origin': '*' - }); - - try { - res.write('\n'); - } catch (err) { - logger.error('[Event Broadcast] Failed to write initial data:', err.message); - return true; - } - - // Store the response object for broadcasting - if (!global.eventClients) { - global.eventClients = []; - } - global.eventClients.push(res); - - // Keep connection alive - const keepAlive = setInterval(() => { - if (!res.writableEnded && !res.destroyed) { - try { - res.write(':\n\n'); - } catch (err) { - logger.error('[Event Broadcast] Failed to write keepalive:', err.message); - clearInterval(keepAlive); - global.eventClients = global.eventClients.filter(r => r !== res); - } - } else { - clearInterval(keepAlive); - global.eventClients = global.eventClients.filter(r => r !== res); - } - }, 30000); - - req.on('close', () => { - clearInterval(keepAlive); - global.eventClients = global.eventClients.filter(r => r !== res); - }); - - return true; -} - -/** - * Initialize UI management features - */ -export function initializeUIManagement() { - // Initialize log broadcasting for UI - if (!global.eventClients) { - global.eventClients = []; - } - if (!global.logBuffer) { - global.logBuffer = []; - } - - // Override console.log to broadcast logs - const originalLog = console.log; - console.log = function(...args) { - originalLog.apply(console, args); - const logEntry = { - timestamp: new Date().toISOString(), - level: 'info', - message: args.map(arg => { - if (typeof arg === 'string') return arg; - try { - return JSON.stringify(arg); - } catch (e) { - return String(arg); - } - }).join(' ') - }; - global.logBuffer.push(logEntry); - if (global.logBuffer.length > 100) { - global.logBuffer.shift(); - } - broadcastEvent('log', logEntry); - }; - - // Override console.error to broadcast errors - const originalError = console.error; - console.error = function(...args) { - originalError.apply(console, args); - const logEntry = { - timestamp: new Date().toISOString(), - level: 'error', - message: args.map(arg => { - if (typeof arg === 'string') return arg; - try { - return JSON.stringify(arg); - } catch (e) { - return String(arg); - } - }).join(' ') - }; - global.logBuffer.push(logEntry); - if (global.logBuffer.length > 100) { - global.logBuffer.shift(); - } - broadcastEvent('log', logEntry); - }; -} - -// 配置multer中间件 -const storage = multer.diskStorage({ - destination: async (req, file, cb) => { - try { - // multer在destination回调时req.body还未解析,先使用默认路径 - // 实际的provider会在文件上传完成后从req.body中获取 - const uploadPath = path.join(process.cwd(), 'configs', 'temp'); - await fs.mkdir(uploadPath, { recursive: true }); - cb(null, uploadPath); - } catch (error) { - cb(error); - } - }, - filename: (req, file, cb) => { - const timestamp = Date.now(); - const sanitizedName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_'); - cb(null, `${timestamp}_${sanitizedName}`); - } -}); - -const fileFilter = (req, file, cb) => { - const allowedTypes = ['.json', '.txt', '.key', '.pem', '.p12', '.pfx']; - const ext = path.extname(file.originalname).toLowerCase(); - if (allowedTypes.includes(ext)) { - cb(null, true); - } else { - cb(new Error('Unsupported file type'), false); - } -}; - -export const upload = multer({ - storage, - fileFilter, - limits: { - fileSize: 5 * 1024 * 1024 // 5MB限制 - } -}); - -/** - * 处理 OAuth 凭据文件上传 - * @param {http.IncomingMessage} req - HTTP 请求对象 - * @param {http.ServerResponse} res - HTTP 响应对象 - * @param {Object} options - 可选配置 - * @param {Object} options.providerMap - 提供商类型映射表 - * @param {string} options.logPrefix - 日志前缀 - * @param {string} options.userInfo - 用户信息(用于日志) - * @param {Object} options.customUpload - 自定义 multer 实例 - * @returns {Promise} 始终返回 true 表示请求已处理 - */ -export function handleUploadOAuthCredentials(req, res, options = {}) { - const { - providerMap = {}, - logPrefix = '[UI API]', - userInfo = '', - customUpload = null - } = options; - - const uploadMiddleware = customUpload ? customUpload.single('file') : upload.single('file'); - - return new Promise((resolve) => { - uploadMiddleware(req, res, async (err) => { - if (err) { - logger.error(`${logPrefix} File upload error:`, err.message); - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: err.message || 'File upload failed' - } - })); - resolve(true); - return; - } - - try { - if (!req.file) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: 'No file was uploaded' - } - })); - resolve(true); - return; - } - - // multer执行完成后,表单字段已解析到req.body中 - const providerType = req.body.provider || 'common'; - // 应用提供商映射(如果有) - const provider = providerMap[providerType] || providerType; - const tempFilePath = req.file.path; - - // 根据实际的provider移动文件到正确的目录 - let targetDir = path.join(process.cwd(), 'configs', provider); - - // 如果是kiro类型的凭证,需要再包裹一层文件夹 - if (provider === 'kiro') { - // 使用时间戳作为子文件夹名称,确保每个上传的文件都有独立的目录 - const timestamp = Date.now(); - const originalNameWithoutExt = path.parse(req.file.originalname).name; - const subFolder = `${timestamp}_${originalNameWithoutExt}`; - targetDir = path.join(targetDir, subFolder); - } - - await fs.mkdir(targetDir, { recursive: true }); - - const targetFilePath = path.join(targetDir, req.file.filename); - await fs.rename(tempFilePath, targetFilePath); - - const relativePath = path.relative(process.cwd(), targetFilePath); - - // 广播更新事件 - broadcastEvent('config_update', { - action: 'add', - filePath: relativePath, - provider: provider, - timestamp: new Date().toISOString() - }); - - const userInfoStr = userInfo ? `, ${userInfo}` : ''; - logger.info(`${logPrefix} OAuth credentials file uploaded: ${targetFilePath} (provider: ${provider}${userInfoStr})`); - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: true, - message: 'File uploaded successfully', - filePath: relativePath, - originalName: req.file.originalname, - provider: provider - })); - resolve(true); - - } catch (error) { - logger.error(`${logPrefix} File upload processing error:`, error); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: 'File upload processing failed: ' + error.message - } - })); - resolve(true); - } - }); - }); -} \ No newline at end of file diff --git a/src/ui-modules/oauth-api.js b/src/ui-modules/oauth-api.js deleted file mode 100644 index 91c50945973cca5f3bd45a7d62987c7eb799ba57..0000000000000000000000000000000000000000 --- a/src/ui-modules/oauth-api.js +++ /dev/null @@ -1,633 +0,0 @@ -import { getRequestBody } from '../utils/common.js'; -import logger from '../utils/logger.js'; -import { - handleGeminiCliOAuth, - handleGeminiAntigravityOAuth, - batchImportGeminiTokensStream, - handleQwenOAuth, - handleKiroOAuth, - handleIFlowOAuth, - handleCodexOAuth, - batchImportCodexTokensStream, - batchImportKiroRefreshTokensStream, - importAwsCredentials -} from '../auth/oauth-handlers.js'; - -/** - * 生成 OAuth 授权 URL - */ -export async function handleGenerateAuthUrl(req, res, currentConfig, providerType) { - try { - let authUrl = ''; - let authInfo = {}; - - // 解析 options - let options = {}; - try { - options = await getRequestBody(req); - } catch (e) { - // 如果没有请求体,使用默认空对象 - } - - // 根据提供商类型生成授权链接并启动回调服务器 - if (providerType === 'gemini-cli-oauth') { - const result = await handleGeminiCliOAuth(currentConfig, options); - authUrl = result.authUrl; - authInfo = result.authInfo; - } else if (providerType === 'gemini-antigravity') { - const result = await handleGeminiAntigravityOAuth(currentConfig, options); - authUrl = result.authUrl; - authInfo = result.authInfo; - } else if (providerType === 'openai-qwen-oauth') { - const result = await handleQwenOAuth(currentConfig, options); - authUrl = result.authUrl; - authInfo = result.authInfo; - } else if (providerType === 'claude-kiro-oauth') { - // Kiro OAuth 支持多种认证方式 - // options.method 可以是: 'google' | 'github' | 'builder-id' - const result = await handleKiroOAuth(currentConfig, options); - authUrl = result.authUrl; - authInfo = result.authInfo; - } else if (providerType === 'openai-iflow') { - // iFlow OAuth 授权 - const result = await handleIFlowOAuth(currentConfig, options); - authUrl = result.authUrl; - authInfo = result.authInfo; - } else if (providerType === 'openai-codex-oauth') { - // Codex OAuth(OAuth2 + PKCE) - const result = await handleCodexOAuth(currentConfig, options); - authUrl = result.authUrl; - authInfo = result.authInfo; - } else { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: `Unsupported provider type: ${providerType}` - } - })); - return true; - } - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: true, - authUrl: authUrl, - authInfo: authInfo - })); - return true; - - } catch (error) { - logger.error(`[UI API] Failed to generate auth URL for ${providerType}:`, error); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: `Failed to generate auth URL: ${error.message}` - } - })); - return true; - } -} - -/** - * 处理手动 OAuth 回调 - */ -export async function handleManualOAuthCallback(req, res) { - try { - const body = await getRequestBody(req); - const { provider, callbackUrl, authMethod } = body; - - if (!provider || !callbackUrl) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: false, - error: 'provider and callbackUrl are required' - })); - return true; - } - - logger.info(`[OAuth Manual Callback] Processing manual callback for ${provider}`); - logger.info(`[OAuth Manual Callback] Callback URL: ${callbackUrl}`); - - // 解析回调URL - const url = new URL(callbackUrl); - const code = url.searchParams.get('code'); - const state = url.searchParams.get('state'); - const token = url.searchParams.get('token'); - - if (!code && !token) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: false, - error: 'Callback URL must contain code or token parameter' - })); - return true; - } - - // 特殊处理 Codex OAuth 回调 - if (provider === 'openai-codex-oauth' && code && state) { - const { handleCodexOAuthCallback } = await import('../auth/oauth-handlers.js'); - const result = await handleCodexOAuthCallback(code, state); - - res.writeHead(result.success ? 200 : 500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(result)); - return true; - } - - // 通过fetch请求本地OAuth回调服务器处理 - // 使用localhost而不是原始hostname,确保请求到达本地服务器 - const localUrl = new URL(callbackUrl); - localUrl.hostname = 'localhost'; - localUrl.protocol = 'http:'; - - try { - const response = await fetch(localUrl.href); - - if (response.ok) { - logger.info(`[OAuth Manual Callback] Successfully processed callback for ${provider}`); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: true, - message: 'OAuth callback processed successfully' - })); - } else { - const errorText = await response.text(); - logger.error(`[OAuth Manual Callback] Callback processing failed:`, errorText); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: false, - error: `Callback processing failed: ${response.status}` - })); - } - } catch (fetchError) { - logger.error(`[OAuth Manual Callback] Failed to process callback:`, fetchError); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: false, - error: `Failed to process callback: ${fetchError.message}` - })); - } - - return true; - } catch (error) { - logger.error('[OAuth Manual Callback] Error:', error); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: false, - error: error.message - })); - return true; - } -} - -/** - * 批量导入 Kiro refreshToken(带实时进度 SSE) - */ -export async function handleBatchImportKiroTokens(req, res) { - try { - const body = await getRequestBody(req); - const { refreshTokens, region } = body; - - if (!refreshTokens || !Array.isArray(refreshTokens) || refreshTokens.length === 0) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: false, - error: 'refreshTokens array is required and must not be empty' - })); - return true; - } - - logger.info(`[Kiro Batch Import] Starting batch import of ${refreshTokens.length} tokens with SSE...`); - - // 设置 SSE 响应头 - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - 'X-Accel-Buffering': 'no' - }); - - // 发送 SSE 事件的辅助函数(带错误处理) - const sendSSE = (event, data) => { - if (!res.writableEnded && !res.destroyed) { - try { - res.write(`event: ${event}\n`); - res.write(`data: ${JSON.stringify(data)}\n\n`); - } catch (err) { - logger.error('[Kiro Batch Import] Failed to write SSE:', err.message); - return false; - } - } - return true; - }; - - // 发送开始事件 - sendSSE('start', { total: refreshTokens.length }); - - // 执行流式批量导入 - const result = await batchImportKiroRefreshTokensStream( - refreshTokens, - region || 'us-east-1', - (progress) => { - // 每处理完一个 token 发送进度更新 - sendSSE('progress', progress); - } - ); - - logger.info(`[Kiro Batch Import] Completed: ${result.success} success, ${result.failed} failed`); - - // 发送完成事件 - sendSSE('complete', { - success: true, - total: result.total, - successCount: result.success, - failedCount: result.failed, - details: result.details - }); - - res.end(); - return true; - - } catch (error) { - logger.error('[Kiro Batch Import] Error:', error); - // 如果已经开始发送 SSE,则发送错误事件 - if (res.headersSent && !res.writableEnded && !res.destroyed) { - try { - res.write(`event: error\n`); - res.write(`data: ${JSON.stringify({ error: error.message })}\n\n`); - res.end(); - } catch (writeErr) { - logger.error('[Kiro Batch Import] Failed to write error:', writeErr.message); - } - } else if (!res.headersSent) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: false, - error: error.message - })); - } - return true; - } -} - -/** - * 批量导入 Gemini Token(带实时进度 SSE) - */ -export async function handleBatchImportGeminiTokens(req, res) { - try { - const body = await getRequestBody(req); - const { providerType, tokens, skipDuplicateCheck } = body; - - if (!providerType || !tokens || !Array.isArray(tokens) || tokens.length === 0) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: false, - error: 'providerType and tokens array are required and must not be empty' - })); - return true; - } - - logger.info(`[Gemini Batch Import] Starting batch import for ${providerType} with ${tokens.length} tokens...`); - - // 设置 SSE 响应头 - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - 'X-Accel-Buffering': 'no' - }); - - // 发送 SSE 事件的辅助函数 - const sendSSE = (event, data) => { - res.write(`event: ${event}\n`); - res.write(`data: ${JSON.stringify(data)}\n\n`); - }; - - // 发送开始事件 - sendSSE('start', { total: tokens.length }); - - // 执行流式批量导入 - const result = await batchImportGeminiTokensStream( - providerType, - tokens, - (progress) => { - sendSSE('progress', progress); - }, - skipDuplicateCheck !== false // 默认为 true - ); - - logger.info(`[Gemini Batch Import] Completed: ${result.success} success, ${result.failed} failed`); - - // 发送完成事件 - sendSSE('complete', { - success: true, - total: result.total, - successCount: result.success, - failedCount: result.failed, - details: result.details - }); - - res.end(); - return true; - - } catch (error) { - logger.error('[Gemini Batch Import] Error:', error); - if (res.headersSent) { - res.write(`event: error\n`); - res.write(`data: ${JSON.stringify({ error: error.message })}\n\n`); - res.end(); - } else { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: false, - error: error.message - })); - } - return true; - } -} - -/** - * 批量导入 Codex Token(带实时进度 SSE) - */ -export async function handleBatchImportCodexTokens(req, res) { - try { - const body = await getRequestBody(req); - const { tokens, skipDuplicateCheck } = body; - - if (!tokens || !Array.isArray(tokens) || tokens.length === 0) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: false, - error: 'tokens array is required and must not be empty' - })); - return true; - } - - logger.info(`[Codex Batch Import] Starting batch import with ${tokens.length} tokens...`); - - // 设置 SSE 响应头 - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - 'X-Accel-Buffering': 'no' - }); - - // 发送 SSE 事件的辅助函数 - const sendSSE = (event, data) => { - res.write(`event: ${event}\n`); - res.write(`data: ${JSON.stringify(data)}\n\n`); - }; - - // 发送开始事件 - sendSSE('start', { total: tokens.length }); - - // 执行流式批量导入 - const result = await batchImportCodexTokensStream( - tokens, - (progress) => { - sendSSE('progress', progress); - }, - skipDuplicateCheck !== false // 默认为 true - ); - - logger.info(`[Codex Batch Import] Completed: ${result.success} success, ${result.failed} failed`); - - // 发送完成事件 - sendSSE('complete', { - success: true, - total: result.total, - successCount: result.success, - failedCount: result.failed, - details: result.details - }); - - res.end(); - return true; - - } catch (error) { - logger.error('[Codex Batch Import] Error:', error); - if (res.headersSent) { - res.write(`event: error\n`); - res.write(`data: ${JSON.stringify({ error: error.message })}\n\n`); - res.end(); - } else { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: false, - error: error.message - })); - } - return true; - } -} - -/** - * 导入 AWS SSO 凭据用于 Kiro(支持单个或批量导入) - */ -export async function handleImportAwsCredentials(req, res) { - try { - const body = await getRequestBody(req); - const { credentials } = body; - - if (!credentials) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: false, - error: 'credentials is required' - })); - return true; - } - - // 检查是否为批量导入(数组) - if (Array.isArray(credentials)) { - // 批量导入模式 - 使用 SSE 流式响应 - if (credentials.length === 0) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: false, - error: 'credentials array must not be empty' - })); - return true; - } - - // 验证每个凭据对象的必需字段 - const validationErrors = []; - for (let i = 0; i < credentials.length; i++) { - const cred = credentials[i]; - const missingFields = []; - if (!cred.clientId) missingFields.push('clientId'); - if (!cred.clientSecret) missingFields.push('clientSecret'); - if (!cred.accessToken) missingFields.push('accessToken'); - if (!cred.refreshToken) missingFields.push('refreshToken'); - - if (missingFields.length > 0) { - validationErrors.push({ - index: i + 1, - missingFields: missingFields - }); - } - } - - // 如果有验证错误,返回详细信息 - if (validationErrors.length > 0) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: false, - error: `Validation failed for ${validationErrors.length} credential(s)`, - validationErrors: validationErrors - })); - return true; - } - - logger.info(`[Kiro AWS Batch Import] Starting batch import of ${credentials.length} credentials with SSE...`); - - // 设置 SSE 响应头 - res.writeHead(200, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - 'X-Accel-Buffering': 'no' - }); - - // 发送 SSE 事件的辅助函数 - const sendSSE = (event, data) => { - res.write(`event: ${event}\n`); - res.write(`data: ${JSON.stringify(data)}\n\n`); - }; - - // 发送开始事件 - sendSSE('start', { total: credentials.length }); - - // 批量导入 - let successCount = 0; - let failedCount = 0; - const details = []; - - for (let i = 0; i < credentials.length; i++) { - const cred = credentials[i]; - const progressData = { - index: i + 1, - total: credentials.length, - current: null - }; - - try { - const result = await importAwsCredentials(cred); - - if (result.success) { - progressData.current = { - index: i + 1, - success: true, - path: result.path - }; - successCount++; - } else { - progressData.current = { - index: i + 1, - success: false, - error: result.error, - existingPath: result.existingPath - }; - failedCount++; - } - } catch (error) { - progressData.current = { - index: i + 1, - success: false, - error: error.message - }; - failedCount++; - } - - details.push(progressData.current); - - // 发送进度更新 - sendSSE('progress', { - ...progressData, - successCount, - failedCount - }); - } - - logger.info(`[Kiro AWS Batch Import] Completed: ${successCount} success, ${failedCount} failed`); - - // 发送完成事件 - sendSSE('complete', { - success: true, - total: credentials.length, - successCount, - failedCount, - details - }); - - res.end(); - return true; - - } else if (typeof credentials === 'object') { - // 单个导入模式 - // 验证必需字段 - 需要四个字段都存在 - const missingFields = []; - if (!credentials.clientId) missingFields.push('clientId'); - if (!credentials.clientSecret) missingFields.push('clientSecret'); - if (!credentials.accessToken) missingFields.push('accessToken'); - if (!credentials.refreshToken) missingFields.push('refreshToken'); - - if (missingFields.length > 0) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: false, - error: `Missing required fields: ${missingFields.join(', ')}` - })); - return true; - } - - logger.info('[Kiro AWS Import] Starting AWS credentials import...'); - - const result = await importAwsCredentials(credentials); - - if (result.success) { - logger.info(`[Kiro AWS Import] Successfully imported credentials to: ${result.path}`); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: true, - path: result.path, - message: 'AWS credentials imported successfully' - })); - } else { - // 重复凭据返回 409 Conflict,其他错误返回 500 - const statusCode = result.error === 'duplicate' ? 409 : 500; - res.writeHead(statusCode, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: false, - error: result.error, - existingPath: result.existingPath || null - })); - } - return true; - } else { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: false, - error: 'credentials must be an object or array' - })); - return true; - } - - } catch (error) { - logger.error('[Kiro AWS Import] Error:', error); - // 如果已经开始发送 SSE,则发送错误事件 - if (res.headersSent) { - res.write(`event: error\n`); - res.write(`data: ${JSON.stringify({ error: error.message })}\n\n`); - res.end(); - } else { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: false, - error: error.message - })); - } - return true; - } -} diff --git a/src/ui-modules/plugin-api.js b/src/ui-modules/plugin-api.js deleted file mode 100644 index 4ed0d1de7501551243bb6e9bc2ef1bd7d0b5e805..0000000000000000000000000000000000000000 --- a/src/ui-modules/plugin-api.js +++ /dev/null @@ -1,77 +0,0 @@ -import { getPluginManager } from '../core/plugin-manager.js'; -import logger from '../utils/logger.js'; -import { getRequestBody } from '../utils/common.js'; -import { broadcastEvent } from './event-broadcast.js'; - -/** - * 获取插件列表 - */ -export async function handleGetPlugins(req, res) { - try { - const pluginManager = getPluginManager(); - const plugins = pluginManager.getPluginList(); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ plugins })); - return true; - } catch (error) { - logger.error('[UI API] Failed to get plugins:', error); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: 'Failed to get plugins list: ' + error.message - } - })); - return true; - } -} - -/** - * 切换插件状态 - */ -export async function handleTogglePlugin(req, res, pluginName) { - try { - const body = await getRequestBody(req); - const { enabled } = body; - - if (typeof enabled !== 'boolean') { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: 'Enabled status must be a boolean' - } - })); - return true; - } - - const pluginManager = getPluginManager(); - await pluginManager.setPluginEnabled(pluginName, enabled); - - // 广播更新事件 - broadcastEvent('plugin_update', { - action: 'toggle', - pluginName, - enabled, - timestamp: new Date().toISOString() - }); - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: true, - message: `Plugin ${pluginName} ${enabled ? 'enabled' : 'disabled'} successfully`, - plugin: { - name: pluginName, - enabled - } - })); - return true; - } catch (error) { - logger.error('[UI API] Failed to toggle plugin:', error); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: 'Failed to toggle plugin: ' + error.message - } - })); - return true; - } -} \ No newline at end of file diff --git a/src/ui-modules/provider-api.js b/src/ui-modules/provider-api.js deleted file mode 100644 index 722ce2581a64d2020584eb01a5231e3eee385d68..0000000000000000000000000000000000000000 --- a/src/ui-modules/provider-api.js +++ /dev/null @@ -1,1048 +0,0 @@ -import { existsSync, readFileSync, writeFileSync } from 'fs'; -import logger from '../utils/logger.js'; -import { getRequestBody } from '../utils/common.js'; -import { getAllProviderModels, getProviderModels } from '../providers/provider-models.js'; -import { generateUUID, createProviderConfig, formatSystemPath, detectProviderFromPath, addToUsedPaths, isPathUsed, pathsEqual } from '../utils/provider-utils.js'; -import { broadcastEvent } from './event-broadcast.js'; -import { getRegisteredProviders } from '../providers/adapter.js'; - -/** - * 获取提供商池摘要 - */ -export async function handleGetProviders(req, res, currentConfig, providerPoolManager) { - let providerPools = {}; - const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; - try { - if (providerPoolManager && providerPoolManager.providerPools) { - providerPools = providerPoolManager.providerPools; - } else if (filePath && existsSync(filePath)) { - const poolsData = JSON.parse(readFileSync(filePath, 'utf-8')); - providerPools = poolsData; - } - } catch (error) { - logger.warn('[UI API] Failed to load provider pools:', error.message); - } - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(providerPools)); - return true; -} - -/** - * 获取支持的提供商类型(已注册适配器的) - */ -export async function handleGetSupportedProviders(req, res) { - const supportedProviders = getRegisteredProviders(); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(supportedProviders)); - return true; -} - -/** - * 获取特定提供商类型的详细信息 - */ -export async function handleGetProviderType(req, res, currentConfig, providerPoolManager, providerType) { - let providerPools = {}; - const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; - try { - if (providerPoolManager && providerPoolManager.providerPools) { - providerPools = providerPoolManager.providerPools; - } else if (filePath && existsSync(filePath)) { - const poolsData = JSON.parse(readFileSync(filePath, 'utf-8')); - providerPools = poolsData; - } - } catch (error) { - logger.warn('[UI API] Failed to load provider pools:', error.message); - } - - const providers = providerPools[providerType] || []; - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - providerType, - providers, - totalCount: providers.length, - healthyCount: providers.filter(p => p.isHealthy).length - })); - return true; -} - -/** - * 获取所有提供商的可用模型 - */ -export async function handleGetProviderModels(req, res) { - const allModels = getAllProviderModels(); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(allModels)); - return true; -} - -/** - * 获取特定提供商类型的可用模型 - */ -export async function handleGetProviderTypeModels(req, res, providerType) { - const models = getProviderModels(providerType); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - providerType, - models - })); - return true; -} - -/** - * 添加新的提供商配置 - */ -export async function handleAddProvider(req, res, currentConfig, providerPoolManager) { - try { - const body = await getRequestBody(req); - const { providerType, providerConfig } = body; - - if (!providerType || !providerConfig) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: 'providerType and providerConfig are required' } })); - return true; - } - - // Generate UUID if not provided - if (!providerConfig.uuid) { - providerConfig.uuid = generateUUID(); - } - - // Set default values - providerConfig.isHealthy = providerConfig.isHealthy !== undefined ? providerConfig.isHealthy : true; - providerConfig.lastUsed = providerConfig.lastUsed || null; - providerConfig.usageCount = providerConfig.usageCount || 0; - providerConfig.errorCount = providerConfig.errorCount || 0; - providerConfig.lastErrorTime = providerConfig.lastErrorTime || null; - - const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'provider_pools.json'; - let providerPools = {}; - - // Load existing pools - if (existsSync(filePath)) { - try { - const fileContent = readFileSync(filePath, 'utf-8'); - providerPools = JSON.parse(fileContent); - } catch (readError) { - logger.warn('[UI API] Failed to read existing provider pools:', readError.message); - } - } - - // Add new provider to the appropriate type - if (!providerPools[providerType]) { - providerPools[providerType] = []; - } - providerPools[providerType].push(providerConfig); - - // Save to file - writeFileSync(filePath, JSON.stringify(providerPools, null, 2), 'utf-8'); - logger.info(`[UI API] Added new provider to ${providerType}: ${providerConfig.uuid}`); - - // Update provider pool manager if available - if (providerPoolManager) { - providerPoolManager.providerPools = providerPools; - providerPoolManager.initializeProviderStatus(); - } - - // 广播更新事件 - broadcastEvent('config_update', { - action: 'add', - filePath: filePath, - providerType, - providerConfig, - timestamp: new Date().toISOString() - }); - - // 广播提供商更新事件 - broadcastEvent('provider_update', { - action: 'add', - providerType, - providerConfig, - timestamp: new Date().toISOString() - }); - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: true, - message: 'Provider added successfully', - provider: providerConfig, - providerType - })); - return true; - } catch (error) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: error.message } })); - return true; - } -} - -/** - * 更新特定提供商配置 - */ -export async function handleUpdateProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid) { - try { - const body = await getRequestBody(req); - const { providerConfig } = body; - - if (!providerConfig) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: 'providerConfig is required' } })); - return true; - } - - const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; - let providerPools = {}; - - // Load existing pools - if (existsSync(filePath)) { - try { - const fileContent = readFileSync(filePath, 'utf-8'); - providerPools = JSON.parse(fileContent); - } catch (readError) { - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: 'Provider pools file not found' } })); - return true; - } - } - - // Find and update the provider - const providers = providerPools[providerType] || []; - const providerIndex = providers.findIndex(p => p.uuid === providerUuid); - - if (providerIndex === -1) { - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: 'Provider not found' } })); - return true; - } - - // Update provider while preserving certain fields - const existingProvider = providers[providerIndex]; - const updatedProvider = { - ...existingProvider, - ...providerConfig, - uuid: providerUuid, // Ensure UUID doesn't change - lastUsed: existingProvider.lastUsed, // Preserve usage stats - usageCount: existingProvider.usageCount, - errorCount: existingProvider.errorCount, - lastErrorTime: existingProvider.lastErrorTime - }; - - providerPools[providerType][providerIndex] = updatedProvider; - - // Save to file - writeFileSync(filePath, JSON.stringify(providerPools, null, 2), 'utf-8'); - logger.info(`[UI API] Updated provider ${providerUuid} in ${providerType}`); - - // Update provider pool manager if available - if (providerPoolManager) { - providerPoolManager.providerPools = providerPools; - providerPoolManager.initializeProviderStatus(); - } - - // 广播更新事件 - broadcastEvent('config_update', { - action: 'update', - filePath: filePath, - providerType, - providerConfig: updatedProvider, - timestamp: new Date().toISOString() - }); - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: true, - message: 'Provider updated successfully', - provider: updatedProvider - })); - return true; - } catch (error) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: error.message } })); - return true; - } -} - -/** - * 删除特定提供商配置 - */ -export async function handleDeleteProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid) { - try { - const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; - let providerPools = {}; - - // Load existing pools - if (existsSync(filePath)) { - try { - const fileContent = readFileSync(filePath, 'utf-8'); - providerPools = JSON.parse(fileContent); - } catch (readError) { - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: 'Provider pools file not found' } })); - return true; - } - } - - // Find and remove the provider - const providers = providerPools[providerType] || []; - const providerIndex = providers.findIndex(p => p.uuid === providerUuid); - - if (providerIndex === -1) { - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: 'Provider not found' } })); - return true; - } - - const deletedProvider = providers[providerIndex]; - providers.splice(providerIndex, 1); - - // Remove the entire provider type if no providers left - if (providers.length === 0) { - delete providerPools[providerType]; - } - - // Save to file - writeFileSync(filePath, JSON.stringify(providerPools, null, 2), 'utf-8'); - logger.info(`[UI API] Deleted provider ${providerUuid} from ${providerType}`); - - // Update provider pool manager if available - if (providerPoolManager) { - providerPoolManager.providerPools = providerPools; - providerPoolManager.initializeProviderStatus(); - } - - // 广播更新事件 - broadcastEvent('config_update', { - action: 'delete', - filePath: filePath, - providerType, - providerConfig: deletedProvider, - timestamp: new Date().toISOString() - }); - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: true, - message: 'Provider deleted successfully', - deletedProvider - })); - return true; - } catch (error) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: error.message } })); - return true; - } -} - -/** - * 禁用/启用特定提供商配置 - */ -export async function handleDisableEnableProvider(req, res, currentConfig, providerPoolManager, providerType, providerUuid, action) { - try { - const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; - let providerPools = {}; - - // Load existing pools - if (existsSync(filePath)) { - try { - const fileContent = readFileSync(filePath, 'utf-8'); - providerPools = JSON.parse(fileContent); - } catch (readError) { - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: 'Provider pools file not found' } })); - return true; - } - } - - // Find and update the provider - const providers = providerPools[providerType] || []; - const providerIndex = providers.findIndex(p => p.uuid === providerUuid); - - if (providerIndex === -1) { - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: 'Provider not found' } })); - return true; - } - - // Update isDisabled field - const provider = providers[providerIndex]; - provider.isDisabled = action === 'disable'; - - // Save to file - writeFileSync(filePath, JSON.stringify(providerPools, null, 2), 'utf-8'); - logger.info(`[UI API] ${action === 'disable' ? 'Disabled' : 'Enabled'} provider ${providerUuid} in ${providerType}`); - - // Update provider pool manager if available - if (providerPoolManager) { - providerPoolManager.providerPools = providerPools; - - // Call the appropriate method - if (action === 'disable') { - providerPoolManager.disableProvider(providerType, provider); - } else { - providerPoolManager.enableProvider(providerType, provider); - } - } - - // 广播更新事件 - broadcastEvent('config_update', { - action: action, - filePath: filePath, - providerType, - providerConfig: provider, - timestamp: new Date().toISOString() - }); - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: true, - message: `Provider ${action}d successfully`, - provider: provider - })); - return true; - } catch (error) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: error.message } })); - return true; - } -} - -/** - * 重置特定提供商类型的所有提供商健康状态 - */ -export async function handleResetProviderHealth(req, res, currentConfig, providerPoolManager, providerType) { - try { - const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; - let providerPools = {}; - - // Load existing pools - if (existsSync(filePath)) { - try { - const fileContent = readFileSync(filePath, 'utf-8'); - providerPools = JSON.parse(fileContent); - } catch (readError) { - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: 'Provider pools file not found' } })); - return true; - } - } - - // Reset health status for all providers of this type - const providers = providerPools[providerType] || []; - - if (providers.length === 0) { - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: 'No providers found for this type' } })); - return true; - } - - let resetCount = 0; - providers.forEach(provider => { - // 统计 isHealthy 从 false 变为 true 的节点数量 - if (!provider.isHealthy) { - resetCount++; - } - // 重置所有节点的状态 - provider.isHealthy = true; - provider.errorCount = 0; - provider.refreshCount = 0; - provider.needsRefresh = false; - provider.lastErrorTime = null; - }); - - // Save to file - writeFileSync(filePath, JSON.stringify(providerPools, null, 2), 'utf-8'); - logger.info(`[UI API] Reset health status for ${resetCount} providers in ${providerType}`); - - // Update provider pool manager if available - if (providerPoolManager) { - providerPoolManager.providerPools = providerPools; - providerPoolManager.initializeProviderStatus(); - } - - // 广播更新事件 - broadcastEvent('config_update', { - action: 'reset_health', - filePath: filePath, - providerType, - resetCount, - timestamp: new Date().toISOString() - }); - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: true, - message: `Successfully reset health status for ${resetCount} providers`, - resetCount, - totalCount: providers.length - })); - return true; - } catch (error) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: error.message } })); - return true; - } -} - -/** - * 删除特定提供商类型的所有不健康节点 - */ -export async function handleDeleteUnhealthyProviders(req, res, currentConfig, providerPoolManager, providerType) { - try { - const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; - let providerPools = {}; - - // Load existing pools - if (existsSync(filePath)) { - try { - const fileContent = readFileSync(filePath, 'utf-8'); - providerPools = JSON.parse(fileContent); - } catch (readError) { - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: 'Provider pools file not found' } })); - return true; - } - } - - // Find and remove unhealthy providers - const providers = providerPools[providerType] || []; - - if (providers.length === 0) { - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: 'No providers found for this type' } })); - return true; - } - - // Filter out unhealthy providers (keep only healthy ones) - const unhealthyProviders = providers.filter(p => !p.isHealthy); - const healthyProviders = providers.filter(p => p.isHealthy); - - if (unhealthyProviders.length === 0) { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: true, - message: 'No unhealthy providers to delete', - deletedCount: 0, - remainingCount: providers.length - })); - return true; - } - - // Update the provider pool with only healthy providers - if (healthyProviders.length === 0) { - delete providerPools[providerType]; - } else { - providerPools[providerType] = healthyProviders; - } - - // Save to file - writeFileSync(filePath, JSON.stringify(providerPools, null, 2), 'utf-8'); - logger.info(`[UI API] Deleted ${unhealthyProviders.length} unhealthy providers from ${providerType}`); - - // Update provider pool manager if available - if (providerPoolManager) { - providerPoolManager.providerPools = providerPools; - providerPoolManager.initializeProviderStatus(); - } - - // 广播更新事件 - broadcastEvent('config_update', { - action: 'delete_unhealthy', - filePath: filePath, - providerType, - deletedCount: unhealthyProviders.length, - deletedProviders: unhealthyProviders.map(p => ({ uuid: p.uuid, customName: p.customName })), - timestamp: new Date().toISOString() - }); - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: true, - message: `Successfully deleted ${unhealthyProviders.length} unhealthy providers`, - deletedCount: unhealthyProviders.length, - remainingCount: healthyProviders.length, - deletedProviders: unhealthyProviders.map(p => ({ uuid: p.uuid, customName: p.customName })) - })); - return true; - } catch (error) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: error.message } })); - return true; - } -} - -/** - * 批量刷新特定提供商类型的所有不健康节点的 UUID - */ -export async function handleRefreshUnhealthyUuids(req, res, currentConfig, providerPoolManager, providerType) { - try { - const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; - let providerPools = {}; - - // Load existing pools - if (existsSync(filePath)) { - try { - const fileContent = readFileSync(filePath, 'utf-8'); - providerPools = JSON.parse(fileContent); - } catch (readError) { - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: 'Provider pools file not found' } })); - return true; - } - } - - // Find unhealthy providers - const providers = providerPools[providerType] || []; - - if (providers.length === 0) { - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: 'No providers found for this type' } })); - return true; - } - - // Filter unhealthy providers and refresh their UUIDs - const refreshedProviders = []; - for (const provider of providers) { - if (!provider.isHealthy) { - const oldUuid = provider.uuid; - const newUuid = generateUUID(); - provider.uuid = newUuid; - refreshedProviders.push({ - oldUuid, - newUuid, - customName: provider.customName - }); - } - } - - if (refreshedProviders.length === 0) { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: true, - message: 'No unhealthy providers to refresh', - refreshedCount: 0, - totalCount: providers.length - })); - return true; - } - - // Save to file - writeFileSync(filePath, JSON.stringify(providerPools, null, 2), 'utf-8'); - logger.info(`[UI API] Refreshed UUIDs for ${refreshedProviders.length} unhealthy providers in ${providerType}`); - - // Update provider pool manager if available - if (providerPoolManager) { - providerPoolManager.providerPools = providerPools; - providerPoolManager.initializeProviderStatus(); - } - - // 广播更新事件 - broadcastEvent('config_update', { - action: 'refresh_unhealthy_uuids', - filePath: filePath, - providerType, - refreshedCount: refreshedProviders.length, - refreshedProviders, - timestamp: new Date().toISOString() - }); - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: true, - message: `Successfully refreshed UUIDs for ${refreshedProviders.length} unhealthy providers`, - refreshedCount: refreshedProviders.length, - totalCount: providers.length, - refreshedProviders - })); - return true; - } catch (error) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: error.message } })); - return true; - } -} - -/** - * 对特定提供商类型的所有提供商执行健康检查 - */ -export async function handleHealthCheck(req, res, currentConfig, providerPoolManager, providerType) { - try { - if (!providerPoolManager) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: 'Provider pool manager not initialized' } })); - return true; - } - - const providers = providerPoolManager.providerStatus[providerType] || []; - - if (providers.length === 0) { - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: 'No providers found for this type' } })); - return true; - } - - // 只检测不健康的节点 - const unhealthyProviders = providers.filter(ps => !ps.config.isHealthy); - - if (unhealthyProviders.length === 0) { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: true, - message: 'No unhealthy providers to check', - successCount: 0, - failCount: 0, - totalCount: providers.length, - results: [] - })); - return true; - } - - logger.info(`[UI API] Starting health check for ${unhealthyProviders.length} unhealthy providers in ${providerType} (total: ${providers.length})`); - - // 执行健康检测(强制检查,忽略 checkHealth 配置) - const results = []; - for (const providerStatus of unhealthyProviders) { - const providerConfig = providerStatus.config; - - // 跳过已禁用的节点 - if (providerConfig.isDisabled) { - logger.info(`[UI API] Skipping health check for disabled provider: ${providerConfig.uuid}`); - continue; - } - - try { - // 传递 forceCheck = true 强制执行健康检查,忽略 checkHealth 配置 - const healthResult = await providerPoolManager._checkProviderHealth(providerType, providerConfig, true); - - if (healthResult === null) { - results.push({ - uuid: providerConfig.uuid, - success: null, - message: 'Health check not supported for this provider type' - }); - continue; - } - - if (healthResult.success) { - providerPoolManager.markProviderHealthy(providerType, providerConfig, false, healthResult.modelName); - results.push({ - uuid: providerConfig.uuid, - success: true, - modelName: healthResult.modelName, - message: 'Healthy' - }); - } else { - // 检查是否为认证错误(401/403),如果是则立即标记为不健康 - const errorMessage = healthResult.errorMessage || 'Check failed'; - const isAuthError = /\b(401|403)\b/.test(errorMessage) || - /\b(Unauthorized|Forbidden|AccessDenied|InvalidToken|ExpiredToken)\b/i.test(errorMessage); - - if (isAuthError) { - providerPoolManager.markProviderUnhealthyImmediately(providerType, providerConfig, errorMessage); - logger.info(`[UI API] Auth error detected for ${providerConfig.uuid}, immediately marked as unhealthy`); - } else { - providerPoolManager.markProviderUnhealthy(providerType, providerConfig, errorMessage); - } - - providerStatus.config.lastHealthCheckTime = new Date().toISOString(); - if (healthResult.modelName) { - providerStatus.config.lastHealthCheckModel = healthResult.modelName; - } - results.push({ - uuid: providerConfig.uuid, - success: false, - modelName: healthResult.modelName, - message: errorMessage, - isAuthError: isAuthError - }); - } - } catch (error) { - const errorMessage = error.message || 'Unknown error'; - // 检查是否为认证错误(401/403),如果是则立即标记为不健康 - const isAuthError = /\b(401|403)\b/.test(errorMessage) || - /\b(Unauthorized|Forbidden|AccessDenied|InvalidToken|ExpiredToken)\b/i.test(errorMessage); - - if (isAuthError) { - providerPoolManager.markProviderUnhealthyImmediately(providerType, providerConfig, errorMessage); - logger.info(`[UI API] Auth error detected for ${providerConfig.uuid}, immediately marked as unhealthy`); - } else { - providerPoolManager.markProviderUnhealthy(providerType, providerConfig, errorMessage); - } - - results.push({ - uuid: providerConfig.uuid, - success: false, - message: errorMessage, - isAuthError: isAuthError - }); - } - } - - // 保存更新后的状态到文件 - const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; - - // 从 providerStatus 构建 providerPools 对象并保存 - const providerPools = {}; - for (const pType in providerPoolManager.providerStatus) { - providerPools[pType] = providerPoolManager.providerStatus[pType].map(ps => ps.config); - } - writeFileSync(filePath, JSON.stringify(providerPools, null, 2), 'utf-8'); - - const successCount = results.filter(r => r.success === true).length; - const failCount = results.filter(r => r.success === false).length; - - logger.info(`[UI API] Health check completed for ${providerType}: ${successCount} recovered, ${failCount} still unhealthy (checked ${unhealthyProviders.length} unhealthy nodes)`); - - // 广播更新事件 - broadcastEvent('config_update', { - action: 'health_check', - filePath: filePath, - providerType, - results, - timestamp: new Date().toISOString() - }); - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: true, - message: `Health check completed: ${successCount} healthy, ${failCount} unhealthy`, - successCount, - failCount, - totalCount: providers.length, - results - })); - return true; - } catch (error) { - logger.error('[UI API] Health check error:', error); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: error.message } })); - return true; - } -} - -/** - * 快速链接配置文件到对应的提供商 - * 支持单个文件路径或文件路径数组 - */ -export async function handleQuickLinkProvider(req, res, currentConfig, providerPoolManager) { - try { - const body = await getRequestBody(req); - const { filePath, filePaths } = body; - - // 支持单个文件路径或文件路径数组 - const pathsToLink = filePaths || (filePath ? [filePath] : []); - - if (!pathsToLink || pathsToLink.length === 0) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: 'filePath or filePaths is required' } })); - return true; - } - - const poolsFilePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; - - // Load existing pools - let providerPools = {}; - if (existsSync(poolsFilePath)) { - try { - const fileContent = readFileSync(poolsFilePath, 'utf-8'); - providerPools = JSON.parse(fileContent); - } catch (readError) { - logger.warn('[UI API] Failed to read existing provider pools:', readError.message); - } - } - - const results = []; - const linkedProviders = []; - - // 处理每个文件路径 - for (const currentFilePath of pathsToLink) { - const normalizedPath = currentFilePath.replace(/\\/g, '/').toLowerCase(); - - // 根据文件路径自动识别提供商类型 - const providerMapping = detectProviderFromPath(normalizedPath); - - if (!providerMapping) { - results.push({ - filePath: currentFilePath, - success: false, - error: 'Unable to identify provider type for config file' - }); - continue; - } - - const { providerType, credPathKey, defaultCheckModel, displayName } = providerMapping; - - // Ensure provider type array exists - if (!providerPools[providerType]) { - providerPools[providerType] = []; - } - - // Check if already linked - 使用标准化路径进行比较 - const normalizedForComparison = currentFilePath.replace(/\\/g, '/'); - const isAlreadyLinked = providerPools[providerType].some(p => { - const existingPath = p[credPathKey]; - if (!existingPath) return false; - const normalizedExistingPath = existingPath.replace(/\\/g, '/'); - return normalizedExistingPath === normalizedForComparison || - normalizedExistingPath === './' + normalizedForComparison || - './' + normalizedExistingPath === normalizedForComparison; - }); - - if (isAlreadyLinked) { - results.push({ - filePath: currentFilePath, - success: false, - error: 'This config file is already linked', - providerType: providerType - }); - continue; - } - - // Create new provider config based on provider type - const newProvider = createProviderConfig({ - credPathKey, - credPath: formatSystemPath(currentFilePath), - defaultCheckModel, - needsProjectId: providerMapping.needsProjectId - }); - - providerPools[providerType].push(newProvider); - linkedProviders.push({ providerType, provider: newProvider }); - - results.push({ - filePath: currentFilePath, - success: true, - providerType: providerType, - displayName: displayName, - provider: newProvider - }); - - logger.info(`[UI API] Quick linked config: ${currentFilePath} -> ${providerType}`); - } - - // Save to file only if there were successful links - const successCount = results.filter(r => r.success).length; - if (successCount > 0) { - writeFileSync(poolsFilePath, JSON.stringify(providerPools, null, 2), 'utf-8'); - - // Update provider pool manager if available - if (providerPoolManager) { - providerPoolManager.providerPools = providerPools; - providerPoolManager.initializeProviderStatus(); - } - - // Broadcast update events - broadcastEvent('config_update', { - action: 'quick_link_batch', - filePath: poolsFilePath, - results: results, - timestamp: new Date().toISOString() - }); - - for (const { providerType, provider } of linkedProviders) { - broadcastEvent('provider_update', { - action: 'add', - providerType, - providerConfig: provider, - timestamp: new Date().toISOString() - }); - } - } - - const failCount = results.filter(r => !r.success).length; - const message = successCount > 0 - ? `Successfully linked ${successCount} config file(s)${failCount > 0 ? `, ${failCount} failed` : ''}` - : `Failed to link all ${failCount} config file(s)`; - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: successCount > 0, - message: message, - successCount: successCount, - failCount: failCount, - results: results - })); - return true; - } catch (error) { - logger.error('[UI API] Quick link failed:', error); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: 'Link failed: ' + error.message - } - })); - return true; - } -} - -/** - * 刷新特定提供商的UUID - */ -export async function handleRefreshProviderUuid(req, res, currentConfig, providerPoolManager, providerType, providerUuid) { - try { - const filePath = currentConfig.PROVIDER_POOLS_FILE_PATH || 'configs/provider_pools.json'; - let providerPools = {}; - - // Load existing pools - if (existsSync(filePath)) { - try { - const fileContent = readFileSync(filePath, 'utf-8'); - providerPools = JSON.parse(fileContent); - } catch (readError) { - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: 'Provider pools file not found' } })); - return true; - } - } - - // Find the provider - const providers = providerPools[providerType] || []; - const providerIndex = providers.findIndex(p => p.uuid === providerUuid); - - if (providerIndex === -1) { - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: 'Provider not found' } })); - return true; - } - - // Generate new UUID - const oldUuid = providerUuid; - const newUuid = generateUUID(); - - // Update provider UUID - providerPools[providerType][providerIndex].uuid = newUuid; - - // Save to file - writeFileSync(filePath, JSON.stringify(providerPools, null, 2), 'utf-8'); - logger.info(`[UI API] Refreshed UUID for provider in ${providerType}: ${oldUuid} -> ${newUuid}`); - - // Update provider pool manager if available - if (providerPoolManager) { - providerPoolManager.providerPools = providerPools; - providerPoolManager.initializeProviderStatus(); - } - - // 广播更新事件 - broadcastEvent('config_update', { - action: 'refresh_uuid', - filePath: filePath, - providerType, - oldUuid, - newUuid, - timestamp: new Date().toISOString() - }); - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: true, - message: 'UUID refreshed successfully', - oldUuid, - newUuid, - provider: providerPools[providerType][providerIndex] - })); - return true; - } catch (error) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: error.message } })); - return true; - } -} \ No newline at end of file diff --git a/src/ui-modules/system-api.js b/src/ui-modules/system-api.js deleted file mode 100644 index b21060141b3c98fe7e2105950453cf9e68b6286f..0000000000000000000000000000000000000000 --- a/src/ui-modules/system-api.js +++ /dev/null @@ -1,200 +0,0 @@ -import { existsSync, readFileSync, createReadStream } from 'fs'; -import logger from '../utils/logger.js'; -import path from 'path'; -import { getCpuUsagePercent } from './system-monitor.js'; - -/** - * 获取系统信息 - */ -export async function handleGetSystem(req, res) { - const memUsage = process.memoryUsage(); - - // 读取版本号 - let appVersion = 'unknown'; - try { - const versionFilePath = path.join(process.cwd(), 'VERSION'); - if (existsSync(versionFilePath)) { - appVersion = readFileSync(versionFilePath, 'utf8').trim(); - } - } catch (error) { - logger.warn('[UI API] Failed to read VERSION file:', error.message); - } - - // 计算 CPU 使用率 - let cpuUsage = '0.0%'; - const IS_WORKER_PROCESS = process.env.IS_WORKER_PROCESS === 'true'; - - if (IS_WORKER_PROCESS) { - // 如果是子进程,尝试从主进程获取状态来确定 PID,或者使用当前 PID (如果要求统计子进程自己的话) - // 根据任务描述 "CPU 使用率应该是统计子进程的PID的使用率" - // 这里的 system-api.js 可能运行在子进程中,直接统计 process.pid 即可 - cpuUsage = getCpuUsagePercent(process.pid); - } else { - // 独立运行模式下统计系统整体 CPU - cpuUsage = getCpuUsagePercent(); - } - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - appVersion: appVersion, - nodeVersion: process.version, - serverTime: new Date().toISOString(), - memoryUsage: `${Math.round(memUsage.heapUsed / 1024 / 1024)} MB / ${Math.round(memUsage.heapTotal / 1024 / 1024)} MB`, - cpuUsage: cpuUsage, - uptime: process.uptime() - })); - return true; -} - -/** - * 下载当日日志 - */ -export async function handleDownloadTodayLog(req, res) { - try { - if (!logger.currentLogFile || !existsSync(logger.currentLogFile)) { - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: 'Today\'s log file not found' } })); - return true; - } - - const fileName = path.basename(logger.currentLogFile); - res.writeHead(200, { - 'Content-Type': 'text/plain', - 'Content-Disposition': `attachment; filename="${fileName}"` - }); - - const readStream = createReadStream(logger.currentLogFile); - readStream.pipe(res); - return true; - } catch (error) { - logger.error('[UI API] Failed to download log:', error); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: 'Failed to download log: ' + error.message } })); - return true; - } -} - -/** - * 清空当日日志 - */ -export async function handleClearTodayLog(req, res) { - try { - const success = logger.clearTodayLog(); - - if (success) { - // 广播日志清空事件 - const { broadcastEvent } = await import('./event-broadcast.js'); - broadcastEvent('log_cleared', { - action: 'log_cleared', - timestamp: new Date().toISOString(), - message: 'Today\'s log file has been cleared' - }); - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: true, - message: '当日日志已清空' - })); - } else { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: false, - error: { message: '清空日志失败' } - })); - } - return true; - } catch (error) { - logger.error('[UI API] Failed to clear log:', error); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: false, - error: { message: 'Failed to clear log: ' + error.message } - })); - return true; - } -} - -/** - * 健康检查接口(用于前端token验证) - */ -export async function handleHealthCheck(req, res) { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ status: 'ok', timestamp: Date.now() })); - return true; -} - -/** - * 获取服务模式信息 - */ -export async function handleGetServiceMode(req, res) { - const IS_WORKER_PROCESS = process.env.IS_WORKER_PROCESS === 'true'; - const masterPort = process.env.MASTER_PORT || 3100; - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - mode: IS_WORKER_PROCESS ? 'worker' : 'standalone', - pid: process.pid, - ppid: process.ppid, - uptime: process.uptime(), - canAutoRestart: IS_WORKER_PROCESS && !!process.send, - masterPort: IS_WORKER_PROCESS ? masterPort : null, - nodeVersion: process.version, - platform: process.platform - })); - return true; -} - -/** - * 重启服务端点 - 支持主进程-子进程架构 - */ -export async function handleRestartService(req, res) { - try { - const IS_WORKER_PROCESS = process.env.IS_WORKER_PROCESS === 'true'; - - if (IS_WORKER_PROCESS && process.send) { - // 作为子进程运行,通知主进程重启 - logger.info('[UI API] Requesting restart from master process...'); - process.send({ type: 'restart_request' }); - - // 广播重启事件 - const { broadcastEvent } = await import('./event-broadcast.js'); - broadcastEvent('service_restart', { - action: 'restart_requested', - timestamp: new Date().toISOString(), - message: 'Service restart requested, worker will be restarted by master process' - }); - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: true, - message: 'Restart request sent to master process', - mode: 'worker', - details: { - workerPid: process.pid, - restartMethod: 'master_controlled' - } - })); - } else { - // 独立运行模式,无法自动重启 - logger.info('[UI API] Service is running in standalone mode, cannot auto-restart'); - - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: false, - message: 'Service is running in standalone mode. Please use master.js to enable auto-restart feature.', - mode: 'standalone', - hint: 'Start the service with: node src/core/master.js [args]' - })); - } - return true; - } catch (error) { - logger.error('[UI API] Failed to restart service:', error); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: 'Failed to restart service: ' + error.message - } - })); - return true; - } -} \ No newline at end of file diff --git a/src/ui-modules/system-monitor.js b/src/ui-modules/system-monitor.js deleted file mode 100644 index a37fc40f9424a3cfe44e37f15fe64433b76d46ed..0000000000000000000000000000000000000000 --- a/src/ui-modules/system-monitor.js +++ /dev/null @@ -1,142 +0,0 @@ -import os from 'os'; -import { execSync } from 'child_process'; - -// CPU 使用率计算相关变量 -let previousCpuInfo = null; - -// 进程 CPU 使用率计算相关变量 (PID -> info) -const processCpuInfoMap = new Map(); - -/** - * 获取系统 CPU 使用率百分比 - * @returns {string} CPU 使用率字符串,如 "25.5%" - */ -export function getSystemCpuUsagePercent() { - const cpus = os.cpus(); - - let totalIdle = 0; - let totalTick = 0; - - for (const cpu of cpus) { - for (const type in cpu.times) { - totalTick += cpu.times[type]; - } - totalIdle += cpu.times.idle; - } - - const currentCpuInfo = { - idle: totalIdle, - total: totalTick - }; - - let cpuPercent = 0; - - if (previousCpuInfo) { - const idleDiff = currentCpuInfo.idle - previousCpuInfo.idle; - const totalDiff = currentCpuInfo.total - previousCpuInfo.total; - - if (totalDiff > 0) { - cpuPercent = 100 - (100 * idleDiff / totalDiff); - } - } - - previousCpuInfo = currentCpuInfo; - - return `${cpuPercent.toFixed(1)}%`; -} - -/** - * 获取特定进程的 CPU 使用率百分比 - * @param {number} pid - 进程 ID - * @returns {string} CPU 使用率字符串,如 "5.2%" - */ -export function getProcessCpuUsagePercent(pid) { - if (!pid) return '0.0%'; - - try { - const isWindows = process.platform === 'win32'; - let cpuPercent = 0; - - // 优先处理当前进程,使用 Node.js 内置的 process.cpuUsage(),这在所有平台上都更准确且无需外部命令 - if (pid === process.pid) { - const usage = process.cpuUsage(); - const timestamp = Date.now(); - const totalMicroseconds = usage.user + usage.system; - const prevInfo = processCpuInfoMap.get(pid); - - if (prevInfo && prevInfo.totalMicroseconds !== undefined) { - const timeDiff = (timestamp - prevInfo.timestamp) * 1000; // 转换为微秒 - const processTimeDiff = totalMicroseconds - prevInfo.totalMicroseconds; - - if (timeDiff > 0) { - const cpuCount = os.cpus().length; - cpuPercent = (processTimeDiff / timeDiff) * 100; - // Node.js 返回的是所有核心累加的使用量,归一化到 0-100% - cpuPercent = cpuPercent / cpuCount; - } - } - - processCpuInfoMap.set(pid, { - totalMicroseconds, - timestamp - }); - } else if (isWindows) { - // Windows 下使用 PowerShell 获取其他进程的 CPU 使用率 - // CPU = (Process.TotalProcessorTime / ElapsedTime) / ProcessorCount - const command = `powershell -Command "Get-Process -Id ${pid} | Select-Object -ExpandProperty TotalProcessorTime | ForEach-Object { $_.TotalSeconds }"`; - const output = execSync(command, { encoding: 'utf8' }).trim(); - const totalProcessorSeconds = parseFloat(output); - const timestamp = Date.now(); - - if (!isNaN(totalProcessorSeconds)) { - const prevInfo = processCpuInfoMap.get(pid); - if (prevInfo && prevInfo.totalProcessorSeconds !== undefined) { - const timeDiff = (timestamp - prevInfo.timestamp) / 1000; // 转换为秒 - const processTimeDiff = totalProcessorSeconds - prevInfo.totalProcessorSeconds; - - if (timeDiff > 0) { - const cpuCount = os.cpus().length; - cpuPercent = (processTimeDiff / timeDiff) * 100; - // 归一化到系统总 CPU 的百分比 (0-100%) - cpuPercent = cpuPercent / cpuCount; - } - } - - processCpuInfoMap.set(pid, { - totalProcessorSeconds, - timestamp - }); - } - } else { - // Linux/macOS 使用 ps 命令获取其他进程的 CPU 使用率 - // 增加 2> /dev/null 以防在 BusyBox 等环境下报错干扰日志 - try { - const output = execSync(`ps -p ${pid} -o %cpu 2>/dev/null`, { encoding: 'utf8' }); - const lines = output.trim().split('\n'); - if (lines.length >= 2) { - cpuPercent = parseFloat(lines[1].trim()); - } - } catch (e) { - // 如果 ps -p 失败,尝试更通用的 ps 方式或直接忽略 - cpuPercent = 0; - } - } - - return `${Math.max(0, cpuPercent).toFixed(1)}%`; - } catch (error) { - // 忽略进程不存在等错误 - return '0.0%'; - } -} - -/** - * 获取 CPU 使用率百分比 (保持向后兼容) - * @param {number} [pid] - 可选的进程 ID,如果提供则统计该进程,否则统计系统整体 - * @returns {string} CPU 使用率字符串 - */ -export function getCpuUsagePercent(pid) { - if (pid) { - return getProcessCpuUsagePercent(pid); - } - return getSystemCpuUsagePercent(); -} diff --git a/src/ui-modules/update-api.js b/src/ui-modules/update-api.js deleted file mode 100644 index ac8976db4aa06cacd6ba7bcb62f2745197dbbb7f..0000000000000000000000000000000000000000 --- a/src/ui-modules/update-api.js +++ /dev/null @@ -1,643 +0,0 @@ -import { existsSync, readFileSync, writeFileSync } from 'fs'; -import logger from '../utils/logger.js'; -import { promises as fs } from 'fs'; -import path from 'path'; -import { exec } from 'child_process'; -import { promisify } from 'util'; -import { CONFIG } from '../core/config-manager.js'; -import { parseProxyUrl } from '../utils/proxy-utils.js'; - -const execAsync = promisify(exec); -const GITHUB_REPO = 'justlovemaki/AIClient-2-API'; - -function buildGitHubApiCandidates(repo) { - const apiPath = `repos/${repo}/tags`; - return [ - { - name: 'gh-proxy.org', - url: `https://gh-proxy.org/https://api.github.com/${apiPath}` - }, - { - name: 'hk.gh-proxy.org', - url: `https://hk.gh-proxy.org/https://api.github.com/${apiPath}` - }, - { - name: 'cdn.gh-proxy.org', - url: `https://cdn.gh-proxy.org/https://api.github.com/${apiPath}` - }, - { - name: 'edgeone.gh-proxy.org', - url: `https://edgeone.gh-proxy.org/https://api.github.com/${apiPath}` - }, - { - name: 'github-direct', - url: `https://api.github.com/${apiPath}` - } - ]; -} - -function buildTarballCandidates(repo, tag) { - const githubTarballPath = `${repo}/archive/refs/tags/${tag}.tar.gz`; - return [ - { - name: 'gh-proxy.org', - url: `https://gh-proxy.org/https://github.com/${githubTarballPath}` - }, - { - name: 'hk.gh-proxy.org', - url: `https://hk.gh-proxy.org/https://github.com/${githubTarballPath}` - }, - { - name: 'cdn.gh-proxy.org', - url: `https://cdn.gh-proxy.org/https://github.com/${githubTarballPath}` - }, - { - name: 'edgeone.gh-proxy.org', - url: `https://edgeone.gh-proxy.org/https://github.com/${githubTarballPath}` - }, - { - name: 'gitclone.com', - url: `https://gitclone.com/github.com/${githubTarballPath}` - } - ]; -} - -/** - * 获取更新检查使用的代理配置 - * @returns {Object|null} 代理配置对象或 null - */ -function getUpdateProxyConfig() { - if (!CONFIG || !CONFIG.PROXY_URL) { - return null; - } - - const proxyConfig = parseProxyUrl(CONFIG.PROXY_URL); - if (proxyConfig) { - logger.info(`[Update] Using ${proxyConfig.proxyType} proxy for update check: ${CONFIG.PROXY_URL}`); - } - return proxyConfig; -} - -/** - * 带代理支持的 fetch 封装 - * @param {string} url - 请求 URL - * @param {Object} options - fetch 选项 - * @returns {Promise} - */ -async function fetchWithProxy(url, options = {}) { - const proxyConfig = getUpdateProxyConfig(); - - if (proxyConfig) { - // 使用 undici 的 fetch 支持代理 - const fetchOptions = { - ...options, - dispatcher: undefined - }; - - // 根据 URL 协议选择合适的 agent - const urlObj = new URL(url); - if (urlObj.protocol === 'https:') { - fetchOptions.agent = proxyConfig.httpsAgent; - } else { - fetchOptions.agent = proxyConfig.httpAgent; - } - - // Node.js 原生 fetch 不直接支持 agent,需要使用 undici 或 node-fetch - // 这里使用动态导入 undici 来支持代理 - try { - const { fetch: undiciFetch, ProxyAgent } = await import('undici'); - const proxyAgent = new ProxyAgent(CONFIG.PROXY_URL); - return await undiciFetch(url, { - ...options, - dispatcher: proxyAgent - }); - } catch (importError) { - // 如果 undici 不可用,回退到原生 fetch(不使用代理) - logger.warn('[Update] undici not available, falling back to native fetch without proxy'); - return await fetch(url, options); - } - } - - return await fetch(url, options); -} - -/** - * 比较版本号 - * @param {string} v1 - 版本号1 - * @param {string} v2 - 版本号2 - * @returns {number} 1 if v1 > v2, -1 if v1 < v2, 0 if equal - */ -function compareVersions(v1, v2) { - // 移除 'v' 前缀(如果有) - const clean1 = v1.replace(/^v/, ''); - const clean2 = v2.replace(/^v/, ''); - - const parts1 = clean1.split('.').map(Number); - const parts2 = clean2.split('.').map(Number); - - const maxLen = Math.max(parts1.length, parts2.length); - - for (let i = 0; i < maxLen; i++) { - const num1 = parts1[i] || 0; - const num2 = parts2[i] || 0; - - if (num1 > num2) return 1; - if (num1 < num2) return -1; - } - - return 0; -} - -/** - * 通过 GitHub API 获取最新版本 - * @returns {Promise} 最新版本号或 null - */ -async function getLatestVersionFromGitHub() { - const candidates = buildGitHubApiCandidates(GITHUB_REPO); - - for (const candidate of candidates) { - try { - logger.info(`[Update] Fetching latest version from GitHub API via ${candidate.name}...`); - logger.info(`[Update] Request URL: ${candidate.url}`); - const response = await fetchWithProxy(candidate.url, { - headers: { - 'Accept': 'application/vnd.github.v3+json', - 'User-Agent': 'AIClient2API-UpdateChecker' - }, - timeout: 10000 - }); - - if (!response.ok) { - throw new Error(`GitHub API returned ${response.status}: ${response.statusText}`); - } - - const tags = await response.json(); - - if (!Array.isArray(tags) || tags.length === 0) { - logger.warn(`[Update] No tags returned via ${candidate.name}`); - continue; - } - - // 提取版本号并排序 - const versions = tags - .map(tag => tag.name) - .filter(name => /^v?\d+\.\d+/.test(name)); - - if (versions.length === 0) { - logger.warn(`[Update] No valid version tags found via ${candidate.name}`); - continue; - } - - versions.sort((a, b) => compareVersions(b, a)); - logger.info(`[Update] Latest version fetched successfully via ${candidate.name}: ${versions[0]}`); - return versions[0]; - } catch (error) { - logger.warn(`[Update] Failed to fetch latest version via ${candidate.name}: ${error.message}`); - } - } - - logger.warn('[Update] All GitHub API proxy attempts failed'); - return null; -} - -/** - * 检查是否有新版本可用 - * 支持两种模式: - * 1. Git 仓库模式:通过 git 命令获取最新 tag - * 2. Docker/非 Git 模式:通过 GitHub API 获取最新版本 - * @returns {Promise} 更新信息 - */ -export async function checkForUpdates() { - const versionFilePath = path.join(process.cwd(), 'VERSION'); - - // 读取本地版本 - let localVersion = 'unknown'; - try { - if (existsSync(versionFilePath)) { - localVersion = readFileSync(versionFilePath, 'utf-8').trim(); - } - } catch (error) { - logger.warn('[Update] Failed to read local VERSION file:', error.message); - } - - // 检查是否在 git 仓库中 - let isGitRepo = false; - try { - await execAsync('git rev-parse --git-dir'); - isGitRepo = true; - } catch (error) { - isGitRepo = false; - logger.info('[Update] Not in a Git repository, will use GitHub API to check for updates'); - } - - let latestTag = null; - let updateMethod = 'unknown'; - - if (isGitRepo) { - // Git 仓库模式:使用 git 命令 - updateMethod = 'git'; - - // 获取远程 tags - try { - logger.info('[Update] Fetching remote tags...'); - await execAsync('git fetch --tags'); - } catch (error) { - logger.warn('[Update] Failed to fetch tags via git, falling back to GitHub API:', error.message); - // 如果 git fetch 失败,回退到 GitHub API - latestTag = await getLatestVersionFromGitHub(); - updateMethod = 'github_api'; - } - - // 如果 git fetch 成功,获取最新的 tag - if (!latestTag && updateMethod === 'git') { - const isWindows = process.platform === 'win32'; - - try { - if (isWindows) { - // Windows: 使用 git for-each-ref,这是跨平台兼容的方式 - const { stdout } = await execAsync('git for-each-ref --sort=-v:refname --format="%(refname:short)" refs/tags --count=1'); - latestTag = stdout.trim(); - } else { - // Linux/macOS: 使用 head 命令,更高效 - const { stdout } = await execAsync('git tag --sort=-v:refname | head -n 1'); - latestTag = stdout.trim(); - } - } catch (error) { - // 备用方案:获取所有 tags 并在 JavaScript 中排序 - try { - const { stdout } = await execAsync('git tag'); - const tags = stdout.trim().split('\n').filter(t => t); - if (tags.length > 0) { - // 按版本号排序(降序) - tags.sort((a, b) => compareVersions(b, a)); - latestTag = tags[0]; - } - } catch (e) { - logger.warn('[Update] Failed to get latest tag via git, falling back to GitHub API:', e.message); - latestTag = await getLatestVersionFromGitHub(); - updateMethod = 'github_api'; - } - } - } - } else { - // 非 Git 仓库模式(如 Docker 容器):使用 GitHub API - updateMethod = 'github_api'; - latestTag = await getLatestVersionFromGitHub(); - } - - if (!latestTag) { - return { - hasUpdate: false, - localVersion, - latestVersion: null, - updateMethod, - error: 'Unable to get latest version information' - }; - } - - // 比较版本 - const comparison = compareVersions(latestTag, localVersion); - const hasUpdate = comparison > 0; - - logger.info(`[Update] Local version: ${localVersion}, Latest version: ${latestTag}, Has update: ${hasUpdate}, Method: ${updateMethod}`); - - return { - hasUpdate, - localVersion, - latestVersion: latestTag, - updateMethod, - error: null - }; -} - -/** - * 执行更新操作 - * @returns {Promise} 更新结果 - */ -export async function performUpdate() { - // 首先检查是否有更新 - const updateInfo = await checkForUpdates(); - - if (updateInfo.error) { - throw new Error(updateInfo.error); - } - - if (!updateInfo.hasUpdate) { - return { - success: true, - message: 'Already at the latest version', - localVersion: updateInfo.localVersion, - latestVersion: updateInfo.latestVersion, - updated: false - }; - } - - const latestTag = updateInfo.latestVersion; - - // 检查更新方式 - 如果是通过 GitHub API 获取的版本信息,说明不在 Git 仓库中 - if (updateInfo.updateMethod === 'github_api') { - // Docker/非 Git 环境,通过下载 tarball 更新 - logger.info('[Update] Running in Docker/non-Git environment, will download and extract tarball'); - return await performTarballUpdate(updateInfo.localVersion, latestTag); - } - - logger.info(`[Update] Starting update to ${latestTag}...`); - - // 检查是否有未提交的更改 - try { - const { stdout: statusOutput } = await execAsync('git status --porcelain'); - if (statusOutput.trim()) { - // 有未提交的更改,先 stash - logger.info('[Update] Stashing local changes...'); - await execAsync('git stash'); - } - } catch (error) { - logger.warn('[Update] Failed to check git status:', error.message); - } - - // 执行 checkout 到最新 tag - try { - logger.info(`[Update] Checking out to ${latestTag}...`); - await execAsync(`git checkout ${latestTag}`); - } catch (error) { - logger.error('[Update] Failed to checkout:', error.message); - throw new Error('Failed to switch to new version: ' + error.message); - } - - // 更新 VERSION 文件(如果 tag 和 VERSION 文件不同步) - const versionFilePath = path.join(process.cwd(), 'VERSION'); - try { - const newVersion = latestTag.replace(/^v/, ''); - writeFileSync(versionFilePath, newVersion, 'utf-8'); - logger.info(`[Update] VERSION file updated to ${newVersion}`); - } catch (error) { - logger.warn('[Update] Failed to update VERSION file:', error.message); - } - - // 检查是否需要安装依赖 - let needsRestart = false; - try { - // 确保本地版本号有 v 前缀,以匹配 git tag 格式 - const localVersionTag = updateInfo.localVersion.startsWith('v') ? updateInfo.localVersion : `v${updateInfo.localVersion}`; - const { stdout: diffOutput } = await execAsync(`git diff ${localVersionTag}..${latestTag} --name-only`); - if (diffOutput.includes('package.json') || diffOutput.includes('package-lock.json')) { - logger.info('[Update] package.json changed, running npm install...'); - await execAsync('npm install'); - needsRestart = true; - } - } catch (error) { - logger.warn('[Update] Failed to check package changes:', error.message); - } - - logger.info(`[Update] Update completed successfully to ${latestTag}`); - - return { - success: true, - message: `Successfully updated to version ${latestTag}`, - localVersion: updateInfo.localVersion, - latestVersion: latestTag, - updated: true, - updateMethod: 'git', - needsRestart: needsRestart, - restartMessage: needsRestart ? 'Dependencies updated, recommend restarting service to apply changes' : null - }; -} - -/** - * 通过下载 tarball 执行更新(用于 Docker/非 Git 环境) - * @param {string} localVersion - 本地版本 - * @param {string} latestTag - 最新版本 tag - * @returns {Promise} 更新结果 - */ -async function performTarballUpdate(localVersion, latestTag) { - const tarballCandidates = buildTarballCandidates(GITHUB_REPO, latestTag); - const appDir = process.cwd(); - const tempDir = path.join(appDir, '.update_temp'); - const tarballPath = path.join(tempDir, 'update.tar.gz'); - - logger.info(`[Update] Starting tarball update to ${latestTag}...`); - - try { - // 1. 创建临时目录 - await fs.mkdir(tempDir, { recursive: true }); - logger.info('[Update] Created temp directory'); - - // 2. 循环下载 tarball,成功后跳过后续代理 - logger.info('[Update] Downloading tarball via proxy candidates...'); - let downloadSucceeded = false; - let lastDownloadError = null; - - for (const candidate of tarballCandidates) { - try { - logger.info(`[Update] Trying tarball download via ${candidate.name}: ${candidate.url}`); - logger.info(`[Update] Request URL: ${candidate.url}`); - const response = await fetchWithProxy(candidate.url, { - headers: { - 'User-Agent': 'AIClient2API-Updater' - }, - redirect: 'follow' - }); - - if (!response.ok) { - throw new Error(`Failed to download tarball: ${response.status} ${response.statusText}`); - } - - const arrayBuffer = await response.arrayBuffer(); - const buffer = Buffer.from(arrayBuffer); - await fs.writeFile(tarballPath, buffer); - logger.info(`[Update] Downloaded tarball via ${candidate.name} (${buffer.length} bytes)`); - downloadSucceeded = true; - break; - } catch (downloadError) { - lastDownloadError = downloadError; - logger.warn(`[Update] Tarball download failed via ${candidate.name}: ${downloadError.message}`); - } - } - - if (!downloadSucceeded) { - throw new Error(`All tarball proxy attempts failed${lastDownloadError ? `: ${lastDownloadError.message}` : ''}`); - } - - // 3. 解压 tarball - logger.info('[Update] Extracting tarball...'); - await execAsync(`tar -xzf "${tarballPath}" -C "${tempDir}"`); - - // 4. 找到解压后的目录(格式通常是 repo-name-tag) - const extractedItems = await fs.readdir(tempDir); - const extractedDir = extractedItems.find(item => - item.startsWith('AIClient-2-API-') || item.startsWith('AIClient2API-') - ); - - if (!extractedDir) { - throw new Error('Could not find extracted directory'); - } - - const sourcePath = path.join(tempDir, extractedDir); - logger.info(`[Update] Extracted to: ${sourcePath}`); - - // 5. 备份当前的 package.json 用于比较 - const oldPackageJson = existsSync(path.join(appDir, 'package.json')) - ? readFileSync(path.join(appDir, 'package.json'), 'utf-8') - : null; - - // 5.5 在解压前删除 src/ 和 static/ 目录,确保旧代码被完全清除 - const dirsToClean = ['src', 'static']; - for (const dirName of dirsToClean) { - const dirPath = path.join(appDir, dirName); - if (existsSync(dirPath)) { - logger.info(`[Update] Removing old ${dirName}/ directory before extraction...`); - await fs.rm(dirPath, { recursive: true, force: true }); - logger.info(`[Update] Old ${dirName}/ directory removed`); - } - } - - // 6. 定义需要保留的目录和文件(不被覆盖) - const preservePaths = [ - 'configs', // 用户配置目录 - 'node_modules', // 依赖目录 - '.update_temp', // 临时更新目录 - 'logs', // 日志目录 - 'tls-sidecar' // TLS Sidecar 目录 - ]; - - // 7. 复制新文件到应用目录 - logger.info('[Update] Copying new files...'); - const sourceItems = await fs.readdir(sourcePath); - - for (const item of sourceItems) { - // 跳过需要保留的目录 - if (preservePaths.includes(item)) { - logger.info(`[Update] Skipping preserved path: ${item}`); - continue; - } - - const srcItemPath = path.join(sourcePath, item); - const destItemPath = path.join(appDir, item); - - // 删除旧文件/目录(如果存在) - if (existsSync(destItemPath)) { - const stat = await fs.stat(destItemPath); - if (stat.isDirectory()) { - await fs.rm(destItemPath, { recursive: true, force: true }); - } else { - await fs.unlink(destItemPath); - } - } - - // 复制新文件/目录 - await copyRecursive(srcItemPath, destItemPath); - logger.info(`[Update] Copied: ${item}`); - } - - // 8. 检查是否需要更新依赖 - let needsRestart = true; // tarball 更新后总是建议重启 - let needsNpmInstall = false; - - if (oldPackageJson) { - const newPackageJson = readFileSync(path.join(appDir, 'package.json'), 'utf-8'); - if (oldPackageJson !== newPackageJson) { - logger.info('[Update] package.json changed, running npm install...'); - needsNpmInstall = true; - try { - await execAsync('npm install', { cwd: appDir }); - logger.info('[Update] npm install completed'); - } catch (npmError) { - logger.error('[Update] npm install failed:', npmError.message); - // 不抛出错误,继续更新流程 - } - } - } - - // 9. 清理临时目录 - logger.info('[Update] Cleaning up...'); - await fs.rm(tempDir, { recursive: true, force: true }); - - logger.info(`[Update] Tarball update completed successfully to ${latestTag}`); - - return { - success: true, - message: `Successfully updated to version ${latestTag}`, - localVersion: localVersion, - latestVersion: latestTag, - updated: true, - updateMethod: 'tarball', - needsRestart: needsRestart, - needsNpmInstall: needsNpmInstall, - restartMessage: 'Code updated, please restart the service to apply changes' - }; - - } catch (error) { - // 清理临时目录 - try { - if (existsSync(tempDir)) { - await fs.rm(tempDir, { recursive: true, force: true }); - } - } catch (cleanupError) { - logger.warn('[Update] Failed to cleanup temp directory:', cleanupError.message); - } - - logger.error('[Update] Tarball update failed:', error.message); - throw new Error(`Tarball update failed: ${error.message}`); - } -} - -/** - * 递归复制文件或目录 - * @param {string} src - 源路径 - * @param {string} dest - 目标路径 - */ -async function copyRecursive(src, dest) { - const stat = await fs.stat(src); - - if (stat.isDirectory()) { - await fs.mkdir(dest, { recursive: true }); - const items = await fs.readdir(src); - for (const item of items) { - await copyRecursive(path.join(src, item), path.join(dest, item)); - } - } else { - await fs.copyFile(src, dest); - } -} - -/** - * 检查更新 - */ -export async function handleCheckUpdate(req, res) { - try { - const updateInfo = await checkForUpdates(); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(updateInfo)); - return true; - } catch (error) { - logger.error('[UI API] Failed to check for updates:', error); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: 'Failed to check for updates: ' + error.message - } - })); - return true; - } -} - -/** - * 执行更新 - */ -export async function handlePerformUpdate(req, res) { - try { - const updateResult = await performUpdate(); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(updateResult)); - return true; - } catch (error) { - logger.error('[UI API] Failed to perform update:', error); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: 'Update failed: ' + error.message - } - })); - return true; - } -} \ No newline at end of file diff --git a/src/ui-modules/upload-config-api.js b/src/ui-modules/upload-config-api.js deleted file mode 100644 index 8b92d7ef36552f95c037d01aba90fd2c85f2dee2..0000000000000000000000000000000000000000 --- a/src/ui-modules/upload-config-api.js +++ /dev/null @@ -1,365 +0,0 @@ -import { existsSync } from 'fs'; -import logger from '../utils/logger.js'; -import { promises as fs } from 'fs'; -import path from 'path'; -import AdmZip from 'adm-zip'; -import { broadcastEvent } from './event-broadcast.js'; -import { scanConfigFiles } from './config-scanner.js'; - -/** - * 获取上传配置文件列表 - */ -export async function handleGetUploadConfigs(req, res, currentConfig, providerPoolManager) { - try { - const configFiles = await scanConfigFiles(currentConfig, providerPoolManager); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(configFiles)); - return true; - } catch (error) { - logger.error('[UI API] Failed to scan config files:', error); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: 'Failed to scan config files: ' + error.message - } - })); - return true; - } -} - -/** - * 查看特定配置文件 - */ -export async function handleViewConfigFile(req, res, filePath) { - try { - const fullPath = path.join(process.cwd(), filePath); - - // 安全检查:确保文件路径在允许的目录内 - const allowedDirs = ['configs']; - const relativePath = path.relative(process.cwd(), fullPath); - const isAllowed = allowedDirs.some(dir => relativePath.startsWith(dir + path.sep) || relativePath === dir); - - if (!isAllowed) { - res.writeHead(403, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: 'Access denied: can only view files in configs directory' - } - })); - return true; - } - - if (!existsSync(fullPath)) { - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: 'File does not exist' - } - })); - return true; - } - - const content = await fs.readFile(fullPath, 'utf-8'); - const stats = await fs.stat(fullPath); - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - path: relativePath, - content: content, - size: stats.size, - modified: stats.mtime.toISOString(), - name: path.basename(fullPath) - })); - return true; - } catch (error) { - logger.error('[UI API] Failed to view config file:', error); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: 'Failed to view config file: ' + error.message - } - })); - return true; - } -} - -/** - * 下载特定配置文件 - */ -export async function handleDownloadConfigFile(req, res, filePath) { - try { - const fullPath = path.join(process.cwd(), filePath); - - // 安全检查:确保文件路径在允许的目录内 - const allowedDirs = ['configs']; - const relativePath = path.relative(process.cwd(), fullPath); - const isAllowed = allowedDirs.some(dir => relativePath.startsWith(dir + path.sep) || relativePath === dir); - - if (!isAllowed) { - res.writeHead(403, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: 'Access denied: can only download files in configs directory' - } - })); - return true; - } - - if (!existsSync(fullPath)) { - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: 'File does not exist' - } - })); - return true; - } - - const content = await fs.readFile(fullPath); - const fileName = path.basename(fullPath); - - res.writeHead(200, { - 'Content-Type': 'application/octet-stream', - 'Content-Disposition': `attachment; filename="${fileName}"`, - 'Content-Length': content.length - }); - res.end(content); - return true; - } catch (error) { - logger.error('[UI API] Failed to download config file:', error); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: 'Failed to download config file: ' + error.message - } - })); - return true; - } -} - -/** - * 删除特定配置文件 - */ -export async function handleDeleteConfigFile(req, res, filePath) { - try { - const fullPath = path.join(process.cwd(), filePath); - - // 安全检查:确保文件路径在允许的目录内 - const allowedDirs = ['configs']; - const relativePath = path.relative(process.cwd(), fullPath); - const isAllowed = allowedDirs.some(dir => relativePath.startsWith(dir + path.sep) || relativePath === dir); - - if (!isAllowed) { - res.writeHead(403, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: 'Access denied: can only delete files in configs directory' - } - })); - return true; - } - - if (!existsSync(fullPath)) { - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: 'File does not exist' - } - })); - return true; - } - - - await fs.unlink(fullPath); - - // 广播更新事件 - broadcastEvent('config_update', { - action: 'delete', - filePath: relativePath, - timestamp: new Date().toISOString() - }); - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: true, - message: 'File deleted successfully', - filePath: relativePath - })); - return true; - } catch (error) { - logger.error('[UI API] Failed to delete config file:', error); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: 'Failed to delete config file: ' + error.message - } - })); - return true; - } -} - -/** - * 下载所有配置为 zip - */ -export async function handleDownloadAllConfigs(req, res) { - try { - const configsPath = path.join(process.cwd(), 'configs'); - if (!existsSync(configsPath)) { - res.writeHead(404, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: { message: 'configs directory does not exist' } })); - return true; - } - - const zip = new AdmZip(); - - // 递归添加目录函数 - const addDirectoryToZip = async (dirPath, zipPath = '') => { - const items = await fs.readdir(dirPath, { withFileTypes: true }); - for (const item of items) { - const fullPath = path.join(dirPath, item.name); - const itemZipPath = zipPath ? path.join(zipPath, item.name) : item.name; - - if (item.isFile()) { - const content = await fs.readFile(fullPath); - zip.addFile(itemZipPath.replace(/\\/g, '/'), content); - } else if (item.isDirectory()) { - await addDirectoryToZip(fullPath, itemZipPath); - } - } - }; - - await addDirectoryToZip(configsPath); - - const zipBuffer = zip.toBuffer(); - const filename = `configs_backup_${new Date().toISOString().replace(/[:.]/g, '-')}.zip`; - - res.writeHead(200, { - 'Content-Type': 'application/zip', - 'Content-Disposition': `attachment; filename="${filename}"`, - 'Content-Length': zipBuffer.length - }); - res.end(zipBuffer); - - logger.info(`[UI API] All configs downloaded as zip: ${filename}`); - return true; - } catch (error) { - logger.error('[UI API] Failed to download all configs:', error); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: 'Failed to download zip: ' + error.message - } - })); - return true; - } -} - -/** - * 批量删除未绑定的配置文件 - * 只删除 configs/xxx/ 子目录下的未绑定配置文件 - */ -export async function handleDeleteUnboundConfigs(req, res, currentConfig, providerPoolManager) { - try { - // 首先获取所有配置文件及其绑定状态 - const configFiles = await scanConfigFiles(currentConfig, providerPoolManager); - - // 筛选出未绑定的配置文件,并且必须在 configs/xxx/ 子目录下 - // 即路径格式为 configs/子目录名/文件名,而不是直接在 configs/ 根目录下 - const unboundConfigs = configFiles.filter(config => { - if (config.isUsed) return false; - - // 检查路径是否在 configs/xxx/ 子目录下 - // 路径格式应该是 configs/子目录/... - const normalizedPath = config.path.replace(/\\/g, '/'); - const pathParts = normalizedPath.split('/'); - - // 路径至少需要3部分:configs/子目录/文件名 - // 例如:configs/kiro/xxx.json 或 configs/gemini/xxx.json - if (pathParts.length >= 3 && pathParts[0] === 'configs') { - // 确保第二部分是子目录名(不是文件名) - return true; - } - - return false; - }); - - if (unboundConfigs.length === 0) { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: true, - message: 'No unbound config files to delete', - deletedCount: 0, - deletedFiles: [] - })); - return true; - } - - const deletedFiles = []; - const failedFiles = []; - - for (const config of unboundConfigs) { - try { - const fullPath = path.join(process.cwd(), config.path); - - // 安全检查:确保文件路径在允许的目录内 - const allowedDirs = ['configs']; - const relativePath = path.relative(process.cwd(), fullPath); - const isAllowed = allowedDirs.some(dir => relativePath.startsWith(dir + path.sep) || relativePath === dir); - - if (!isAllowed) { - failedFiles.push({ - path: config.path, - error: 'Access denied: can only delete files in configs directory' - }); - continue; - } - - if (!existsSync(fullPath)) { - failedFiles.push({ - path: config.path, - error: 'File does not exist' - }); - continue; - } - - await fs.unlink(fullPath); - deletedFiles.push(config.path); - - } catch (error) { - failedFiles.push({ - path: config.path, - error: error.message - }); - } - } - - // 广播更新事件 - if (deletedFiles.length > 0) { - broadcastEvent('config_update', { - action: 'batch_delete', - deletedFiles: deletedFiles, - timestamp: new Date().toISOString() - }); - } - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - success: true, - message: `Deleted ${deletedFiles.length} unbound config files`, - deletedCount: deletedFiles.length, - deletedFiles: deletedFiles, - failedCount: failedFiles.length, - failedFiles: failedFiles - })); - return true; - } catch (error) { - logger.error('[UI API] Failed to delete unbound configs:', error); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: 'Failed to delete unbound configs: ' + error.message - } - })); - return true; - } -} \ No newline at end of file diff --git a/src/ui-modules/usage-api.js b/src/ui-modules/usage-api.js deleted file mode 100644 index e9c6329d36a2f716a95615174e1a3159e1071003..0000000000000000000000000000000000000000 --- a/src/ui-modules/usage-api.js +++ /dev/null @@ -1,356 +0,0 @@ -import { CONFIG } from '../core/config-manager.js'; -import logger from '../utils/logger.js'; -import { serviceInstances, getServiceAdapter } from '../providers/adapter.js'; -import { formatKiroUsage, formatGeminiUsage, formatAntigravityUsage, formatCodexUsage, formatGrokUsage } from '../services/usage-service.js'; -import { readUsageCache, writeUsageCache, readProviderUsageCache, updateProviderUsageCache } from './usage-cache.js'; -import { PROVIDER_MAPPINGS } from '../utils/provider-utils.js'; -import path from 'path'; - -const supportedProviders = ['claude-kiro-oauth', 'gemini-cli-oauth', 'gemini-antigravity', 'openai-codex-oauth', 'grok-custom']; - - -/** - * 获取所有支持用量查询的提供商的用量信息 - * @param {Object} currentConfig - 当前配置 - * @param {Object} providerPoolManager - 提供商池管理器 - * @returns {Promise} 所有提供商的用量信息 - */ -async function getAllProvidersUsage(currentConfig, providerPoolManager) { - const results = { - timestamp: new Date().toISOString(), - providers: {} - }; - - // 并发获取所有提供商的用量数据 - const usagePromises = supportedProviders.map(async (providerType) => { - try { - const providerUsage = await getProviderTypeUsage(providerType, currentConfig, providerPoolManager); - return { providerType, data: providerUsage, success: true }; - } catch (error) { - return { - providerType, - data: { - error: error.message, - instances: [] - }, - success: false - }; - } - }); - - // 等待所有并发请求完成 - const usageResults = await Promise.all(usagePromises); - - // 将结果整合到 results.providers 中 - for (const result of usageResults) { - results.providers[result.providerType] = result.data; - } - - return results; -} - -/** - * 获取指定提供商类型的用量信息 - * @param {string} providerType - 提供商类型 - * @param {Object} currentConfig - 当前配置 - * @param {Object} providerPoolManager - 提供商池管理器 - * @returns {Promise} 提供商用量信息 - */ -async function getProviderTypeUsage(providerType, currentConfig, providerPoolManager) { - const result = { - providerType, - instances: [], - totalCount: 0, - successCount: 0, - errorCount: 0 - }; - - // 获取提供商池中的所有实例 - let providers = []; - if (providerPoolManager && providerPoolManager.providerPools && providerPoolManager.providerPools[providerType]) { - providers = providerPoolManager.providerPools[providerType]; - } else if (currentConfig.providerPools && currentConfig.providerPools[providerType]) { - providers = currentConfig.providerPools[providerType]; - } - - result.totalCount = providers.length; - - // 遍历所有提供商实例获取用量 - for (const provider of providers) { - const providerKey = providerType + (provider.uuid || ''); - let adapter = serviceInstances[providerKey]; - - const instanceResult = { - uuid: provider.uuid || 'unknown', - name: getProviderDisplayName(provider, providerType), - configFilePath: getProviderConfigFilePath(provider, providerType), - isHealthy: provider.isHealthy !== false, - isDisabled: provider.isDisabled === true, - success: false, - usage: null, - error: null - }; - - // First check if disabled, skip initialization for disabled providers - if (provider.isDisabled) { - instanceResult.error = 'Provider is disabled'; - result.errorCount++; - } else if (!adapter) { - // Service instance not initialized, try auto-initialization - try { - logger.info(`[Usage API] Auto-initializing service adapter for ${providerType}: ${provider.uuid}`); - // Build configuration object - const serviceConfig = { - ...CONFIG, - ...provider, - MODEL_PROVIDER: providerType - }; - adapter = getServiceAdapter(serviceConfig); - } catch (initError) { - logger.error(`[Usage API] Failed to initialize adapter for ${providerType}: ${provider.uuid}:`, initError.message); - instanceResult.error = `Service instance initialization failed: ${initError.message}`; - result.errorCount++; - } - } - - // If adapter exists (including just initialized), and no error, try to get usage - if (adapter && !instanceResult.error) { - try { - const usage = await getAdapterUsage(adapter, providerType); - instanceResult.success = true; - instanceResult.usage = usage; - result.successCount++; - } catch (error) { - instanceResult.error = error.message; - result.errorCount++; - } - } - - result.instances.push(instanceResult); - } - - return result; -} - -/** - * 从适配器获取用量信息 - * @param {Object} adapter - 服务适配器 - * @param {string} providerType - 提供商类型 - * @returns {Promise} 用量信息 - */ -async function getAdapterUsage(adapter, providerType) { - if (providerType === 'claude-kiro-oauth') { - if (typeof adapter.getUsageLimits === 'function') { - const rawUsage = await adapter.getUsageLimits(); - return formatKiroUsage(rawUsage); - } else if (adapter.kiroApiService && typeof adapter.kiroApiService.getUsageLimits === 'function') { - const rawUsage = await adapter.kiroApiService.getUsageLimits(); - return formatKiroUsage(rawUsage); - } - throw new Error('This adapter does not support usage query'); - } - - if (providerType === 'gemini-cli-oauth') { - if (typeof adapter.getUsageLimits === 'function') { - const rawUsage = await adapter.getUsageLimits(); - return formatGeminiUsage(rawUsage); - } else if (adapter.geminiApiService && typeof adapter.geminiApiService.getUsageLimits === 'function') { - const rawUsage = await adapter.geminiApiService.getUsageLimits(); - return formatGeminiUsage(rawUsage); - } - throw new Error('This adapter does not support usage query'); - } - - if (providerType === 'gemini-antigravity') { - if (typeof adapter.getUsageLimits === 'function') { - const rawUsage = await adapter.getUsageLimits(); - return formatAntigravityUsage(rawUsage); - } else if (adapter.antigravityApiService && typeof adapter.antigravityApiService.getUsageLimits === 'function') { - const rawUsage = await adapter.antigravityApiService.getUsageLimits(); - return formatAntigravityUsage(rawUsage); - } - throw new Error('This adapter does not support usage query'); - } - - if (providerType === 'openai-codex-oauth') { - if (typeof adapter.getUsageLimits === 'function') { - const rawUsage = await adapter.getUsageLimits(); - return formatCodexUsage(rawUsage); - } else if (adapter.codexApiService && typeof adapter.codexApiService.getUsageLimits === 'function') { - const rawUsage = await adapter.codexApiService.getUsageLimits(); - return formatCodexUsage(rawUsage); - } - throw new Error('This adapter does not support usage query'); - } - - if (providerType === 'grok-custom') { - if (typeof adapter.getUsageLimits === 'function') { - const rawUsage = await adapter.getUsageLimits(); - return formatGrokUsage(rawUsage); - } - throw new Error('This adapter does not support usage query'); - } - - throw new Error(`Unsupported provider type: ${providerType}`); -} - -/** - * 获取提供商显示名称 - * @param {Object} provider - 提供商配置 - * @param {string} providerType - 提供商类型 - * @returns {string} 显示名称 - */ -function getProviderDisplayName(provider, providerType) { - // 优先使用自定义名称 - if (provider.customName) { - return provider.customName; - } - - if (provider.uuid) { - return provider.uuid; - } - - // 尝试从凭据文件路径提取名称 - const mapping = PROVIDER_MAPPINGS.find(m => m.providerType === providerType); - const credPathKey = mapping ? mapping.credPathKey : null; - - if (credPathKey && provider[credPathKey]) { - const filePath = provider[credPathKey]; - const fileName = path.basename(filePath); - const dirName = path.basename(path.dirname(filePath)); - return `${dirName}/${fileName}`; - } - - return 'Unnamed'; -} - -/** - * 获取提供商配置文件路径 - * @param {Object} provider - 提供商配置 - * @param {string} providerType - 提供商类型 - * @returns {string|null} 配置文件路径 - */ -function getProviderConfigFilePath(provider, providerType) { - const mapping = PROVIDER_MAPPINGS.find(m => m.providerType === providerType); - const credPathKey = mapping ? mapping.credPathKey : null; - - return (credPathKey && provider[credPathKey]) ? provider[credPathKey] : null; -} - -/** - * 获取支持用量查询的提供商列表 - */ -export async function handleGetSupportedProviders(req, res) { - try { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(supportedProviders)); - return true; - } catch (error) { - logger.error('[Usage API] Failed to get supported providers:', error); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: 'Failed to get supported providers: ' + error.message - } - })); - return true; - } -} - -/** - * 获取所有提供商的用量限制 - */ -export async function handleGetUsage(req, res, currentConfig, providerPoolManager) { - try { - // 解析查询参数,检查是否需要强制刷新 - const url = new URL(req.url, `http://${req.headers.host}`); - const refresh = url.searchParams.get('refresh') === 'true'; - - let usageResults; - - if (!refresh) { - // 优先读取缓存 - const cachedData = await readUsageCache(); - if (cachedData) { - logger.info('[Usage API] Returning cached usage data'); - usageResults = { ...cachedData, fromCache: true }; - } - } - - if (!usageResults) { - // 缓存不存在或需要刷新,重新查询 - logger.info('[Usage API] Fetching fresh usage data'); - usageResults = await getAllProvidersUsage(currentConfig, providerPoolManager); - // 写入缓存 - await writeUsageCache(usageResults); - } - - // Always include current server time - const finalResults = { - ...usageResults, - serverTime: new Date().toISOString() - }; - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(finalResults)); - return true; - } catch (error) { - logger.error('[UI API] Failed to get usage:', error); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: 'Failed to get usage info: ' + error.message - } - })); - return true; - } -} - -/** - * 获取特定提供商类型的用量限制 - */ -export async function handleGetProviderUsage(req, res, currentConfig, providerPoolManager, providerType) { - try { - // 解析查询参数,检查是否需要强制刷新 - const url = new URL(req.url, `http://${req.headers.host}`); - const refresh = url.searchParams.get('refresh') === 'true'; - - let usageResults; - - if (!refresh) { - // Prefer reading from cache - const cachedData = await readProviderUsageCache(providerType); - if (cachedData) { - logger.info(`[Usage API] Returning cached usage data for ${providerType}`); - usageResults = { ...cachedData, fromCache: true }; - } - } - - if (!usageResults) { - // Cache does not exist or refresh required, re-query - logger.info(`[Usage API] Fetching fresh usage data for ${providerType}`); - usageResults = await getProviderTypeUsage(providerType, currentConfig, providerPoolManager); - // 更新缓存 - await updateProviderUsageCache(providerType, usageResults); - } - - // Always include current server time - const finalResults = { - ...usageResults, - serverTime: new Date().toISOString() - }; - - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(finalResults)); - return true; - } catch (error) { - logger.error(`[UI API] Failed to get usage for ${providerType}:`, error); - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ - error: { - message: `Failed to get usage info for ${providerType}: ` + error.message - } - })); - return true; - } -} \ No newline at end of file diff --git a/src/ui-modules/usage-cache.js b/src/ui-modules/usage-cache.js deleted file mode 100644 index 1de626b0f1629d0d1eeca6016982a780d91de76b..0000000000000000000000000000000000000000 --- a/src/ui-modules/usage-cache.js +++ /dev/null @@ -1,72 +0,0 @@ -import { existsSync } from 'fs'; -import logger from '../utils/logger.js'; -import { promises as fs } from 'fs'; -import path from 'path'; - -// 用量缓存文件路径 -const USAGE_CACHE_FILE = path.join(process.cwd(), 'configs', 'usage-cache.json'); - -/** - * 读取用量缓存文件 - * @returns {Promise} 缓存的用量数据,如果不存在或读取失败则返回 null - */ -export async function readUsageCache() { - try { - if (existsSync(USAGE_CACHE_FILE)) { - const content = await fs.readFile(USAGE_CACHE_FILE, 'utf8'); - return JSON.parse(content); - } - return null; - } catch (error) { - logger.warn('[Usage Cache] Failed to read usage cache:', error.message); - return null; - } -} - -/** - * 写入用量缓存文件 - * @param {Object} usageData - 用量数据 - */ -export async function writeUsageCache(usageData) { - try { - await fs.writeFile(USAGE_CACHE_FILE, JSON.stringify(usageData, null, 2), 'utf8'); - logger.info('[Usage Cache] Usage data cached to', USAGE_CACHE_FILE); - } catch (error) { - logger.error('[Usage Cache] Failed to write usage cache:', error.message); - } -} - -/** - * 读取特定提供商类型的用量缓存 - * @param {string} providerType - 提供商类型 - * @returns {Promise} 缓存的用量数据 - */ -export async function readProviderUsageCache(providerType) { - const cache = await readUsageCache(); - if (cache && cache.providers && cache.providers[providerType]) { - return { - ...cache.providers[providerType], - cachedAt: cache.timestamp, - fromCache: true - }; - } - return null; -} - -/** - * 更新特定提供商类型的用量缓存 - * @param {string} providerType - 提供商类型 - * @param {Object} usageData - 用量数据 - */ -export async function updateProviderUsageCache(providerType, usageData) { - let cache = await readUsageCache(); - if (!cache) { - cache = { - timestamp: new Date().toISOString(), - providers: {} - }; - } - cache.providers[providerType] = usageData; - cache.timestamp = new Date().toISOString(); - await writeUsageCache(cache); -} \ No newline at end of file diff --git a/src/utils/common.js b/src/utils/common.js deleted file mode 100644 index c031ea4244dd7954d675e88a40eb182f32bf8826..0000000000000000000000000000000000000000 --- a/src/utils/common.js +++ /dev/null @@ -1,1530 +0,0 @@ -import { promises as fs } from 'fs'; -import * as path from 'path'; -import * as http from 'http'; // Add http for IncomingMessage and ServerResponse types -import * as crypto from 'crypto'; // Import crypto for MD5 hashing -import logger from './logger.js'; -import { convertData, getOpenAIStreamChunkStop } from '../convert/convert.js'; -import { ProviderStrategyFactory } from './provider-strategies.js'; -import { getPluginManager } from '../core/plugin-manager.js'; - -// ==================== 网络错误处理 ==================== - -/** - * 可重试的网络错误标识列表 - * 这些错误可能出现在 error.code 或 error.message 中 - */ -export const RETRYABLE_NETWORK_ERRORS = [ - 'ECONNRESET', // 连接被重置 - 'ETIMEDOUT', // 连接超时 - 'ECONNREFUSED', // 连接被拒绝 - 'ENOTFOUND', // DNS 解析失败 - 'ENETUNREACH', // 网络不可达 - 'EHOSTUNREACH', // 主机不可达 - 'EPIPE', // 管道破裂 - 'EAI_AGAIN', // DNS 临时失败 - 'ECONNABORTED', // 连接中止 - 'ESOCKETTIMEDOUT', // Socket 超时 -]; - -/** - * 检查是否为可重试的网络错误 - * @param {Error} error - 错误对象 - * @returns {boolean} - 是否为可重试的网络错误 - */ -export function isRetryableNetworkError(error) { - if (!error) return false; - - const errorCode = error.code || ''; - const errorMessage = error.message || ''; - - return RETRYABLE_NETWORK_ERRORS.some(errId => - errorCode === errId || errorMessage.includes(errId) - ); -} - -// ==================== API 常量 ==================== - -export const API_ACTIONS = { - GENERATE_CONTENT: 'generateContent', - STREAM_GENERATE_CONTENT: 'streamGenerateContent', -}; - -export const MODEL_PROTOCOL_PREFIX = { - // Model provider constants - GEMINI: 'gemini', - OPENAI: 'openai', - OPENAI_RESPONSES: 'openaiResponses', - CLAUDE: 'claude', - CODEX: 'codex', - FORWARD: 'forward', - GROK: 'grok', -} - -export const MODEL_PROVIDER = { - // Model provider constants - GEMINI_CLI: 'gemini-cli-oauth', - ANTIGRAVITY: 'gemini-antigravity', - OPENAI_CUSTOM: 'openai-custom', - OPENAI_CUSTOM_RESPONSES: 'openaiResponses-custom', - CLAUDE_CUSTOM: 'claude-custom', - KIRO_API: 'claude-kiro-oauth', - QWEN_API: 'openai-qwen-oauth', - IFLOW_API: 'openai-iflow', - CODEX_API: 'openai-codex-oauth', - FORWARD_API: 'forward-api', - GROK_CUSTOM: 'grok-custom', - AUTO: 'auto', -} - -/** - * Extracts the protocol prefix from a given model provider string. - * This is used to determine if two providers belong to the same underlying protocol (e.g., gemini, openai, claude). - * @param {string} provider - The model provider string (e.g., 'gemini-cli', 'openai-custom'). - * @returns {string} The protocol prefix (e.g., 'gemini', 'openai', 'claude'). - */ -export function getProtocolPrefix(provider) { - // Special case for Codex - it needs its own protocol - if (provider === 'openai-codex-oauth') { - return 'codex'; - } - - const hyphenIndex = provider.indexOf('-'); - if (hyphenIndex !== -1) { - return provider.substring(0, hyphenIndex); - } - return provider; // Return original if no hyphen is found -} - -export const ENDPOINT_TYPE = { - OPENAI_CHAT: 'openai_chat', - OPENAI_RESPONSES: 'openai_responses', - GEMINI_CONTENT: 'gemini_content', - CLAUDE_MESSAGE: 'claude_message', - OPENAI_MODEL_LIST: 'openai_model_list', - GEMINI_MODEL_LIST: 'gemini_model_list', -}; - -export const FETCH_SYSTEM_PROMPT_FILE = path.join(process.cwd(), 'configs', 'fetch_system_prompt.txt'); -export const INPUT_SYSTEM_PROMPT_FILE = path.join(process.cwd(), 'configs', 'input_system_prompt.txt'); - -export function formatExpiryTime(expiryTimestamp) { - if (!expiryTimestamp || typeof expiryTimestamp !== 'number') return "No expiry date available"; - const diffMs = expiryTimestamp - Date.now(); - if (diffMs <= 0) return "Token has expired"; - let totalSeconds = Math.floor(diffMs / 1000); - const hours = Math.floor(totalSeconds / 3600); - totalSeconds %= 3600; - const minutes = Math.floor(totalSeconds / 60); - const seconds = totalSeconds % 60; - const pad = (num) => String(num).padStart(2, '0'); - return `${pad(hours)}h ${pad(minutes)}m ${pad(seconds)}s`; -} - -/** - * 格式化日志输出,统一日志格式 - * @param {string} tag - 日志标签,如 'Qwen', 'Kiro' 等 - * @param {string} message - 日志消息 - * @param {Object} [data] - 可选的数据对象,将被格式化输出 - * @returns {string} 格式化后的日志字符串 - */ -export function formatLog(tag, message, data = null) { - let logMessage = `[${tag}] ${message}`; - - if (data !== null && data !== undefined) { - if (typeof data === 'object') { - const dataStr = Object.entries(data) - .map(([key, value]) => `${key}: ${value}`) - .join(', '); - logMessage += ` | ${dataStr}`; - } else { - logMessage += ` | ${data}`; - } - } - - return logMessage; -} - -/** - * 格式化凭证过期时间日志 - * @param {string} tag - 日志标签,如 'Qwen', 'Kiro' 等 - * @param {number} expiryDate - 过期时间戳 - * @param {number} nearMinutes - 临近过期的分钟数 - * @returns {{message: string, isNearExpiry: boolean}} 格式化后的日志字符串和是否临近过期 - */ -export function formatExpiryLog(tag, expiryDate, nearMinutes) { - const currentTime = Date.now(); - const nearMinutesInMillis = nearMinutes * 60 * 1000; - const thresholdTime = currentTime + nearMinutesInMillis; - const isNearExpiry = expiryDate <= thresholdTime; - - const message = formatLog(tag, 'Checking expiry date', { - 'Expiry date': expiryDate, - 'Current time': currentTime, - [`${nearMinutes} minutes from now`]: thresholdTime, - 'Is near expiry': isNearExpiry - }); - - return { message, isNearExpiry }; -} - -/** - * Get client IP address from request - * @param {http.IncomingMessage} req - The HTTP request object. - * @returns {string} The client IP address. - */ -export function getClientIp(req) { - const forwarded = req.headers['x-forwarded-for']; - let ip = forwarded ? forwarded.split(',')[0].trim() : req.socket.remoteAddress; - - // Clean up IPv4-mapped IPv6 addresses (e.g., ::ffff:127.0.0.1 -> 127.0.0.1) - if (ip && ip.includes('::ffff:')) { - ip = ip.replace('::ffff:', ''); - } - - return ip || 'unknown'; -} - -/** - * Reads the entire request body from an HTTP request. - * @param {http.IncomingMessage} req - The HTTP request object. - * @returns {Promise} A promise that resolves with the parsed JSON request body. - * @throws {Error} If the request body is not valid JSON. - */ -export function getRequestBody(req) { - return new Promise((resolve, reject) => { - let body = ''; - req.on('data', chunk => { - body += chunk.toString(); - }); - req.on('end', () => { - if (!body) { - return resolve({}); - } - try { - resolve(JSON.parse(body)); - } catch (error) { - reject(new Error("Invalid JSON in request body.")); - } - }); - req.on('error', err => { - reject(err); - }); - }); -} - -export async function logConversation(type, content, logMode, logFilename) { - if (logMode === 'none') return; - if (!content) return; - - const timestamp = new Date().toLocaleString(); - const logEntry = `${timestamp} [${type.toUpperCase()}]:\n${content}\n--------------------------------------\n`; - - if (logMode === 'console') { - logger.info(logEntry); - } else if (logMode === 'file') { - try { - // Append to the file - await fs.appendFile(logFilename, logEntry); - } catch (err) { - logger.error(`[Error] Failed to write conversation log to ${logFilename}:`, err); - } - } -} - -/** - * Checks if the request is authorized based on API key. - * @param {http.IncomingMessage} req - The HTTP request object. - * @param {URL} requestUrl - The parsed URL object. - * @param {string} REQUIRED_API_KEY - The API key required for authorization. - * @returns {boolean} True if authorized, false otherwise. - */ -export function isAuthorized(req, requestUrl, REQUIRED_API_KEY) { - const authHeader = req.headers['authorization']; - const queryKey = requestUrl.searchParams.get('key'); - const googApiKey = req.headers['x-goog-api-key']; - const claudeApiKey = req.headers['x-api-key']; // Claude-specific header - - // Check for Bearer token in Authorization header (OpenAI style) - if (authHeader && authHeader.startsWith('Bearer ')) { - const token = authHeader.substring(7); - if (token === REQUIRED_API_KEY) { - return true; - } - } - - // Check for API key in URL query parameter (Gemini style) - if (queryKey === REQUIRED_API_KEY) { - return true; - } - - // Check for API key in x-goog-api-key header (Gemini style) - if (googApiKey === REQUIRED_API_KEY) { - return true; - } - - // Check for API key in x-api-key header (Claude style) - if (claudeApiKey === REQUIRED_API_KEY) { - return true; - } - - logger.info(`[Auth] Unauthorized request denied. Bearer: "${authHeader ? 'present' : 'N/A'}", Query Key: "${queryKey}", x-goog-api-key: "${googApiKey}", x-api-key: "${claudeApiKey}"`); - return false; -} - -/** - * Handles the common logic for sending API responses (unary and stream). - * This includes writing response headers, logging conversation, and logging auth token expiry. - * @param {http.ServerResponse} res - The HTTP response object. - * @param {Object} responsePayload - The actual response payload (string for unary, object for stream chunks). - * @param {boolean} isStream - Whether the response is a stream. - */ -export async function handleUnifiedResponse(res, responsePayload, isStream) { - if (isStream) { - res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "Transfer-Encoding": "chunked" }); - } else { - res.writeHead(200, { 'Content-Type': 'application/json' }); - } - - if (isStream) { - // Stream chunks are handled by the calling function that iterates the stream - } else { - res.end(responsePayload); - } -} - -export async function handleStreamRequest(res, service, model, requestBody, fromProvider, toProvider, PROMPT_LOG_MODE, PROMPT_LOG_FILENAME, providerPoolManager, pooluuid, customName, retryContext = null) { - let fullResponseText = ''; - let fullResponseJson = ''; - let fullOldResponseJson = ''; - let responseClosed = false; - let anyDataSent = retryContext?.anyDataSent || false; // 跟踪是否已向客户端发送过任何数据 - - // 重试上下文:包含 CONFIG 和重试计数 - // maxRetries: 凭证切换最大次数(跨凭证),默认 5 次 - const maxRetries = retryContext?.maxRetries ?? 5; - const currentRetry = retryContext?.currentRetry ?? 0; - const CONFIG = retryContext?.CONFIG; - const isRetry = currentRetry > 0; - - // 使用共享的 clientDisconnected 状态(如果是重试,继承上层的状态) - let clientDisconnected = retryContext?.clientDisconnected || { value: false }; - if (!isRetry) { - clientDisconnected = { value: false }; // 使用对象引用,便于在递归中共享状态 - } - - // 监听客户端断开连接事件(命名函数,便于移除) - const onClientClose = () => { - clientDisconnected.value = true; - logger.info('[Stream] Client disconnected, stopping stream processing'); - }; - - const onClientError = (err) => { - clientDisconnected.value = true; - logger.error('[Stream] Response stream error:', err.message); - }; - - // 只在首次请求时注册事件监听器(避免重试时重复注册) - if (!isRetry) { - res.on('close', onClientClose); - res.on('error', onClientError); - } - - // 只在首次请求时发送响应头,重试时跳过(响应头已发送) - if (!isRetry) { - await handleUnifiedResponse(res, '', true); - } - - let hasToolCall = false; - let hasMessageStop = false; // 跟踪是否已经发送过结束标志(message_stop / done) - - try { - // fs.writeFile('request'+Date.now()+'.json', JSON.stringify(requestBody)); - // The service returns a stream in its native format (toProvider). - const needsConversion = getProtocolPrefix(fromProvider) !== getProtocolPrefix(toProvider); - requestBody.model = model; - const nativeStream = await service.generateContentStream(model, requestBody); - const addEvent = getProtocolPrefix(fromProvider) === MODEL_PROTOCOL_PREFIX.CLAUDE || getProtocolPrefix(fromProvider) === MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES; - // 为每个请求生成唯一 ID,用于在单例 converter 中隔离并发流状态 - const streamRequestId = `req_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`; - - for await (const nativeChunk of nativeStream) { - // 检查客户端是否已断开连接 - if (clientDisconnected.value) { - logger.info('[Stream] Stopping iteration due to client disconnect'); - break; - } - - // Extract text for logging purposes - const chunkText = extractResponseText(nativeChunk, toProvider); - if (chunkText && !Array.isArray(chunkText)) { - fullResponseText += chunkText; - } - - // Convert the complete chunk object to the client's format (fromProvider), if necessary. - const chunkToSend = needsConversion - ? convertData(nativeChunk, 'streamChunk', toProvider, fromProvider, model, streamRequestId) - : nativeChunk; - - // 监控钩子:流式响应分块 - if (CONFIG?._monitorRequestId) { - try { - const pluginManager = getPluginManager(); - await pluginManager.executeHook('onStreamChunk', { - nativeChunk, - chunkToSend, - fromProvider, - toProvider, - model, - requestId: CONFIG._monitorRequestId - }); - } catch (e) {} - } - - if (!chunkToSend) { - continue; - } - - // 处理 chunkToSend 可能是数组或对象的情况 - const chunksToSend = Array.isArray(chunkToSend) ? chunkToSend : [chunkToSend]; - - for (const chunk of chunksToSend) { - // 再次检查客户端连接状态 - if (clientDisconnected.value) { - break; - } - - // [FIX] 跟踪工具调用并在结束时修正 finish_reason - // OpenAI 格式 - if (chunk.choices?.[0]?.delta?.tool_calls || chunk.choices?.[0]?.finish_reason === 'tool_calls') { - hasToolCall = true; - } - // Claude 格式 - if (chunk.type === 'content_block_start' && chunk.content_block?.type === 'tool_use') { - hasToolCall = true; - } - if (chunk.type === 'message_delta' && (chunk.delta?.stop_reason === 'tool_use' || chunk.stop_reason === 'tool_use')) { - hasToolCall = true; - } - // Gemini 格式 - if (chunk.candidates?.[0]?.content?.parts?.some(p => p.functionCall)) { - hasToolCall = true; - } - - // 如果之前有工具调用,且当前 chunk 是正常结束,修正为 tool_calls / tool_use / FINISH_REASON_TOOL_CALLS - if (hasToolCall && needsConversion) { - if (chunk.choices?.[0]?.finish_reason === 'stop') { - chunk.choices[0].finish_reason = 'tool_calls'; - } else if (chunk.type === 'message_delta' && chunk.delta?.stop_reason === 'end_turn') { - chunk.delta.stop_reason = 'tool_use'; - } else if (chunk.candidates?.[0]?.finishReason === 'STOP' || chunk.candidates?.[0]?.finishReason === 'stop') { - // 修正 Gemini 原生格式的结束原因 - chunk.candidates[0].finishReason = 'TOOL_CALLS'; - } - } - - // 防止重复发送结束标志 - // OpenAI: choices[].finish_reason - // Claude: message_stop - // OpenAI Responses: done - // Gemini: candidates[].finishReason(如 STOP / MAX_TOKENS / SAFETY 等) - if ( - chunk?.choices?.some(choice => choice?.finish_reason) || - chunk?.type === 'message_stop' || - chunk?.type === 'done' || - chunk?.candidates?.some(candidate => candidate?.finishReason) - ) { - hasMessageStop = true; - } - - if (addEvent) { - // fullOldResponseJson += chunk.type+"\n"; - // fullResponseJson += chunk.type+"\n"; - if (!clientDisconnected.value && !res.writableEnded) { - try { - res.write(`event: ${chunk.type}\n`); - anyDataSent = true; - } catch (writeErr) { - logger.error('[Stream] Failed to write event:', writeErr.message); - clientDisconnected.value = true; - break; - } - } - // logger.info(`event: ${chunk.type}\n`); - } - - // fullOldResponseJson += JSON.stringify(chunk)+"\n"; - // fullResponseJson += JSON.stringify(chunk)+"\n\n"; - if (!clientDisconnected.value && !res.writableEnded) { - try { - res.write(`data: ${JSON.stringify(chunk)}\n\n`); - anyDataSent = true; - } catch (writeErr) { - logger.error('[Stream] Failed to write data:', writeErr.message); - clientDisconnected.value = true; - break; - } - } - // logger.info(`data: ${JSON.stringify(chunk)}\n`); - } - } - - // 流式请求成功完成,统计使用次数,错误次数重置为0 - if (providerPoolManager && pooluuid) { - const customNameDisplay = customName ? `, ${customName}` : ''; - logger.info(`[Provider Pool] Increasing usage count for ${toProvider} (${pooluuid}${customNameDisplay}) after successful stream request`); - providerPoolManager.markProviderHealthy(toProvider, { - uuid: pooluuid - }); - } - - } catch (error) { - logger.error('\n[Server] Error during stream processing:', error.stack); - - // 如果客户端已断开,不需要发送错误响应 - if (clientDisconnected.value) { - logger.info('[Stream] Skipping error response due to client disconnect'); - responseClosed = true; - return; - } - - // 如果已经发送了数据(包括 metadata),不进行重试(避免响应数据损坏或顺序错误) - if (anyDataSent) { - logger.info(`[Stream Retry] Cannot retry: data already sent to client`); - // 直接发送错误并结束 - const errorPayload = createStreamErrorResponse(error, fromProvider); - if (!res.writableEnded) { - try { - res.write(errorPayload); - res.end(); - } catch (writeErr) { - logger.error('[Stream] Failed to write error response:', writeErr.message); - } - } - responseClosed = true; - return; - } - - // 获取状态码(用于日志记录,不再用于判断是否重试) - const status = error.response?.status; - - // 检查是否应该跳过错误计数(用于 429/5xx 等需要直接切换凭证的情况) - const skipErrorCount = error.skipErrorCount === true; - // 检查是否应该切换凭证(用于 429/5xx/402/403 等情况) - const shouldSwitchCredential = error.shouldSwitchCredential === true; - - // 检查凭证是否已在底层被标记为不健康(避免重复标记) - let credentialMarkedUnhealthy = error.credentialMarkedUnhealthy === true; - - // 如果底层未标记,且不跳过错误计数,则在此处标记 - if (!credentialMarkedUnhealthy && !skipErrorCount && providerPoolManager && pooluuid) { - // 400 报错码通常是请求参数问题,不记录为提供商错误 - if (error.response?.status === 400) { - logger.info(`[Provider Pool] Skipping unhealthy marking for ${toProvider} (${pooluuid}) due to status 400 (client error)`); - } else { - logger.info(`[Provider Pool] Marking ${toProvider} as unhealthy due to stream error (status: ${status || 'unknown'})`); - // 如果是号池模式,并且请求处理失败,则标记当前使用的提供者为不健康 - providerPoolManager.markProviderUnhealthy(toProvider, { - uuid: pooluuid - }, error.message); - credentialMarkedUnhealthy = true; - } - } - - // 如果需要切换凭证(无论是否标记不健康),都设置标记以触发重试 - if (shouldSwitchCredential && !credentialMarkedUnhealthy) { - credentialMarkedUnhealthy = true; // 触发下面的重试逻辑 - } - - // 凭证已被标记为不健康后,尝试切换到新凭证重试 - // 不再依赖状态码判断,只要凭证被标记不健康且可以重试,就尝试切换 - if (credentialMarkedUnhealthy && currentRetry < maxRetries && providerPoolManager && CONFIG) { - // 增加10秒内的随机等待时间,避免所有请求同时切换凭证 - const randomDelay = Math.floor(Math.random() * 10000); // 0-10000毫秒 - logger.info(`[Stream Retry] Credential marked unhealthy. Waiting ${randomDelay}ms before retry ${currentRetry + 1}/${maxRetries} with different credential...`); - await new Promise(resolve => setTimeout(resolve, randomDelay)); - - try { - // 动态导入以避免循环依赖 - const { getApiServiceWithFallback } = await import('../services/service-manager.js'); - // 使用 acquireSlot: true 以占用新凭证的并发插槽 - const result = await getApiServiceWithFallback(CONFIG, model, { acquireSlot: true }); - - if (result && result.service) { - logger.info(`[Stream Retry] Switched to new credential: ${result.uuid} (provider: ${result.actualProviderType})`); - - // 使用新服务重试 - const newRetryContext = { - ...retryContext, - CONFIG, - currentRetry: currentRetry + 1, - maxRetries, - clientDisconnected, // 传递断开状态 - anyDataSent // 传递数据发送状态 - }; - - // 递归调用,使用新的服务 - return await handleStreamRequest( - res, - result.service, - result.actualModel || model, - requestBody, - fromProvider, - result.actualProviderType || toProvider, - PROMPT_LOG_MODE, - PROMPT_LOG_FILENAME, - providerPoolManager, - result.uuid, - result.serviceConfig?.customName || customName, - newRetryContext - ); - } else { - logger.info(`[Stream Retry] No healthy credential available for retry.`); - } - } catch (retryError) { - logger.error(`[Stream Retry] Failed to get alternative service:`, retryError.message); - } - } - - // 使用新方法创建符合 fromProvider 格式的流式错误响应 - const errorPayload = createStreamErrorResponse(error, fromProvider); - if (!clientDisconnected.value && !res.writableEnded) { - try { - res.write(errorPayload); - res.end(); - } catch (writeErr) { - logger.error('[Stream] Failed to write error response:', writeErr.message); - } - } - responseClosed = true; - } finally { - // 释放并发插槽 - if (providerPoolManager && pooluuid) { - providerPoolManager.releaseSlot(toProvider, pooluuid); - } - - // 只在首次请求时移除事件监听器(避免重试时误删) - if (!isRetry) { - res.off('close', onClientClose); - res.off('error', onClientError); - } - - // 只在非重试或重试失败时才发送结束标记 - // 如果是重试成功,递归调用会处理结束标记 - if (!responseClosed && !clientDisconnected.value && !isRetry) { - // 根据客户端协议发送相应的流式结束标记 - const clientProtocol = getProtocolPrefix(fromProvider); - if (!res.writableEnded) { - try { - if (clientProtocol === MODEL_PROTOCOL_PREFIX.OPENAI) { - if (!hasMessageStop) { - res.write('data: [DONE]\n\n'); - hasMessageStop = true; - } - } else if (clientProtocol === MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES) { - // OpenAI Responses 以 response.completed/response.incomplete(或 error)作为结束事件。 - // 连接关闭即表示流结束;不要再追加 `event: done` + `data: {}`,否则会触发下游类型校验失败(AI_TypeValidationError)。 - } else if (clientProtocol === MODEL_PROTOCOL_PREFIX.CLAUDE) { - if (!hasMessageStop) { - res.write('event: message_stop\n'); - res.write('data: {"type":"message_stop"}\n\n'); - hasMessageStop = true; - } - } else if (clientProtocol === MODEL_PROTOCOL_PREFIX.GEMINI) { - if (!hasMessageStop) { - res.write('data: {"candidates":[{"finishReason":"STOP"}]}\n\n'); - hasMessageStop = true; - } - } - res.end(); - } catch (writeErr) { - logger.error('[Stream] Failed to write completion marker:', writeErr.message); - } - } - } - - // 只在首次请求时记录日志(避免重试时重复记录) - if (!isRetry) { - await logConversation('output', fullResponseText, PROMPT_LOG_MODE, PROMPT_LOG_FILENAME); - } - // fs.writeFile('oldResponseChunk'+Date.now()+'.json', fullOldResponseJson); - // fs.writeFile('responseChunk'+Date.now()+'.json', fullResponseJson); - } -} - - -export async function handleUnaryRequest(res, service, model, requestBody, fromProvider, toProvider, PROMPT_LOG_MODE, PROMPT_LOG_FILENAME, providerPoolManager, pooluuid, customName, retryContext = null) { - // 重试上下文:包含 CONFIG 和重试计数 - // maxRetries: 凭证切换最大次数(跨凭证),默认 5 次 - const maxRetries = retryContext?.maxRetries ?? 5; - const currentRetry = retryContext?.currentRetry ?? 0; - const CONFIG = retryContext?.CONFIG; - - try{ - // The service returns the response in its native format (toProvider). - const needsConversion = getProtocolPrefix(fromProvider) !== getProtocolPrefix(toProvider); - requestBody.model = model; - // fs.writeFile('oldRequest'+Date.now()+'.json', JSON.stringify(requestBody)); - const nativeResponse = await service.generateContent(model, requestBody); - const responseText = extractResponseText(nativeResponse, toProvider); - - // Convert the response back to the client's format (fromProvider), if necessary. - let clientResponse = nativeResponse; - if (needsConversion) { - logger.info(`[Response Convert] Converting response from ${toProvider} to ${fromProvider}`); - clientResponse = convertData(nativeResponse, 'response', toProvider, fromProvider, model); - } - - // 监控钩子:非流式响应 - if (CONFIG?._monitorRequestId) { - try { - const pluginManager = getPluginManager(); - await pluginManager.executeHook('onUnaryResponse', { - nativeResponse, - clientResponse, - fromProvider, - toProvider, - model, - requestId: CONFIG._monitorRequestId - }); - } catch (e) {} - } - - //logger.info(`[Response] Sending response to client: ${JSON.stringify(clientResponse)}`); - await handleUnifiedResponse(res, JSON.stringify(clientResponse), false); - await logConversation('output', responseText, PROMPT_LOG_MODE, PROMPT_LOG_FILENAME); - // fs.writeFile('oldResponse'+Date.now()+'.json', JSON.stringify(clientResponse)); - - // 一元请求成功完成,统计使用次数,错误次数重置为0 - if (providerPoolManager && pooluuid) { - const customNameDisplay = customName ? `, ${customName}` : ''; - logger.info(`[Provider Pool] Increasing usage count for ${toProvider} (${pooluuid}${customNameDisplay}) after successful unary request`); - providerPoolManager.markProviderHealthy(toProvider, { - uuid: pooluuid - }); - } - } catch (error) { - logger.error('\n[Server] Error during unary processing:', error.stack); - - // 获取状态码(用于日志记录,不再用于判断是否重试) - const status = error.response?.status; - - // 检查是否应该跳过错误计数(用于 429/5xx 等需要直接切换凭证的情况) - const skipErrorCount = error.skipErrorCount === true; - // 检查是否应该切换凭证(用于 429/5xx/402/403 等情况) - const shouldSwitchCredential = error.shouldSwitchCredential === true; - - // 检查凭证是否已在底层被标记为不健康(避免重复标记) - let credentialMarkedUnhealthy = error.credentialMarkedUnhealthy === true; - - // 如果底层未标记,且不跳过错误计数,则在此处标记 - if (!credentialMarkedUnhealthy && !skipErrorCount && providerPoolManager && pooluuid) { - // 400 报错码通常是请求参数问题,不记录为提供商错误 - if (error.response?.status === 400) { - logger.info(`[Provider Pool] Skipping unhealthy marking for ${toProvider} (${pooluuid}) due to status 400 (client error)`); - } else { - logger.info(`[Provider Pool] Marking ${toProvider} as unhealthy due to unary error (status: ${status || 'unknown'})`); - // 如果是号池模式,并且请求处理失败,则标记当前使用的提供者为不健康 - providerPoolManager.markProviderUnhealthy(toProvider, { - uuid: pooluuid - }, error.message); - credentialMarkedUnhealthy = true; - } - } - - // 如果需要切换凭证(无论是否标记不健康),都设置标记以触发重试 - if (shouldSwitchCredential && !credentialMarkedUnhealthy) { - credentialMarkedUnhealthy = true; // 触发下面的重试逻辑 - } - - // 凭证已被标记为不健康后,尝试切换到新凭证重试 - // 不再依赖状态码判断,只要凭证被标记不健康且可以重试,就尝试切换 - if (credentialMarkedUnhealthy && currentRetry < maxRetries && providerPoolManager && CONFIG) { - // 增加10秒内的随机等待时间,避免所有请求同时切换凭证 - const randomDelay = Math.floor(Math.random() * 10000); // 0-10000毫秒 - logger.info(`[Unary Retry] Credential marked unhealthy. Waiting ${randomDelay}ms before retry ${currentRetry + 1}/${maxRetries} with different credential...`); - await new Promise(resolve => setTimeout(resolve, randomDelay)); - - try { - // 动态导入以避免循环依赖 - const { getApiServiceWithFallback } = await import('../services/service-manager.js'); - // 使用 acquireSlot: true 以占用新凭证的并发插槽 - const result = await getApiServiceWithFallback(CONFIG, model, { acquireSlot: true }); - - if (result && result.service) { - logger.info(`[Unary Retry] Switched to new credential: ${result.uuid} (provider: ${result.actualProviderType})`); - - // 使用新服务重试 - const newRetryContext = { - ...retryContext, - CONFIG, - currentRetry: currentRetry + 1, - maxRetries - }; - - // 递归调用,使用新的服务 - return await handleUnaryRequest( - res, - result.service, - result.actualModel || model, - requestBody, - fromProvider, - result.actualProviderType || toProvider, - PROMPT_LOG_MODE, - PROMPT_LOG_FILENAME, - providerPoolManager, - result.uuid, - result.serviceConfig?.customName || customName, - newRetryContext - ); - } else { - logger.info(`[Unary Retry] No healthy credential available for retry.`); - } - } catch (retryError) { - logger.error(`[Unary Retry] Failed to get alternative service:`, retryError.message); - } - } - - // 使用新方法创建符合 fromProvider 格式的错误响应 - const errorResponse = createErrorResponse(error, fromProvider); - await handleUnifiedResponse(res, JSON.stringify(errorResponse), false); - } finally { - // 确保在请求结束或出错时释放插槽 - if (providerPoolManager && pooluuid) { - providerPoolManager.releaseSlot(toProvider, pooluuid); - } - } -} - -/** - * Handles requests for listing available models. It fetches models from the - * service, transforms them to the format expected by the client (OpenAI, Claude, etc.), - * and sends the JSON response. - * @param {http.IncomingMessage} req The HTTP request object. - * @param {http.ServerResponse} res The HTTP response object. - * @param {Object} service - The API service instance. - * @param {string} endpointType The type of endpoint being called (e.g., OPENAI_MODEL_LIST). - * @param {Object} CONFIG - The server configuration object. - * @param {Object} providerPoolManager - The provider pool manager instance. - * @param {string} pooluuid - The selected provider UUID. - */ -export async function handleModelListRequest(req, res, service, endpointType, CONFIG, providerPoolManager, pooluuid) { - try { - const clientProviderMap = { - [ENDPOINT_TYPE.OPENAI_MODEL_LIST]: MODEL_PROTOCOL_PREFIX.OPENAI, - [ENDPOINT_TYPE.GEMINI_MODEL_LIST]: MODEL_PROTOCOL_PREFIX.GEMINI, - }; - - const fromProvider = clientProviderMap[endpointType]; - - if (!fromProvider) { - throw new Error(`Unsupported endpoint type for model list: ${endpointType}`); - } - - let clientModelList; - - // --- 核心逻辑: auto 路由模式下的模型聚合 --- - if (CONFIG.MODEL_PROVIDER === MODEL_PROVIDER.AUTO && providerPoolManager) { - logger.info(`[ModelList] Aggregating models for 'auto' mode...`); - clientModelList = await providerPoolManager.getAllAvailableModels(endpointType); - } else { - // --- 单提供商逻辑 --- - const toProvider = CONFIG.MODEL_PROVIDER; - - // service 可能未在上层预先注入(例如仅改了路径 provider 前缀),这里兜底获取 - let resolvedService = service; - if (!resolvedService) { - const { getApiService } = await import('../services/service-manager.js'); - resolvedService = await getApiService(CONFIG, null, { skipUsageCount: true }); - } - - if (!resolvedService || typeof resolvedService.listModels !== 'function') { - throw new Error(`[ModelList] Service adapter is unavailable or does not implement listModels() for provider: ${toProvider}`); - } - - // 1. Get the model list in the backend's native format. - const nativeModelList = await resolvedService.listModels(); - - // 2. Convert the model list to the client's expected format, if necessary. - clientModelList = nativeModelList; - if (!getProtocolPrefix(toProvider).includes(getProtocolPrefix(fromProvider))) { - logger.info(`[ModelList Convert] Converting model list from ${toProvider} to ${fromProvider}`); - clientModelList = convertData(nativeModelList, 'modelList', toProvider, fromProvider); - } else { - logger.info(`[ModelList Convert] Model list format matches. No conversion needed.`); - } - } - - // logger.info(`[ModelList Response] Sending model list to client: ${JSON.stringify(clientModelList)}`); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(clientModelList)); - } catch (error) { - logger.error('\n[Server] Error during model list processing:', error.stack); - // if (providerPoolManager && pooluuid && CONFIG.MODEL_PROVIDER !== MODEL_PROVIDER.AUTO) { - // // 如果是号池模式(且非 auto 模式),并且请求处理失败,则标记当前使用的提供者为不健康 - // providerPoolManager.markProviderUnhealthy(CONFIG.MODEL_PROVIDER, { - // uuid: pooluuid - // }, error.message); - // } - handleError(res, error, CONFIG.MODEL_PROVIDER); - } -} - - -/** - * Handles requests for content generation (both unary and streaming). This function - * orchestrates request body parsing, conversion to the internal Gemini format, - * logging, and dispatching to the appropriate stream or unary handler. - * @param {http.IncomingMessage} req The HTTP request object. - * @param {http.ServerResponse} res The HTTP response object. - * @param {string} endpointType The type of endpoint being called (e.g., OPENAI_CHAT). - * @param {Object} CONFIG - The server configuration object. - * @param {string} PROMPT_LOG_FILENAME - The prompt log filename. - */ -export async function handleContentGenerationRequest(req, res, service, endpointType, CONFIG, PROMPT_LOG_FILENAME, providerPoolManager, pooluuid, requestPath = null) { - const originalRequestBody = await getRequestBody(req); - - if (!originalRequestBody) { - throw new Error("Request body is missing for content generation."); - } - - const clientProviderMap = { - [ENDPOINT_TYPE.OPENAI_CHAT]: MODEL_PROTOCOL_PREFIX.OPENAI, - [ENDPOINT_TYPE.OPENAI_RESPONSES]: MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES, - [ENDPOINT_TYPE.CLAUDE_MESSAGE]: MODEL_PROTOCOL_PREFIX.CLAUDE, - [ENDPOINT_TYPE.GEMINI_CONTENT]: MODEL_PROTOCOL_PREFIX.GEMINI, - }; - - const fromProvider = clientProviderMap[endpointType]; - // 使用实际的提供商类型(可能是 fallback 后的类型) - let toProvider = CONFIG.actualProviderType || CONFIG.MODEL_PROVIDER; - let actualUuid = pooluuid; - - if (!fromProvider) { - throw new Error(`Unsupported endpoint type for content generation: ${endpointType}`); - } - - // 2. Extract model and determine if the request is for streaming. - let { model, isStream } = _extractModelAndStreamInfo(req, originalRequestBody, fromProvider); - - if (!model) { - throw new Error("Could not determine the model from the request."); - } - logger.info(`[Content Generation] Model: ${model}, Stream: ${isStream}`); - - let actualCustomName = CONFIG.customName; - - // 2.5. 根据模型选择服务适配器: - // - service 缺失时(例如上游未预先注入)进行兜底选择 - // - 使用号池/AUTO 时按模型重选并支持 fallback - // 注意:仅在号池场景开启 acquireSlot,占用并发名额或进入队列 - const shouldSelectByPool = providerPoolManager && (CONFIG.MODEL_PROVIDER === MODEL_PROVIDER.AUTO || (CONFIG.providerPools && CONFIG.providerPools[CONFIG.MODEL_PROVIDER])); - if (!service || shouldSelectByPool) { - const { getApiServiceWithFallback } = await import('../services/service-manager.js'); - const result = await getApiServiceWithFallback(CONFIG, model, { acquireSlot: shouldSelectByPool }); - - service = result.service; - toProvider = result.actualProviderType; - actualUuid = result.uuid || pooluuid; - actualCustomName = result.serviceConfig?.customName || CONFIG.customName; - - // 如果发生了模型级别的 fallback,需要更新请求使用的模型 - if (result.actualModel && result.actualModel !== model) { - logger.info(`[Content Generation] Model Fallback: ${model} -> ${result.actualModel}`); - model = result.actualModel; - } - - if (result.isFallback) { - logger.info(`[Content Generation] Fallback activated: ${CONFIG.MODEL_PROVIDER} -> ${toProvider} (uuid: ${actualUuid})`); - } else { - logger.info(`[Content Generation] Selected service adapter based on model: ${model}`); - } - } - - // 1. Convert request body from client format to backend format, if necessary. - let processedRequestBody = originalRequestBody; - // 将 _monitorRequestId 注入到 requestBody 中,以便在 service 内部访问 - if (CONFIG._monitorRequestId) { - processedRequestBody._monitorRequestId = CONFIG._monitorRequestId; - } - - // 将 requestBaseUrl 注入到 requestBody 中,以便在转换器中使用 - if (CONFIG.requestBaseUrl) { - processedRequestBody._requestBaseUrl = CONFIG.requestBaseUrl; - } - - // fs.writeFile('originalRequestBody'+Date.now()+'.json', JSON.stringify(originalRequestBody)); - if (getProtocolPrefix(fromProvider) !== getProtocolPrefix(toProvider)) { - logger.info(`[Request Convert] Converting request from ${fromProvider} to ${toProvider}`); - processedRequestBody = convertData(originalRequestBody, 'request', fromProvider, toProvider); - } else { - logger.info(`[Request Convert] Request format matches backend provider. No conversion needed.`); - } - - // 为 forward provider 添加原始请求路径作为 endpoint - if (requestPath && toProvider === MODEL_PROVIDER.FORWARD_API) { - logger.info(`[Forward API] Request path: ${requestPath}`); - processedRequestBody.endpoint = requestPath; - } - - // 3. Apply system prompt from file if configured. - processedRequestBody = await _applySystemPromptFromFile(CONFIG, processedRequestBody, toProvider); - await _manageSystemPrompt(processedRequestBody, toProvider); - - // 4. Log the incoming prompt (after potential conversion to the backend's format). - const promptText = extractPromptText(processedRequestBody, toProvider); - await logConversation('input', promptText, CONFIG.PROMPT_LOG_MODE, PROMPT_LOG_FILENAME); - - // 5. Call the appropriate stream or unary handler, passing the provider info. - // 创建重试上下文,包含 CONFIG 以便在认证错误时切换凭证重试 - // 凭证切换重试次数(默认 5),可在配置中自定义更大的值 - // 注意:这与底层的 429/5xx 重试(REQUEST_MAX_RETRIES)是不同层次的重试机制 - // - 底层重试:同一凭证遇到 429/5xx 时的重试 - // - 凭证切换重试:凭证被标记不健康后切换到其他凭证 - // 当没有不同的健康凭证可用时,重试会自动停止 - const credentialSwitchMaxRetries = CONFIG.CREDENTIAL_SWITCH_MAX_RETRIES || 5; - const retryContext = providerPoolManager ? { CONFIG, currentRetry: 0, maxRetries: credentialSwitchMaxRetries } : null; - - if (isStream) { - await handleStreamRequest(res, service, model, processedRequestBody, fromProvider, toProvider, CONFIG.PROMPT_LOG_MODE, PROMPT_LOG_FILENAME, providerPoolManager, actualUuid, actualCustomName, retryContext); - } else { - await handleUnaryRequest(res, service, model, processedRequestBody, fromProvider, toProvider, CONFIG.PROMPT_LOG_MODE, PROMPT_LOG_FILENAME, providerPoolManager, actualUuid, actualCustomName, retryContext); - } - - // 执行插件钩子:内容生成后 - try { - const pluginManager = getPluginManager(); - await pluginManager.executeHook('onContentGenerated', { - ...CONFIG, - originalRequestBody, - processedRequestBody, - fromProvider, - toProvider, - model, - isStream - }); - } catch (e) { /* 静默失败,不影响主流程 */ } -} - -/** - * Helper function to extract model and stream information from the request. - * @param {http.IncomingMessage} req The HTTP request object. - * @param {Object} requestBody The parsed request body. - * @param {string} fromProvider The type of endpoint being called. - * @returns {{model: string, isStream: boolean}} An object containing the model name and stream status. - */ -function _extractModelAndStreamInfo(req, requestBody, fromProvider) { - const strategy = ProviderStrategyFactory.getStrategy(getProtocolPrefix(fromProvider)); - return strategy.extractModelAndStreamInfo(req, requestBody); -} - -async function _applySystemPromptFromFile(config, requestBody, toProvider) { - const strategy = ProviderStrategyFactory.getStrategy(getProtocolPrefix(toProvider)); - return strategy.applySystemPromptFromFile(config, requestBody); -} - -export async function _manageSystemPrompt(requestBody, provider) { - const strategy = ProviderStrategyFactory.getStrategy(getProtocolPrefix(provider)); - await strategy.manageSystemPrompt(requestBody); -} - -// Helper functions for content extraction and conversion (from convert.js, but needed here) -export function extractResponseText(response, provider) { - const strategy = ProviderStrategyFactory.getStrategy(getProtocolPrefix(provider)); - return strategy.extractResponseText(response); -} - -export function extractPromptText(requestBody, provider) { - const strategy = ProviderStrategyFactory.getStrategy(getProtocolPrefix(provider)); - return strategy.extractPromptText(requestBody); -} - -export function handleError(res, error, provider = null) { - const statusCode = error.response?.status || error.statusCode || error.status || error.code || 500; - let errorMessage = error.message; - let suggestions = []; - - // 仅在没有传入错误信息时,才使用默认消息;否则只添加建议 - const hasOriginalMessage = error.message && error.message.trim() !== ''; - - // 根据提供商获取适配的错误信息和建议 - const providerSuggestions = _getProviderSpecificSuggestions(statusCode, provider); - - // Provide detailed information and suggestions for different error types - switch (statusCode) { - case 401: - errorMessage = 'Authentication failed. Please check your credentials.'; - suggestions = providerSuggestions.auth; - break; - case 403: - errorMessage = 'Access forbidden. Insufficient permissions.'; - suggestions = providerSuggestions.permission; - break; - case 429: - errorMessage = 'Too many requests. Rate limit exceeded.'; - suggestions = providerSuggestions.rateLimit; - break; - case 500: - case 502: - case 503: - case 504: - errorMessage = 'Server error occurred. This is usually temporary.'; - suggestions = providerSuggestions.serverError; - break; - default: - if (statusCode >= 400 && statusCode < 500) { - errorMessage = `Client error (${statusCode}): ${error.message}`; - suggestions = providerSuggestions.clientError; - } else if (statusCode >= 500) { - errorMessage = `Server error (${statusCode}): ${error.message}`; - suggestions = providerSuggestions.serverError; - } - } - - errorMessage = hasOriginalMessage ? error.message.trim() : errorMessage; - logger.error(`\n[Server] Request failed (${statusCode}): ${errorMessage}`); - if (suggestions.length > 0) { - logger.error('[Server] Suggestions:'); - suggestions.forEach((suggestion, index) => { - logger.error(` ${index + 1}. ${suggestion}`); - }); - } - logger.error('[Server] Full error details:', error.stack); - - // 检查响应流是否已关闭或结束 - if (res.writableEnded || res.destroyed) { - logger.warn('[Server] Response already ended or destroyed, skipping error response'); - return; - } - - if (!res.headersSent) { - res.writeHead(statusCode, { 'Content-Type': 'application/json' }); - } - - const errorPayload = { - error: { - message: errorMessage, - code: statusCode, - suggestions: suggestions, - details: error.response?.data - } - }; - - try { - res.end(JSON.stringify(errorPayload)); - } catch (writeError) { - logger.error('[Server] Failed to write error response:', writeError.message); - } -} - -/** - * 根据提供商类型获取适配的错误建议 - * @param {number} statusCode - HTTP 状态码 - * @param {string|null} provider - 提供商类型 - * @returns {Object} 包含各类错误建议的对象 - */ -function _getProviderSpecificSuggestions(statusCode, provider) { - const protocolPrefix = provider ? getProtocolPrefix(provider) : null; - - // 默认/通用建议 - const defaultSuggestions = { - auth: [ - 'Verify your API key or credentials are valid', - 'Check if your credentials have expired', - 'Ensure the API key has the necessary permissions' - ], - permission: [ - 'Check if your account has the necessary permissions', - 'Verify the API endpoint is accessible with your credentials', - 'Contact your administrator if permissions are restricted' - ], - rateLimit: [ - 'The request has been automatically retried with exponential backoff', - 'If the issue persists, try reducing the request frequency', - 'Consider upgrading your API quota if available' - ], - serverError: [ - 'The request has been automatically retried', - 'If the issue persists, try again in a few minutes', - 'Check the service status page for outages' - ], - clientError: [ - 'Check your request format and parameters', - 'Verify the model name is correct', - 'Ensure all required fields are provided' - ] - }; - - // 根据提供商返回特定建议 - switch (protocolPrefix) { - case MODEL_PROTOCOL_PREFIX.GEMINI: - return { - auth: [ - 'Verify your OAuth credentials are valid', - 'Try re-authenticating by deleting the credentials file', - 'Check if your Google Cloud project has the necessary permissions' - ], - permission: [ - 'Ensure your Google Cloud project has the Gemini API enabled', - 'Check if your account has the necessary permissions', - 'Verify the project ID is correct' - ], - rateLimit: [ - 'The request has been automatically retried with exponential backoff', - 'If the issue persists, try reducing the request frequency', - 'Consider upgrading your Google Cloud API quota' - ], - serverError: [ - 'The request has been automatically retried', - 'If the issue persists, try again in a few minutes', - 'Check Google Cloud status page for service outages' - ], - clientError: [ - 'Check your request format and parameters', - 'Verify the model name is a valid Gemini model', - 'Ensure all required fields are provided' - ] - }; - - case MODEL_PROTOCOL_PREFIX.OPENAI: - case MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES: - return { - auth: [ - 'Verify your OpenAI API key is valid', - 'Check if your API key has expired or been revoked', - 'Ensure the API key is correctly formatted (starts with sk-)' - ], - permission: [ - 'Check if your OpenAI account has access to the requested model', - 'Verify your organization settings allow this operation', - 'Ensure you have sufficient credits in your account' - ], - rateLimit: [ - 'The request has been automatically retried with exponential backoff', - 'If the issue persists, try reducing the request frequency', - 'Consider upgrading your OpenAI usage tier for higher limits' - ], - serverError: [ - 'The request has been automatically retried', - 'If the issue persists, try again in a few minutes', - 'Check OpenAI status page (status.openai.com) for outages' - ], - clientError: [ - 'Check your request format and parameters', - 'Verify the model name is a valid OpenAI model', - 'Ensure the message format is correct (role and content fields)' - ] - }; - - case MODEL_PROTOCOL_PREFIX.CLAUDE: - return { - auth: [ - 'Verify your Anthropic API key is valid', - 'Check if your API key has expired or been revoked', - 'Ensure the x-api-key header is correctly set' - ], - permission: [ - 'Check if your Anthropic account has access to the requested model', - 'Verify your account is in good standing', - 'Ensure you have sufficient credits in your account' - ], - rateLimit: [ - 'The request has been automatically retried with exponential backoff', - 'If the issue persists, try reducing the request frequency', - 'Consider upgrading your Anthropic usage tier for higher limits' - ], - serverError: [ - 'The request has been automatically retried', - 'If the issue persists, try again in a few minutes', - 'Check Anthropic status page for service outages' - ], - clientError: [ - 'Check your request format and parameters', - 'Verify the model name is a valid Claude model', - 'Ensure the message format follows Anthropic API specifications' - ] - }; - - default: - return defaultSuggestions; - } -} - -/** - * 从请求体中提取系统提示词。 - * @param {Object} requestBody - 请求体对象。 - * @param {string} provider - 提供商类型('openai', 'gemini', 'claude')。 - * @returns {string} 提取到的系统提示词字符串。 - */ -export function extractSystemPromptFromRequestBody(requestBody, provider) { - let incomingSystemText = ''; - switch (provider) { - case MODEL_PROTOCOL_PREFIX.OPENAI: - const openaiSystemMessage = requestBody.messages?.find(m => m.role === 'system'); - if (openaiSystemMessage?.content) { - incomingSystemText = openaiSystemMessage.content; - } else if (requestBody.messages?.length > 0) { - // Fallback to first user message if no system message - const userMessage = requestBody.messages.find(m => m.role === 'user'); - if (userMessage) { - incomingSystemText = userMessage.content; - } - } - break; - case MODEL_PROTOCOL_PREFIX.GEMINI: - const geminiSystemInstruction = requestBody.system_instruction || requestBody.systemInstruction; - if (geminiSystemInstruction?.parts) { - incomingSystemText = geminiSystemInstruction.parts - .filter(p => p?.text) - .map(p => p.text) - .join('\n'); - } else if (requestBody.contents?.length > 0) { - // Fallback to first user content if no system instruction - const userContent = requestBody.contents[0]; - if (userContent?.parts) { - incomingSystemText = userContent.parts - .filter(p => p?.text) - .map(p => p.text) - .join('\n'); - } - } - break; - case MODEL_PROTOCOL_PREFIX.CLAUDE: - if (typeof requestBody.system === 'string') { - incomingSystemText = requestBody.system; - } else if (typeof requestBody.system === 'object') { - incomingSystemText = JSON.stringify(requestBody.system); - } else if (requestBody.messages?.length > 0) { - // Fallback to first user message if no system property - const userMessage = requestBody.messages.find(m => m.role === 'user'); - if (userMessage) { - if (Array.isArray(userMessage.content)) { - incomingSystemText = userMessage.content.map(block => block.text).join(''); - } else { - incomingSystemText = userMessage.content; - } - } - } - break; - default: - logger.warn(`[System Prompt] Unknown provider: ${provider}`); - break; - } - return incomingSystemText; -} - -/** - * Generates an MD5 hash for a given object by first converting it to a JSON string. - * @param {object} obj - The object to hash. - * @returns {string} The MD5 hash of the object's JSON string representation. - */ -export function getMD5Hash(obj) { - const jsonString = JSON.stringify(obj); - return crypto.createHash('md5').update(jsonString).digest('hex'); -} - -/** - * 将日期转换为系统本地时间格式 - * @param {string|number} dateInput - 日期字符串或时间戳 - * @returns {string} 格式化后的时间字符串 - */ -export function formatToLocal(dateInput) { - try { - if (!dateInput) return '--'; - // 处理数值型时间戳(秒 -> 毫秒) - let finalInput = dateInput; - if (typeof dateInput === 'number' && dateInput < 10000000000) { - finalInput = dateInput * 1000; - } - const date = new Date(finalInput); - if (isNaN(date.getTime())) return '--'; - - return date.toLocaleString('zh-CN', { - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - hour12: false - }).replace(/\//g, '-'); - } catch (e) { - return '--'; - } -} - - -/** - * 创建符合 fromProvider 格式的错误响应(非流式) - * @param {Error} error - 错误对象 - * @param {string} fromProvider - 客户端期望的提供商格式 - * @returns {Object} 格式化的错误响应对象 - */ -function createErrorResponse(error, fromProvider) { - const protocolPrefix = getProtocolPrefix(fromProvider); - const statusCode = error.status || error.code || 500; - const errorMessage = error.message || "An error occurred during processing."; - - // 根据 HTTP 状态码映射错误类型 - const getErrorType = (code) => { - if (code === 401) return 'authentication_error'; - if (code === 403) return 'permission_error'; - if (code === 429) return 'rate_limit_error'; - if (code >= 500) return 'server_error'; - return 'invalid_request_error'; - }; - - // 根据 HTTP 状态码映射 Gemini 的 status - const getGeminiStatus = (code) => { - if (code === 400) return 'INVALID_ARGUMENT'; - if (code === 401) return 'UNAUTHENTICATED'; - if (code === 403) return 'PERMISSION_DENIED'; - if (code === 404) return 'NOT_FOUND'; - if (code === 429) return 'RESOURCE_EXHAUSTED'; - if (code >= 500) return 'INTERNAL'; - return 'UNKNOWN'; - }; - - switch (protocolPrefix) { - case MODEL_PROTOCOL_PREFIX.OPENAI: - // OpenAI 非流式错误格式 - return { - error: { - message: errorMessage, - type: getErrorType(statusCode), - code: getErrorType(statusCode) // OpenAI 使用 code 字段作为核心判断 - } - }; - - case MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES: - // OpenAI Responses API 非流式错误格式 - return { - error: { - type: getErrorType(statusCode), - message: errorMessage, - code: getErrorType(statusCode) - } - }; - - case MODEL_PROTOCOL_PREFIX.CLAUDE: - // Claude 非流式错误格式(外层有 type 标记) - return { - type: "error", // 核心区分标记 - error: { - type: getErrorType(statusCode), // Claude 使用 error.type 作为核心判断 - message: errorMessage - } - }; - - case MODEL_PROTOCOL_PREFIX.GEMINI: - // Gemini 非流式错误格式(遵循 Google Cloud 标准) - return { - error: { - code: statusCode, - message: errorMessage, - status: getGeminiStatus(statusCode) // Gemini 使用 status 作为核心判断 - } - }; - - default: - // 默认使用 OpenAI 格式 - return { - error: { - message: errorMessage, - type: getErrorType(statusCode), - code: getErrorType(statusCode) - } - }; - } -} - -/** - * 创建符合 fromProvider 格式的流式错误响应 - * @param {Error} error - 错误对象 - * @param {string} fromProvider - 客户端期望的提供商格式 - * @returns {string} 格式化的流式错误响应字符串 - */ -function createStreamErrorResponse(error, fromProvider) { - const protocolPrefix = getProtocolPrefix(fromProvider); - const statusCode = error.status || error.code || 500; - const errorMessage = error.message || "An error occurred during streaming."; - - // 根据 HTTP 状态码映射错误类型 - const getErrorType = (code) => { - if (code === 401) return 'authentication_error'; - if (code === 403) return 'permission_error'; - if (code === 429) return 'rate_limit_error'; - if (code >= 500) return 'server_error'; - return 'invalid_request_error'; - }; - - // 根据 HTTP 状态码映射 Gemini 的 status - const getGeminiStatus = (code) => { - if (code === 400) return 'INVALID_ARGUMENT'; - if (code === 401) return 'UNAUTHENTICATED'; - if (code === 403) return 'PERMISSION_DENIED'; - if (code === 404) return 'NOT_FOUND'; - if (code === 429) return 'RESOURCE_EXHAUSTED'; - if (code >= 500) return 'INTERNAL'; - return 'UNKNOWN'; - }; - - switch (protocolPrefix) { - case MODEL_PROTOCOL_PREFIX.OPENAI: - // OpenAI 流式错误格式(SSE data 块) - const openaiError = { - error: { - message: errorMessage, - type: getErrorType(statusCode), - code: null - } - }; - return `data: ${JSON.stringify(openaiError)}\n\n`; - - case MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES: - // OpenAI Responses API 流式错误格式(SSE event + data) - const responsesError = { - id: `resp_${Date.now()}`, - object: "error", - created: Math.floor(Date.now() / 1000), - error: { - type: getErrorType(statusCode), - message: errorMessage, - code: getErrorType(statusCode) - } - }; - return `event: error\ndata: ${JSON.stringify(responsesError)}\n\n`; - - case MODEL_PROTOCOL_PREFIX.CLAUDE: - // Claude 流式错误格式(SSE event + data) - const claudeError = { - type: "error", - error: { - type: getErrorType(statusCode), - message: errorMessage - } - }; - return `event: error\ndata: ${JSON.stringify(claudeError)}\n\n`; - - case MODEL_PROTOCOL_PREFIX.GEMINI: - // Gemini 流式错误格式 - // 注意:虽然 Gemini 原生使用 JSON 数组,但在我们的实现中已经转换为 SSE 格式 - // 所以这里也需要使用 data: 前缀,保持与正常流式响应一致 - const geminiError = { - error: { - code: statusCode, - message: errorMessage, - status: getGeminiStatus(statusCode) - } - }; - return `data: ${JSON.stringify(geminiError)}\n\n`; - - default: - // 默认使用 OpenAI SSE 格式 - const defaultError = { - error: { - message: errorMessage, - type: getErrorType(statusCode), - code: null - } - }; - return `data: ${JSON.stringify(defaultError)}\n\n`; - } -} diff --git a/src/utils/grok-assets-proxy.js b/src/utils/grok-assets-proxy.js deleted file mode 100644 index 80f6f139683ac37aab4414d3f0fbbf6cad8214aa..0000000000000000000000000000000000000000 --- a/src/utils/grok-assets-proxy.js +++ /dev/null @@ -1,125 +0,0 @@ -import axios from 'axios'; -import logger from './logger.js'; -import { configureAxiosProxy } from './proxy-utils.js'; -import { MODEL_PROVIDER } from './common.js'; - -/** - * 处理 Grok 资源代理请求 - * @param {http.IncomingMessage} req 原始请求 - * @param {http.ServerResponse} res 原始响应 - * @param {Object} config 全局配置 - * @param {Object} providerPoolManager 提供商号池管理器 - */ -export async function handleGrokAssetsProxy(req, res, config, providerPoolManager) { - try { - const requestUrl = new URL(req.url, `http://${req.headers.host}`); - const targetUrl = requestUrl.searchParams.get('url'); - let ssoToken = requestUrl.searchParams.get('sso'); - const uuid = requestUrl.searchParams.get('uuid'); - - if (!targetUrl) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Missing url parameter' })); - return; - } - - // 优先尝试从 uuid 换取 token,提高安全性 - if (!ssoToken && uuid && providerPoolManager) { - const providerConfig = providerPoolManager.findProviderByUuid(uuid); - if (providerConfig) { - ssoToken = providerConfig.GROK_COOKIE_TOKEN; - logger.debug(`[Grok Proxy] Resolved SSO token from uuid: ${uuid}`); - } else { - logger.warn(`[Grok Proxy] Could not find provider configuration for uuid: ${uuid}`); - } - } - - if (!ssoToken) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Missing sso parameter or valid uuid' })); - return; - } - - // 清理 token - if (ssoToken.startsWith('sso=')) { - ssoToken = ssoToken.substring(4); - } - - // 构造完整的 assets.grok.com URL(如果是相对路径) - let finalTargetUrl = targetUrl; - if (!targetUrl.startsWith('http')) { - finalTargetUrl = `https://assets.grok.com${targetUrl.startsWith('/') ? '' : '/'}${targetUrl}`; - } - - // 验证域名安全,只允许代理 assets.grok.com - try { - const parsedTarget = new URL(finalTargetUrl); - if (parsedTarget.hostname !== 'assets.grok.com') { - res.writeHead(403, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Forbidden: Only assets.grok.com is allowed' })); - return; - } - } catch (e) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Invalid target URL' })); - return; - } - - const headers = { - 'User-Agent': config.GROK_USER_AGENT || 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36', - 'Cookie': `sso=${ssoToken}; sso-rw=${ssoToken}`, - 'Referer': 'https://grok.com/', - 'Accept': '*/*' - }; - - const axiosConfig = { - method: 'get', - url: finalTargetUrl, - headers: headers, - responseType: 'stream', - timeout: 30000, - validateStatus: false - }; - - // 配置代理 - configureAxiosProxy(axiosConfig, config, MODEL_PROVIDER.GROK_CUSTOM); - - logger.debug(`[Grok Proxy] Proxying request to: ${finalTargetUrl}`); - - const response = await axios(axiosConfig); - - // 转发响应头 - const responseHeaders = { - 'Content-Type': response.headers['content-type'] || 'application/octet-stream', - 'Cache-Control': response.headers['cache-control'] || 'public, max-age=3600', - }; - - if (response.headers['content-length']) { - responseHeaders['Content-Length'] = response.headers['content-length']; - } - - res.writeHead(response.status, responseHeaders); - - // 管道传输数据 - response.data.pipe(res); - - response.data.on('error', (err) => { - logger.error(`[Grok Proxy] Stream error: ${err.message}`); - if (!res.headersSent) { - res.writeHead(500); - res.end(); - } else { - res.end(); - } - }); - - } catch (error) { - logger.error(`[Grok Proxy] Error: ${error.message}`); - if (!res.headersSent) { - res.writeHead(500, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: 'Internal Server Error', message: error.message })); - } else { - res.end(); - } - } -} diff --git a/src/utils/logger.js b/src/utils/logger.js deleted file mode 100644 index afe94a20860c3332ff3665efcdbadb26d9f4c145..0000000000000000000000000000000000000000 --- a/src/utils/logger.js +++ /dev/null @@ -1,450 +0,0 @@ -import * as fs from 'fs'; -import * as path from 'path'; -import { randomUUID } from 'crypto'; -import { AsyncLocalStorage } from 'node:async_hooks'; - -/** - * 统一日志工具类 - * 支持控制台和文件输出,自动添加请求ID和时间戳 - */ -class Logger { - constructor() { - this.config = { - enabled: true, - outputMode: 'all', // 'console', 'file', 'all', 'none' - logDir: 'logs', - logLevel: 'info', // 'debug', 'info', 'warn', 'error' - includeRequestId: true, - includeTimestamp: true, - maxFileSize: 10 * 1024 * 1024, // 10MB - maxFiles: 10 - }; - this.currentLogFile = null; - this.logStream = null; - this.asyncStorage = new AsyncLocalStorage(); // 使用 AsyncLocalStorage 存储请求上下文 - this.requestContext = new Map(); // 存储请求上下文 - this.contextTTL = 5 * 60 * 1000; // 请求上下文 TTL:5 分钟 - this._contextCleanupTimer = null; - this.levels = { - debug: 0, - info: 1, - warn: 2, - error: 3 - }; - } - - - /** - * 初始化日志配置 - * @param {Object} config - 日志配置对象 - */ - initialize(config = {}) { - this.config = { ...this.config, ...config }; - - if (this.config.outputMode === 'none') { - this.config.enabled = false; - return; - } - - if (this.config.outputMode === 'file' || this.config.outputMode === 'all') { - this.initializeFileLogging(); - } - } - - /** - * 初始化文件日志 - */ - initializeFileLogging() { - try { - // 确保日志目录存在 - if (!fs.existsSync(this.config.logDir)) { - fs.mkdirSync(this.config.logDir, { recursive: true }); - } - - // 创建日志文件名(按本地日期) - const date = new Date(); - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - const dateStr = `${year}-${month}-${day}`; - this.currentLogFile = path.join(this.config.logDir, `app-${dateStr}.log`); - - // 创建写入流 - this.logStream = fs.createWriteStream(this.currentLogFile, { flags: 'a' }); - - // 监听错误 - this.logStream.on('error', (err) => { - console.error('[Logger] Failed to write to log file:', err.message); - }); - } catch (error) { - console.error('[Logger] Failed to initialize file logging:', error.message); - } - } - - /** - * 在请求上下文中运行 - * @param {string} requestId - 请求ID - * @param {Function} callback - 回调函数 - * @returns {any} - */ - runWithContext(requestId, callback) { - if (!requestId) { - requestId = randomUUID().substring(0, 8); - } - this.requestContext.set(requestId, { _createdAt: Date.now() }); - this._ensureContextCleanup(); - return this.asyncStorage.run(requestId, callback); - } - - /** - * 设置请求上下文 (不推荐直接使用,建议使用 runWithContext) - * @param {string} requestId - 请求ID - * @param {Object} context - 上下文信息 - */ - setRequestContext(requestId, context = {}) { - if (!requestId) { - requestId = randomUUID().substring(0, 8); - } - this.asyncStorage.enterWith(requestId); - this.requestContext.set(requestId, { ...context, _createdAt: Date.now() }); - this._ensureContextCleanup(); - return requestId; - } - - /** - * 获取当前请求ID - * @returns {string} 请求ID - */ - getCurrentRequestId() { - // 从 AsyncLocalStorage 中获取当前请求ID - return this.asyncStorage.getStore(); - } - - /** - * 获取当前请求上下文 - * @param {string} requestId - 请求ID - * @returns {Object} 上下文信息 - */ - getRequestContext(requestId) { - if (!requestId) { - requestId = this.getCurrentRequestId(); - } - return this.requestContext.get(requestId) || {}; - } - - /** - * 清除请求上下文 - * @param {string} requestId - 请求ID - */ - clearRequestContext(requestId) { - if (requestId) { - this.requestContext.delete(requestId); - } - // AsyncLocalStorage 不需要手动清除,run() 会在结束时自动处理 - // 如果使用了 enterWith,则没有简单的方法在该异步路径中清除 - } - - - /** - * 启动定期清理过期请求上下文的定时器(防止内存泄漏) - * 每 60 秒扫描一次,清除超过 contextTTL 的条目 - */ - _ensureContextCleanup() { - if (this._contextCleanupTimer) return; - this._contextCleanupTimer = setInterval(() => { - const now = Date.now(); - let cleaned = 0; - for (const [id, ctx] of this.requestContext) { - if (now - (ctx._createdAt || 0) > this.contextTTL) { - this.requestContext.delete(id); - cleaned++; - } - } - if (cleaned > 0) { - this.log('warn', [`[Logger] Cleaned ${cleaned} stale request context(s) (TTL: ${this.contextTTL}ms)`]); - } - // 当 Map 为空时停止定时器 - if (this.requestContext.size === 0) { - clearInterval(this._contextCleanupTimer); - this._contextCleanupTimer = null; - } - }, 60_000); - // 不阻止进程退出 - if (this._contextCleanupTimer.unref) { - this._contextCleanupTimer.unref(); - } - } - - /** - * 格式化日志消息 - * @param {string} level - 日志级别 - * @param {Array} args - 日志参数 - * @param {string} requestId - 请求ID - * @returns {string} 格式化后的日志 - */ - formatMessage(level, args, requestId) { - const parts = []; - - // 添加本地时间戳 - if (this.config.includeTimestamp) { - const now = new Date(); - const year = now.getFullYear(); - const month = String(now.getMonth() + 1).padStart(2, '0'); - const day = String(now.getDate()).padStart(2, '0'); - const hours = String(now.getHours()).padStart(2, '0'); - const minutes = String(now.getMinutes()).padStart(2, '0'); - const seconds = String(now.getSeconds()).padStart(2, '0'); - const ms = String(now.getMilliseconds()).padStart(3, '0'); - const timestamp = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${ms}`; - parts.push(`[${timestamp}]`); - } - - // 添加请求ID - if (this.config.includeRequestId && requestId) { - parts.push(`[Req:${requestId}]`); - } - - // 添加日志级别 - parts.push(`[${level.toUpperCase()}]`); - - // 添加消息内容 - const message = args.map(arg => { - if (typeof arg === 'object') { - try { - return JSON.stringify(arg, null, 2); - } catch (e) { - return String(arg); - } - } - return String(arg); - }).join(' '); - - parts.push(message); - - return parts.join(' '); - } - - /** - * 检查是否应该输出该级别的日志 - * @param {string} level - 日志级别 - * @returns {boolean} - */ - shouldLog(level) { - if (!this.config.enabled) return false; - const currentLevel = this.levels[this.config.logLevel] ?? 1; - const targetLevel = this.levels[level] ?? 1; - return targetLevel >= currentLevel; - } - - /** - * 检查并轮转日志文件 - */ - checkAndRotateLogFile() { - try { - if (!this.currentLogFile || !fs.existsSync(this.currentLogFile)) { - return; - } - - const stats = fs.statSync(this.currentLogFile); - if (stats.size >= this.config.maxFileSize) { - // 关闭当前日志流 - if (this.logStream && !this.logStream.destroyed) { - this.logStream.end(); - } - - // 重命名当前日志文件,添加时间戳 - const timestamp = new Date().getTime(); - const ext = path.extname(this.currentLogFile); - const basename = path.basename(this.currentLogFile, ext); - const newName = path.join(this.config.logDir, `${basename}-${timestamp}${ext}`); - fs.renameSync(this.currentLogFile, newName); - - // 重新创建日志流 - this.logStream = fs.createWriteStream(this.currentLogFile, { flags: 'a' }); - this.logStream.on('error', (err) => { - console.error('[Logger] Failed to write to log file:', err.message); - }); - - // 清理旧日志文件 - this.cleanupOldLogs(); - } - } catch (error) { - console.error('[Logger] Failed to rotate log file:', error.message); - } - } - - /** - * 输出日志 - * @param {string} level - 日志级别 - * @param {Array} args - 日志参数 - * @param {string} requestId - 请求ID - */ - log(level, args, requestId = null) { - if (!this.shouldLog(level)) return; - - const message = this.formatMessage(level, args, requestId); - - // 输出到控制台 - if (this.config.outputMode === 'console' || this.config.outputMode === 'all') { - const consoleMethod = level === 'error' ? console.error : - level === 'warn' ? console.warn : - level === 'debug' ? console.debug : console.log; - consoleMethod(message); - } - - // 输出到文件 - if (this.config.outputMode === 'file' || this.config.outputMode === 'all') { - if (this.logStream && !this.logStream.destroyed && this.logStream.writable) { - try { - // 检查文件大小并轮转 - this.checkAndRotateLogFile(); - this.logStream.write(message + '\n'); - } catch (err) { - // 如果写入失败,输出到控制台作为备份 - console.error('[Logger] Failed to write to log file:', err.message); - } - } - } - } - - /** - * Debug 级别日志 - * @param {...any} args - 日志参数 - */ - debug(...args) { - const requestId = this.getCurrentRequestId(); - this.log('debug', args, requestId); - } - - /** - * Info 级别日志 - * @param {...any} args - 日志参数 - */ - info(...args) { - const requestId = this.getCurrentRequestId(); - this.log('info', args, requestId); - } - - /** - * Warn 级别日志 - * @param {...any} args - 日志参数 - */ - warn(...args) { - const requestId = this.getCurrentRequestId(); - this.log('warn', args, requestId); - } - - /** - * Error 级别日志 - * @param {...any} args - 日志参数 - */ - error(...args) { - const requestId = this.getCurrentRequestId(); - this.log('error', args, requestId); - } - - /** - * 创建带请求ID的日志记录器 - * @param {string} requestId - 请求ID - * @returns {Object} 带请求上下文的日志方法 - */ - withRequest(requestId) { - if (!requestId) { - requestId = this.getCurrentRequestId(); - } - - return { - debug: (...args) => this.log('debug', args, requestId), - info: (...args) => this.log('info', args, requestId), - warn: (...args) => this.log('warn', args, requestId), - error: (...args) => this.log('error', args, requestId) - }; - } - - /** - * 关闭日志流 - */ - close() { - if (this._contextCleanupTimer) { - clearInterval(this._contextCleanupTimer); - this._contextCleanupTimer = null; - } - if (this.logStream && !this.logStream.destroyed) { - this.logStream.end(); - this.logStream = null; - } - } - - /** - * 清理旧日志文件 - */ - cleanupOldLogs() { - try { - if (!fs.existsSync(this.config.logDir)) { - return; - } - - const files = fs.readdirSync(this.config.logDir) - .filter(file => file.startsWith('app-') && file.endsWith('.log')) - .map(file => ({ - name: file, - path: path.join(this.config.logDir, file), - time: fs.statSync(path.join(this.config.logDir, file)).mtime.getTime() - })) - .sort((a, b) => b.time - a.time); - - // 保留最新的 maxFiles 个文件,删除其他的 - if (files.length > this.config.maxFiles) { - for (let i = this.config.maxFiles; i < files.length; i++) { - try { - fs.unlinkSync(files[i].path); - } catch (err) { - console.error('[Logger] Failed to delete old log file:', files[i].name, err.message); - } - } - } - } catch (error) { - console.error('[Logger] Failed to cleanup old logs:', error.message); - } - } - - /** - * 清空当日日志文件 - * @returns {boolean} 是否成功清空 - */ - clearTodayLog() { - try { - if (!this.currentLogFile || !fs.existsSync(this.currentLogFile)) { - console.warn('[Logger] No current log file to clear'); - return false; - } - - // 关闭当前日志流 - if (this.logStream && !this.logStream.destroyed) { - this.logStream.end(); - } - - // 清空文件内容 - fs.writeFileSync(this.currentLogFile, ''); - - // 重新创建日志流 - this.logStream = fs.createWriteStream(this.currentLogFile, { flags: 'a' }); - this.logStream.on('error', (err) => { - console.error('[Logger] Failed to write to log file:', err.message); - }); - - console.log('[Logger] Today\'s log file cleared successfully'); - return true; - } catch (error) { - console.error('[Logger] Failed to clear today\'s log file:', error.message); - return false; - } - } -} - -// 创建单例实例 -const logger = new Logger(); - -// 导出实例和类 -export default logger; -export { Logger }; diff --git a/src/utils/provider-strategies.js b/src/utils/provider-strategies.js deleted file mode 100644 index fd875f5880422929239a753cdb4a6062b2028fc6..0000000000000000000000000000000000000000 --- a/src/utils/provider-strategies.js +++ /dev/null @@ -1,36 +0,0 @@ -import { MODEL_PROTOCOL_PREFIX } from '../utils/common.js'; -import { GeminiStrategy } from '../providers/gemini/gemini-strategy.js'; -import { OpenAIStrategy } from '../providers/openai/openai-strategy.js'; -import { ClaudeStrategy } from '../providers/claude/claude-strategy.js'; -import { ResponsesAPIStrategy } from '../providers/openai/openai-responses-strategy.js'; -import { CodexResponsesAPIStrategy } from '../providers/openai/codex-responses-strategy.js'; -import { ForwardStrategy } from '../providers/forward/forward-strategy.js'; -import { GrokStrategy } from '../providers/grok/grok-strategy.js'; - -/** - * Strategy factory that returns the appropriate strategy instance based on the provider protocol. - */ -class ProviderStrategyFactory { - static getStrategy(providerProtocol) { - switch (providerProtocol) { - case MODEL_PROTOCOL_PREFIX.GEMINI: - return new GeminiStrategy(); - case MODEL_PROTOCOL_PREFIX.OPENAI: - return new OpenAIStrategy(); - case MODEL_PROTOCOL_PREFIX.OPENAI_RESPONSES: - return new ResponsesAPIStrategy(); - case MODEL_PROTOCOL_PREFIX.CLAUDE: - return new ClaudeStrategy(); - case MODEL_PROTOCOL_PREFIX.CODEX: - return new CodexResponsesAPIStrategy(); - case MODEL_PROTOCOL_PREFIX.FORWARD: - return new ForwardStrategy(); - case MODEL_PROTOCOL_PREFIX.GROK: - return new GrokStrategy(); - default: - throw new Error(`Unsupported provider protocol: ${providerProtocol}`); - } - } -} - -export { ProviderStrategyFactory }; diff --git a/src/utils/provider-strategy.js b/src/utils/provider-strategy.js deleted file mode 100644 index f408f9628f4b3e0c8402825ccca0fe7c56cbef43..0000000000000000000000000000000000000000 --- a/src/utils/provider-strategy.js +++ /dev/null @@ -1,84 +0,0 @@ -import { promises as fs } from 'fs'; -import logger from './logger.js'; -import { FETCH_SYSTEM_PROMPT_FILE } from '../utils/common.js'; - -/** - * Abstract provider strategy class, defining the interface for handling different model providers. - */ -export class ProviderStrategy { - /** - * Extracts model and stream information. - * @param {object} req - HTTP request object. - * @param {object} requestBody - Parsed request body. - * @returns {{model: string, isStream: boolean}} Object containing model name and stream status. - */ - extractModelAndStreamInfo(req, requestBody) { - throw new Error("Method 'extractModelAndStreamInfo()' must be implemented."); - } - - /** - * Extracts text content from the response. - * @param {object} response - API response object. - * @returns {string} Extracted text content. - */ - extractResponseText(response) { - throw new Error("Method 'extractResponseText()' must be implemented."); - } - - /** - * Extracts prompt text from the request body. - * @param {object} requestBody - Request body object. - * @returns {string} Extracted prompt text. - */ - extractPromptText(requestBody) { - throw new Error("Method 'extractPromptText()' must be implemented."); - } - - /** - * Applies system prompt file content to the request body. - * @param {object} config - Configuration object. - * @param {object} requestBody - Request body object. - * @returns {Promise} Modified request body. - */ - async applySystemPromptFromFile(config, requestBody) { - throw new Error("Method 'applySystemPromptFromFile()' must be implemented."); - } - - /** - * Manages the system prompt file. - * @param {object} requestBody - Request body object. - * @returns {Promise} - */ - async manageSystemPrompt(requestBody) { - throw new Error("Method 'manageSystemPrompt()' must be implemented."); - } - - /** - * Updates the system prompt file. - * @param {string} incomingSystemText - Incoming system prompt text. - * @param {string} providerName - Provider name (for logging). - * @returns {Promise} - */ - async _updateSystemPromptFile(incomingSystemText, providerName) { - let currentSystemText = ''; - try { - currentSystemText = await fs.readFile(FETCH_SYSTEM_PROMPT_FILE, 'utf8'); - } catch (error) { - if (error.code !== 'ENOENT') { - logger.error(`[System Prompt Manager] Error reading system prompt file: ${error.message}`); - } - } - - try { - if (incomingSystemText && incomingSystemText !== currentSystemText) { - await fs.writeFile(FETCH_SYSTEM_PROMPT_FILE, incomingSystemText); - logger.info(`[System Prompt Manager] System prompt updated in file for provider '${providerName}'.`); - } else if (!incomingSystemText && currentSystemText) { - await fs.writeFile(FETCH_SYSTEM_PROMPT_FILE, ''); - logger.info('[System Prompt Manager] System prompt cleared from file.'); - } - } catch (error) { - logger.error(`[System Prompt Manager] Failed to manage system prompt file: ${error.message}`); - } - } -} diff --git a/src/utils/provider-utils.js b/src/utils/provider-utils.js deleted file mode 100644 index 8f1f8549aaab6f2241745404dec457fcafabb5b8..0000000000000000000000000000000000000000 --- a/src/utils/provider-utils.js +++ /dev/null @@ -1,388 +0,0 @@ -/** - * 提供商工具模块 - * 包含 ui-manager.js 和 service-manager.js 共用的工具函数 - */ - -import * as path from 'path'; -import logger from './logger.js'; -import { promises as fs } from 'fs'; - -/** - * 提供商目录映射配置 - * 定义目录名称到提供商类型的映射关系 - */ -export const PROVIDER_MAPPINGS = [ - { - // Kiro OAuth 配置 - dirName: 'kiro', - patterns: ['configs/kiro/', '/kiro/'], - providerType: 'claude-kiro-oauth', - credPathKey: 'KIRO_OAUTH_CREDS_FILE_PATH', - defaultCheckModel: 'claude-haiku-4-5', - displayName: 'Claude Kiro OAuth', - needsProjectId: false, - urlKeys: ['KIRO_BASE_URL', 'KIRO_REFRESH_URL', 'KIRO_REFRESH_IDC_URL'] - }, - { - // Gemini CLI OAuth 配置 - dirName: 'gemini', - patterns: ['configs/gemini/', '/gemini/', 'configs/gemini-cli/'], - providerType: 'gemini-cli-oauth', - credPathKey: 'GEMINI_OAUTH_CREDS_FILE_PATH', - defaultCheckModel: 'gemini-2.5-flash', - displayName: 'Gemini CLI OAuth', - needsProjectId: true, - urlKeys: ['GEMINI_BASE_URL'] - }, - { - // Qwen OAuth 配置 - dirName: 'qwen', - patterns: ['configs/qwen/', '/qwen/'], - providerType: 'openai-qwen-oauth', - credPathKey: 'QWEN_OAUTH_CREDS_FILE_PATH', - defaultCheckModel: 'qwen3-coder-plus', - displayName: 'Qwen OAuth', - needsProjectId: false, - urlKeys: ['QWEN_BASE_URL', 'QWEN_OAUTH_BASE_URL'] - }, - { - // Antigravity OAuth 配置 - dirName: 'antigravity', - patterns: ['configs/antigravity/', '/antigravity/'], - providerType: 'gemini-antigravity', - credPathKey: 'ANTIGRAVITY_OAUTH_CREDS_FILE_PATH', - defaultCheckModel: 'gemini-2.5-computer-use-preview-10-2025', - displayName: 'Gemini Antigravity', - needsProjectId: true, - urlKeys: ['ANTIGRAVITY_BASE_URL_DAILY', 'ANTIGRAVITY_BASE_URL_AUTOPUSH'] - }, - { - // iFlow 配置 - dirName: 'iflow', - patterns: ['configs/iflow/', '/iflow/'], - providerType: 'openai-iflow', - credPathKey: 'IFLOW_TOKEN_FILE_PATH', - defaultCheckModel: 'gpt-4o', - displayName: 'iFlow API', - needsProjectId: false, - urlKeys: ['IFLOW_BASE_URL'] - }, - { - // Codex OAuth 配置 - dirName: 'codex', - patterns: ['configs/codex/', '/codex/'], - providerType: 'openai-codex-oauth', - credPathKey: 'CODEX_OAUTH_CREDS_FILE_PATH', - defaultCheckModel: 'gpt-5.2-codex', - displayName: 'OpenAI Codex OAuth', - needsProjectId: false, - urlKeys: ['CODEX_BASE_URL'] - }, - { - // Grok Reverse 配置 - dirName: 'grok', - patterns: ['configs/grok/', '/grok/'], - providerType: 'grok-custom', - credPathKey: 'GROK_COOKIE_TOKEN', - defaultCheckModel: 'grok-3', - displayName: 'Grok Reverse', - needsProjectId: false, - urlKeys: ['GROK_BASE_URL', 'GROK_CF_CLEARANCE', 'GROK_USER_AGENT'] - } -]; - -/** - * 生成 UUID - * @returns {string} UUID 字符串 - */ -export function generateUUID() { - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { - const r = Math.random() * 16 | 0; - const v = c === 'x' ? r : (r & 0x3 | 0x8); - return v.toString(16); - }); -} - -/** - * 标准化路径,用于跨平台兼容 - * @param {string} filePath - 文件路径 - * @returns {string} 使用正斜杠的标准化路径 - */ -export function normalizePath(filePath) { - if (!filePath) return filePath; - - // 使用 path 模块标准化,然后转换为正斜杠 - const normalized = path.normalize(filePath); - return normalized.replace(/\\/g, '/'); -} - -/** - * 从路径中提取文件名 - * @param {string} filePath - 文件路径 - * @returns {string} 文件名 - */ -export function getFileName(filePath) { - return path.basename(filePath); -} - -/** - * 格式化相对路径为当前系统的路径格式 - * @param {string} relativePath - 相对路径 - * @returns {string} 格式化后的路径(带有 ./ 或 .\ 前缀) - */ -export function formatSystemPath(relativePath) { - if (!relativePath) return relativePath; - - // 根据操作系统判断使用对应的路径分隔符 - const isWindows = process.platform === 'win32'; - const separator = isWindows ? '\\' : '/'; - // 统一转换路径分隔符为当前系统的分隔符 - const systemPath = relativePath.replace(/[\/\\]/g, separator); - return systemPath.startsWith('.' + separator) ? systemPath : '.' + separator + systemPath; -} - -/** - * 检查两个路径是否指向同一文件(跨平台兼容) - * @param {string} path1 - 第一个路径 - * @param {string} path2 - 第二个路径 - * @returns {boolean} 如果路径指向同一文件则返回 true - */ -export function pathsEqual(path1, path2) { - if (!path1 || !path2) return false; - - try { - // 标准化两个路径 - const normalized1 = normalizePath(path1); - const normalized2 = normalizePath(path2); - - // 直接匹配 - if (normalized1 === normalized2) { - return true; - } - - // 移除开头的 './' 后比较 - const clean1 = normalized1.replace(/^\.\//, ''); - const clean2 = normalized2.replace(/^\.\//, ''); - - if (clean1 === clean2) { - return true; - } - - // 检查一个是否是另一个的子集(用于相对路径与绝对路径比较) - if (normalized1.endsWith('/' + clean2) || normalized2.endsWith('/' + clean1)) { - return true; - } - - return false; - } catch (error) { - logger.warn(`[Path Comparison] Error comparing paths: ${path1} vs ${path2}`, error.message); - return false; - } -} - -/** - * 检查文件路径是否正在被使用(跨平台兼容) - * @param {string} relativePath - 相对路径 - * @param {string} fileName - 文件名 - * @param {Set} usedPaths - 已使用路径的集合 - * @returns {boolean} 如果文件正在被使用则返回 true - */ -export function isPathUsed(relativePath, fileName, usedPaths) { - if (!relativePath) return false; - - // 标准化相对路径 - const normalizedRelativePath = normalizePath(relativePath); - const cleanRelativePath = normalizedRelativePath.replace(/^\.\//, ''); - - // 从相对路径获取文件名 - const relativeFileName = getFileName(normalizedRelativePath); - - // 遍历所有已使用路径进行匹配 - for (const usedPath of usedPaths) { - if (!usedPath) continue; - - // 1. 直接路径匹配 - if (pathsEqual(relativePath, usedPath) || pathsEqual(relativePath, './' + usedPath)) { - return true; - } - - // 2. 标准化路径匹配 - if (pathsEqual(normalizedRelativePath, usedPath) || - pathsEqual(normalizedRelativePath, './' + usedPath)) { - return true; - } - - // 3. 清理后的路径匹配 - if (pathsEqual(cleanRelativePath, usedPath) || - pathsEqual(cleanRelativePath, './' + usedPath)) { - return true; - } - - // 4. 文件名匹配(确保不是误匹配) - const usedFileName = getFileName(usedPath); - if (usedFileName === fileName || usedFileName === relativeFileName) { - // 确保是同一个目录下的文件 - const usedDir = path.dirname(usedPath); - const relativeDir = path.dirname(normalizedRelativePath); - - if (pathsEqual(usedDir, relativeDir) || - pathsEqual(usedDir, cleanRelativePath.replace(/\/[^\/]+$/, '')) || - pathsEqual(relativeDir.replace(/^\.\//, ''), usedDir.replace(/^\.\//, ''))) { - return true; - } - } - - // 5. 绝对路径匹配(Windows 和 Unix) - try { - const resolvedUsedPath = path.resolve(usedPath); - const resolvedRelativePath = path.resolve(relativePath); - - if (resolvedUsedPath === resolvedRelativePath) { - return true; - } - } catch (error) { - // 忽略路径解析错误 - } - } - - return false; -} - -/** - * 根据文件路径检测提供商类型 - * @param {string} normalizedPath - 标准化的文件路径(小写,正斜杠) - * @returns {Object|null} 提供商映射对象,如果未检测到则返回 null - */ -export function detectProviderFromPath(normalizedPath) { - // 遍历映射关系,查找匹配的提供商 - for (const mapping of PROVIDER_MAPPINGS) { - for (const pattern of mapping.patterns) { - if (normalizedPath.includes(pattern)) { - return { - providerType: mapping.providerType, - credPathKey: mapping.credPathKey, - defaultCheckModel: mapping.defaultCheckModel, - displayName: mapping.displayName, - needsProjectId: mapping.needsProjectId - }; - } - } - } - - return null; -} - -/** - * 根据目录名获取提供商映射 - * @param {string} dirName - 目录名称 - * @returns {Object|null} 提供商映射对象,如果未找到则返回 null - */ -export function getProviderMappingByDirName(dirName) { - return PROVIDER_MAPPINGS.find(m => m.dirName === dirName) || null; -} - -/** - * 验证文件是否是有效的 OAuth 凭据文件 - * @param {string} filePath - 文件路径 - * @returns {Promise} 是否有效 - */ -export async function isValidOAuthCredentials(filePath) { - try { - const content = await fs.readFile(filePath, 'utf8'); - const jsonData = JSON.parse(content); - - // 检查是否包含 OAuth 相关字段 - // 凭据通常包含 access_token/accessToken, refresh_token/refreshToken, client_id 等字段 - // 支持下划线命名(access_token)和驼峰命名(accessToken)两种格式 - if (jsonData.access_token || jsonData.refresh_token || - jsonData.accessToken || jsonData.refreshToken || - jsonData.client_id || jsonData.client_secret || - jsonData.token || jsonData.credentials) { - return true; - } - - // 也可能是包含嵌套结构的凭据文件 - if (jsonData.installed || jsonData.web) { - return true; - } - - return false; - } catch (error) { - // 如果无法解析,认为不是有效的凭据文件 - return false; - } -} - -/** - * 创建新的提供商配置对象 - * @param {Object} options - 配置选项 - * @param {string} options.credPathKey - 凭据路径键名 - * @param {string} options.credPath - 凭据文件路径 - * @param {string} options.defaultCheckModel - 默认检测模型 - * @param {boolean} options.needsProjectId - 是否需要 PROJECT_ID - * @param {Array} options.urlKeys - 可选的 URL 配置项键名列表 - * @returns {Object} 新的提供商配置对象 - */ -export function createProviderConfig(options) { - const { credPathKey, credPath, defaultCheckModel, needsProjectId, urlKeys } = options; - - const newProvider = { - [credPathKey]: credPath, - uuid: generateUUID(), - checkModelName: defaultCheckModel, - checkHealth: false, - isHealthy: true, - isDisabled: false, - lastUsed: null, - usageCount: 0, - errorCount: 0, - lastErrorTime: null, - lastHealthCheckTime: null, - lastHealthCheckModel: null, - lastErrorMessage: null - }; - - // 如果需要 PROJECT_ID,添加空字符串占位 - if (needsProjectId) { - newProvider.PROJECT_ID = ''; - } - - // 初始化可选的 URL 配置项 - if (urlKeys && Array.isArray(urlKeys)) { - urlKeys.forEach(key => { - newProvider[key] = ''; - }); - } - - return newProvider; -} - -/** - * 将路径添加到已使用路径集合(标准化多种格式) - * @param {Set} usedPaths - 已使用路径的集合 - * @param {string} filePath - 要添加的文件路径 - */ -export function addToUsedPaths(usedPaths, filePath) { - if (!filePath) return; - - const normalizedPath = filePath.replace(/\\/g, '/'); - usedPaths.add(filePath); - usedPaths.add(normalizedPath); - if (normalizedPath.startsWith('./')) { - usedPaths.add(normalizedPath.slice(2)); - } else { - usedPaths.add('./' + normalizedPath); - } -} - -/** - * 检查路径是否已关联(用于自动关联检测) - * @param {string} relativePath - 相对路径 - * @param {Set} linkedPaths - 已关联路径的集合 - * @returns {boolean} 是否已关联 - */ -export function isPathLinked(relativePath, linkedPaths) { - return linkedPaths.has(relativePath) || - linkedPaths.has('./' + relativePath) || - linkedPaths.has(relativePath.replace(/^\.\//, '')); -} \ No newline at end of file diff --git a/src/utils/proxy-utils.js b/src/utils/proxy-utils.js deleted file mode 100644 index 45bd9ece294c87f1d8ffe81cb041c018179a3c5d..0000000000000000000000000000000000000000 --- a/src/utils/proxy-utils.js +++ /dev/null @@ -1,176 +0,0 @@ -/** - * 代理工具模块 - * 支持 HTTP、HTTPS 和 SOCKS5 代理 - */ - -import { HttpsProxyAgent } from 'https-proxy-agent'; -import logger from './logger.js'; -import { HttpProxyAgent } from 'http-proxy-agent'; -import { SocksProxyAgent } from 'socks-proxy-agent'; -import { getTLSSidecar } from './tls-sidecar.js'; - -/** - * 解析代理URL并返回相应的代理配置 - * @param {string} proxyUrl - 代理URL,如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080 - * @returns {Object|null} 代理配置对象,包含 httpAgent 和 httpsAgent - */ -export function parseProxyUrl(proxyUrl) { - if (!proxyUrl || typeof proxyUrl !== 'string') { - return null; - } - - const trimmedUrl = proxyUrl.trim(); - if (!trimmedUrl) { - return null; - } - - try { - const url = new URL(trimmedUrl); - const protocol = url.protocol.toLowerCase(); - - if (protocol === 'socks5:' || protocol === 'socks4:' || protocol === 'socks:') { - // SOCKS 代理 - const socksAgent = new SocksProxyAgent(trimmedUrl); - return { - httpAgent: socksAgent, - httpsAgent: socksAgent, - proxyType: 'socks' - }; - } else if (protocol === 'http:' || protocol === 'https:') { - // HTTP/HTTPS 代理 - return { - httpAgent: new HttpProxyAgent(trimmedUrl), - httpsAgent: new HttpsProxyAgent(trimmedUrl), - proxyType: 'http' - }; - } else { - logger.warn(`[Proxy] Unsupported proxy protocol: ${protocol}`); - return null; - } - } catch (error) { - logger.error(`[Proxy] Failed to parse proxy URL: ${error.message}`); - return null; - } -} - -/** - * 检查指定的提供商是否启用了代理 - * @param {Object} config - 配置对象 - * @param {string} providerType - 提供商类型 - * @returns {boolean} 是否启用代理 - */ -export function isProxyEnabledForProvider(config, providerType) { - if (!config || !config.PROXY_URL || !config.PROXY_ENABLED_PROVIDERS) { - return false; - } - - const enabledProviders = config.PROXY_ENABLED_PROVIDERS; - if (!Array.isArray(enabledProviders)) { - return false; - } - - return enabledProviders.includes(providerType); -} - -/** - * 获取指定提供商的代理配置 - * @param {Object} config - 配置对象 - * @param {string} providerType - 提供商类型 - * @returns {Object|null} 代理配置对象或 null - */ -export function getProxyConfigForProvider(config, providerType) { - if (!isProxyEnabledForProvider(config, providerType)) { - return null; - } - - const proxyConfig = parseProxyUrl(config.PROXY_URL); - if (proxyConfig) { - logger.info(`[Proxy] Using ${proxyConfig.proxyType} proxy for ${providerType}: ${config.PROXY_URL}`); - } - return proxyConfig; -} - -/** - * 为 axios 配置代理 - * @param {Object} axiosConfig - axios 配置对象 - * @param {Object} config - 应用配置对象 - * @param {string} providerType - 提供商类型 - * @returns {Object} 更新后的 axios 配置 - */ -export function configureAxiosProxy(axiosConfig, config, providerType) { - const proxyConfig = getProxyConfigForProvider(config, providerType); - - if (proxyConfig) { - // 使用代理 agent - axiosConfig.httpAgent = proxyConfig.httpAgent; - axiosConfig.httpsAgent = proxyConfig.httpsAgent; - // 禁用 axios 内置的代理配置,使用我们的 agent - axiosConfig.proxy = false; - } - - return axiosConfig; -} - -/** - * 检查指定的提供商是否启用了 TLS Sidecar - * @param {Object} config - 配置对象 - * @param {string} providerType - 提供商类型 - * @returns {boolean} 是否启用 TLS Sidecar - */ -export function isTLSSidecarEnabledForProvider(config, providerType) { - if (!config || !config.TLS_SIDECAR_ENABLED || !config.TLS_SIDECAR_ENABLED_PROVIDERS) { - return false; - } - - const enabledProviders = config.TLS_SIDECAR_ENABLED_PROVIDERS; - if (!Array.isArray(enabledProviders)) { - return false; - } - - return enabledProviders.includes(providerType); -} - -/** - * 为 axios 配置 TLS Sidecar - * @param {Object} axiosConfig - axios 配置对象 - * @param {Object} config - 应用配置对象 - * @param {string} providerType - 提供商类型 - * @param {string} [defaultBaseUrl] - 默认基础 URL(用于处理相对路径) - * @returns {Object} 更新后的 axios 配置 - */ -export function configureTLSSidecar(axiosConfig, config, providerType, defaultBaseUrl = null) { - const sidecar = getTLSSidecar(); - if (sidecar.isReady() && isTLSSidecarEnabledForProvider(config, providerType)) { - const proxyUrl = config.TLS_SIDECAR_PROXY_URL || null; - - // 处理相对路径 - if (axiosConfig.url && !axiosConfig.url.startsWith('http')) { - const baseUrl = (axiosConfig.baseURL || defaultBaseUrl || '').replace(/\/$/, ''); - if (baseUrl) { - const path = axiosConfig.url.startsWith('/') ? axiosConfig.url : '/' + axiosConfig.url; - axiosConfig.url = baseUrl + path; - } - } - - sidecar.wrapAxiosConfig(axiosConfig, proxyUrl); - } - return axiosConfig; -} - -/** - * 为 google-auth-library 配置代理 - * @param {Object} config - 应用配置对象 - * @param {string} providerType - 提供商类型 - * @returns {Object|null} transporter 配置对象或 null - */ -export function getGoogleAuthProxyConfig(config, providerType) { - const proxyConfig = getProxyConfigForProvider(config, providerType); - - if (proxyConfig) { - return { - agent: proxyConfig.httpsAgent - }; - } - - return null; -} diff --git a/src/utils/tls-sidecar.js b/src/utils/tls-sidecar.js deleted file mode 100644 index 9a6f879b0e517732060711fc9868e28b0a1c8a0a..0000000000000000000000000000000000000000 --- a/src/utils/tls-sidecar.js +++ /dev/null @@ -1,297 +0,0 @@ -/** - * TLS Sidecar Manager - * - * 管理 Go uTLS sidecar 进程的生命周期: - * - 启动/停止 sidecar 二进制 - * - 健康检查 & 自动重启 - * - 为 axios 提供 sidecar 代理配置 - */ - -import { spawn } from 'child_process'; -import fs from 'fs'; -import path from 'path'; -import { fileURLToPath } from 'url'; -import logger from './logger.js'; -import http from 'http'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -const DEFAULT_PORT = 9090; -const HEALTH_CHECK_INTERVAL = 30000; // 30s -const HEALTH_CHECK_TIMEOUT = 3000; // 3s -const MAX_RESTART_ATTEMPTS = 5; -const RESTART_DELAY = 2000; // 2s - -class TLSSidecar { - constructor() { - this.process = null; - this.port = DEFAULT_PORT; - this.baseUrl = null; - this.healthCheckTimer = null; - this.restartCount = 0; - this.isShuttingDown = false; - this.ready = false; - } - - /** - * 启动 sidecar 进程 - * @param {Object} options - * @param {number} [options.port] - 监听端口 - * @param {string} [options.binaryPath] - 自定义二进制路径 - * @returns {Promise} - */ - async start(options = {}) { - if (this.process) { - logger.info('[TLS-Sidecar] Already running'); - return true; - } - - this.port = options.port || parseInt(process.env.TLS_SIDECAR_PORT) || DEFAULT_PORT; - this.baseUrl = `http://127.0.0.1:${this.port}`; - - // 查找二进制文件 - const binaryPath = options.binaryPath || this._findBinary(); - if (!binaryPath) { - logger.error('[TLS-Sidecar] Binary not found. Build it with: cd tls-sidecar && go build -o tls-sidecar'); - return false; - } - - logger.info(`[TLS-Sidecar] Starting: ${binaryPath} on port ${this.port}`); - - try { - // 确保 Linux/macOS 下有执行权限 - if (process.platform !== 'win32') { - try { - fs.chmodSync(binaryPath, 0o755); - } catch (e) { - logger.warn(`[TLS-Sidecar] Failed to chmod binary: ${e.message}`); - } - } - - this.process = spawn(binaryPath, [], { - env: { - ...process.env, - TLS_SIDECAR_PORT: String(this.port), - }, - stdio: ['ignore', 'pipe', 'pipe'], - }); - - // 转发 sidecar 日志 - this.process.stdout.on('data', (data) => { - const msg = data.toString().trim(); - if (msg) logger.info(`[TLS-Sidecar] ${msg}`); - }); - - this.process.stderr.on('data', (data) => { - const msg = data.toString().trim(); - if (msg) logger.error(`[TLS-Sidecar] ${msg}`); - }); - - this.process.on('exit', (code, signal) => { - logger.warn(`[TLS-Sidecar] Process exited (code=${code}, signal=${signal})`); - this.process = null; - this.ready = false; - - if (!this.isShuttingDown && this.restartCount < MAX_RESTART_ATTEMPTS) { - this.restartCount++; - logger.info(`[TLS-Sidecar] Auto-restart attempt ${this.restartCount}/${MAX_RESTART_ATTEMPTS}`); - setTimeout(() => this.start(options), RESTART_DELAY); - } - }); - - this.process.on('error', (err) => { - logger.error(`[TLS-Sidecar] Spawn error: ${err.message}`); - this.process = null; - this.ready = false; - }); - - // 等待 sidecar 就绪 - const ok = await this._waitForReady(); - if (ok) { - this.ready = true; - this.restartCount = 0; - this._startHealthCheck(); - logger.info(`[TLS-Sidecar] Ready at ${this.baseUrl}`); - } - return ok; - - } catch (err) { - logger.error(`[TLS-Sidecar] Failed to start: ${err.message}`); - return false; - } - } - - /** - * 停止 sidecar 进程 - */ - async stop() { - this.isShuttingDown = true; - this._stopHealthCheck(); - - if (this.process) { - logger.info('[TLS-Sidecar] Stopping...'); - return new Promise((resolve) => { - const timeout = setTimeout(() => { - if (this.process) { - logger.warn('[TLS-Sidecar] Force killing'); - this.process.kill('SIGKILL'); - } - resolve(); - }, 5000); - - this.process.once('exit', () => { - clearTimeout(timeout); - this.process = null; - this.ready = false; - logger.info('[TLS-Sidecar] Stopped'); - resolve(); - }); - - this.process.kill('SIGTERM'); - }); - } - } - - /** - * 检查 sidecar 是否正在运行且健康 - * @returns {boolean} - */ - isReady() { - return this.ready && this.process !== null; - } - - /** - * 获取 sidecar base URL - * @returns {string|null} - */ - getBaseUrl() { - return this.isReady() ? this.baseUrl : null; - } - - /** - * 为 axios 配置 sidecar 代理 - * 将目标 URL 改为 sidecar 地址,原始目标通过 header 传递 - * - * @param {Object} axiosConfig - axios 配置对象 - * @param {string} [proxyUrl] - 上游代理 URL(可选) - * @returns {Object} 修改后的 axios 配置 - */ - wrapAxiosConfig(axiosConfig, proxyUrl) { - if (!this.isReady()) { - return axiosConfig; // sidecar 不可用,原样返回 - } - - const targetUrl = axiosConfig.url; - - // 将请求指向 sidecar - axiosConfig.url = this.baseUrl; - - // 通过 header 传递目标和代理信息 - axiosConfig.headers = axiosConfig.headers || {}; - axiosConfig.headers['X-Target-Url'] = targetUrl; - if (proxyUrl) { - axiosConfig.headers['X-Proxy-Url'] = proxyUrl; - } - - // 走 sidecar 不需要 Node.js 侧的 TLS agent - delete axiosConfig.httpAgent; - delete axiosConfig.httpsAgent; - // 确保 axios 不使用自己的代理 - axiosConfig.proxy = false; - - return axiosConfig; - } - - // ──── 内部方法 ──── - - _findBinary() { - const projectRoot = path.resolve(__dirname, '..', '..'); - const isWin = process.platform === 'win32'; - const ext = isWin ? '.exe' : ''; - - const candidates = [ - path.join(projectRoot, 'tls-sidecar', `tls-sidecar${ext}`), - path.join(projectRoot, `tls-sidecar${ext}`), - path.join('/usr', 'local', 'bin', `tls-sidecar${ext}`), - path.join('/app', 'tls-sidecar', `tls-sidecar${ext}`), - path.join('/app', `tls-sidecar${ext}`), - ]; - - for (const p of candidates) { - try { - if (fs.existsSync(p) && fs.statSync(p).isFile()) { - return p; - } - } catch { /* ignore */ } - } - return null; - } - - async _waitForReady(timeoutMs = 10000) { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - try { - const ok = await this._healthCheck(); - if (ok) return true; - } catch { /* retry */ } - await sleep(500); - } - logger.error('[TLS-Sidecar] Timed out waiting for sidecar to become ready'); - return false; - } - - _healthCheck() { - return new Promise((resolve) => { - const req = http.get(`${this.baseUrl}/health`, { timeout: HEALTH_CHECK_TIMEOUT }, (res) => { - let body = ''; - res.on('data', (chunk) => body += chunk); - res.on('end', () => { - resolve(res.statusCode === 200); - }); - }); - req.on('error', () => resolve(false)); - req.on('timeout', () => { - req.destroy(); - resolve(false); - }); - }); - } - - _startHealthCheck() { - this._stopHealthCheck(); - this.healthCheckTimer = setInterval(async () => { - const ok = await this._healthCheck(); - if (!ok && this.ready) { - logger.warn('[TLS-Sidecar] Health check failed'); - this.ready = false; - } else if (ok && !this.ready) { - logger.info('[TLS-Sidecar] Recovered'); - this.ready = true; - } - }, HEALTH_CHECK_INTERVAL); - } - - _stopHealthCheck() { - if (this.healthCheckTimer) { - clearInterval(this.healthCheckTimer); - this.healthCheckTimer = null; - } - } -} - -function sleep(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); -} - -// 单例 -let instance = null; - -export function getTLSSidecar() { - if (!instance) { - instance = new TLSSidecar(); - } - return instance; -} - -export default TLSSidecar; diff --git a/src/utils/token-utils.js b/src/utils/token-utils.js deleted file mode 100644 index 59049fddb34d10d1b7b5d4bebe4f9bedc6ae3d19..0000000000000000000000000000000000000000 --- a/src/utils/token-utils.js +++ /dev/null @@ -1,170 +0,0 @@ -import { countTokens } from '@anthropic-ai/tokenizer'; -import logger from './logger.js'; - -/** - * Extract text content from message format - */ -export function getContentText(message) { - if (message == null) { - return ""; - } - if (Array.isArray(message)) { - return message.map(part => { - if (typeof part === 'string') return part; - if (part && typeof part === 'object') { - if (part.type === 'text' && part.text) return part.text; - if (part.text) return part.text; - } - return ''; - }).join(''); - } else if (typeof message.content === 'string') { - return message.content; - } else if (Array.isArray(message.content)) { - return message.content.map(part => { - if (typeof part === 'string') return part; - if (part && typeof part === 'object') { - if (part.type === 'text' && part.text) return part.text; - if (part.text) return part.text; - } - return ''; - }).join(''); - } - return String(message.content || message); -} - -/** - * Process content blocks into text - * @param {any} content - content object or array - * @returns {string} processed text - */ -export function processContent(content) { - if (!content) return ""; - if (typeof content === 'string') return content; - if (Array.isArray(content)) { - return content.map(part => { - if (typeof part === 'string') return part; - if (part && typeof part === 'object') { - if (part.type === 'text') return part.text || ""; - if (part.type === 'thinking') return part.thinking || part.text || ""; - if (part.type === 'tool_result') return processContent(part.content); - if (part.type === 'tool_use' && part.input) return JSON.stringify(part.input); - if (part.text) return part.text; - } - return ""; - }).join(""); - } - return getContentText(content); -} - -/** - * Count tokens for a given text using Claude's official tokenizer - */ -export function countTextTokens(text) { - if (!text) return 0; - try { - return countTokens(text); - } catch (error) { - // Fallback to estimation if tokenizer fails - logger.warn('[TokenUtils] Tokenizer error, falling back to estimation:', error.message); - return Math.ceil((text || '').length / 4); - } -} - -/** - * Calculate input tokens from request body using Claude's official tokenizer - */ -export function estimateInputTokens(requestBody) { - let allText = ""; - - // Count system prompt tokens - if (requestBody.system) { - allText += processContent(requestBody.system); - } - - // Count thinking prefix tokens if thinking is enabled - if (requestBody.thinking?.type && typeof requestBody.thinking.type === 'string') { - const t = requestBody.thinking.type.toLowerCase().trim(); - if (t === 'enabled') { - const budgetTokens = requestBody.thinking.budget_tokens; - let budget = Number(budgetTokens); - if (!Number.isFinite(budget) || budget <= 0) { - budget = 20000; - } - budget = Math.floor(budget); - if (budget < 1024) budget = 1024; - budget = Math.min(budget, 24576); - allText += `enabled${budget}`; - } -else if (t === 'adaptive') { - const effortRaw = typeof requestBody.thinking.effort === 'string' ? requestBody.thinking.effort : ''; - const effort = effortRaw.toLowerCase().trim(); - const normalizedEffort = (effort === 'low' || effort === 'medium' || effort === 'high') ? effort : 'high'; - allText += `adaptive${normalizedEffort}`; - } - } - - // Count all messages tokens - if (requestBody.messages && Array.isArray(requestBody.messages)) { - for (const message of requestBody.messages) { - if (message.content) { - allText += processContent(message.content); - } - } - } - - // Count tools definitions tokens if present - if (requestBody.tools && Array.isArray(requestBody.tools)) { - allText += JSON.stringify(requestBody.tools); - } - - return countTextTokens(allText); -} - -/** - * Count tokens for a message request (compatible with Anthropic API) - * @param {Object} requestBody - The request body containing model, messages, system, tools, etc. - * @returns {Object} { input_tokens: number } - */ -export function countTokensAnthropic(requestBody) { - let allText = ""; - let extraTokens = 0; - - // Count system prompt tokens - if (requestBody.system) { - allText += processContent(requestBody.system); - } - - // Count all messages tokens - if (requestBody.messages && Array.isArray(requestBody.messages)) { - for (const message of requestBody.messages) { - if (message.content) { - if (Array.isArray(message.content)) { - for (const block of message.content) { - if (block.type === 'image') { - // Images have a fixed token cost (approximately 1600 tokens for a typical image) - extraTokens += 1600; - } else if (block.type === 'document') { - // Documents - estimate based on content if available - if (block.source?.data) { - // For base64 encoded documents, estimate tokens - const estimatedChars = block.source.data.length * 0.75; // base64 to bytes ratio - extraTokens += Math.ceil(estimatedChars / 4); - } - } else { - allText += processContent([block]); - } - } - } else { - allText += processContent(message.content); - } - } - } - } - - // Count tools definitions tokens if present - if (requestBody.tools && Array.isArray(requestBody.tools)) { - allText += JSON.stringify(requestBody.tools); - } - - return { input_tokens: countTextTokens(allText) + extraTokens }; -} diff --git a/static/app/I18N_GUIDE.md b/static/app/I18N_GUIDE.md deleted file mode 100644 index b14271c86782576632f0e9f4dbbfc9dc27e746d0..0000000000000000000000000000000000000000 --- a/static/app/I18N_GUIDE.md +++ /dev/null @@ -1,157 +0,0 @@ -# 多语言支持使用指南 - -## 概述 - -本项目已实现中英文双语支持,可以通过页面右上角的语言切换按钮在简体中文和英文之间切换。 - -## 文件结构 - -``` -static/app/ -├── i18n.js # 多语言配置文件(包含所有翻译) -├── language-switcher.js # 语言切换组件 -└── I18N_GUIDE.md # 本指南 -``` - -## 如何使用 - -### 1. 在 HTML 中添加多语言支持 - -使用 `data-i18n` 属性标记需要翻译的元素: - -```html - -

AIClient2API 管理控制台

- - - - - - - - -共 10 个配置文件 -``` - -### 2. 在 JavaScript 中使用翻译 - -```javascript -import { t } from './i18n.js'; - -// 简单翻译 -const title = t('header.title'); - -// 带参数的翻译 -const message = t('upload.count', { count: 10 }); - -// 在 showToast 中使用 -showToast(t('common.success'), t('config.saved'), 'success'); -``` - -### 3. 添加新的翻译 - -在 `i18n.js` 文件中的 `translations` 对象中添加: - -```javascript -const translations = { - 'zh-CN': { - 'your.key': '你的中文翻译', - // ... - }, - 'en-US': { - 'your.key': 'Your English translation', - // ... - } -}; -``` - -### 4. 动态内容的翻译 - -对于动态生成的内容,在创建 DOM 元素时添加 `data-i18n` 属性: - -```javascript -const element = document.createElement('div'); -element.setAttribute('data-i18n', 'your.translation.key'); -element.textContent = t('your.translation.key'); -``` - -## 翻译键命名规范 - -使用点号分隔的层级结构: - -- `header.*` - 页头相关 -- `nav.*` - 导航相关 -- `dashboard.*` - 仪表盘相关 -- `config.*` - 配置相关 -- `providers.*` - 提供商相关 -- `upload.*` - 上传配置相关 -- `usage.*` - 用量查询相关 -- `logs.*` - 日志相关 -- `common.*` - 通用文本 - -## 已实现的功能 - -✅ 自动检测并保存用户语言偏好 -✅ 页面刷新后保持语言选择 -✅ 动态添加的元素自动翻译 -✅ 支持带参数的翻译 -✅ 语言切换时实时更新所有文本 - -## 待完善的部分 - -由于页面内容较多,以下部分需要继续添加 `data-i18n` 属性: - -1. 配置管理页面的表单标签和提示 -2. 提供商池管理的详细信息 -3. 配置管理的列表项 -4. 用量查询的统计信息 -5. 实时日志的控制按钮 - -## 示例:完整的多语言表单 - -```html -
- - -
-``` - -对应的翻译配置: - -```javascript -'zh-CN': { - 'config.apiKey': 'API密钥', - 'config.apiKeyPlaceholder': '请输入API密钥' -}, -'en-US': { - 'config.apiKey': 'API Key', - 'config.apiKeyPlaceholder': 'Please enter API key' -} -``` - -## 注意事项 - -1. 所有翻译键必须在两种语言中都存在 -2. 参数化翻译使用 `{paramName}` 格式 -3. HTML 内容使用 `data-i18n-html` 属性 -4. 语言切换会触发 `languageChanged` 事件 -5. 新添加的 DOM 元素会自动被翻译系统检测 - -## 调试 - -在浏览器控制台中: - -```javascript -// 获取当前语言 -import { getCurrentLanguage } from './app/i18n.js'; -console.log(getCurrentLanguage()); - -// 手动切换语言 -import { setLanguage } from './app/i18n.js'; -setLanguage('en-US'); \ No newline at end of file diff --git a/static/app/app.js b/static/app/app.js deleted file mode 100644 index 270410457fde3cb5e24bc4bc29ca3bc1f5cf545f..0000000000000000000000000000000000000000 --- a/static/app/app.js +++ /dev/null @@ -1,256 +0,0 @@ -// 主应用入口文件 - 模块化版本 - -// 导入所有模块 -import { - providerStats, - REFRESH_INTERVALS -} from './constants.js'; - -import { - showToast, - getProviderStats -} from './utils.js'; - -import { t } from './i18n.js'; - -import { - initFileUpload, - fileUploadHandler -} from './file-upload.js'; - -import { - initNavigation -} from './navigation.js'; - -import { - initEventListeners, - setDataLoaders, - setReloadConfig -} from './event-handlers.js'; - -import { - initEventStream, - setProviderLoaders, - setConfigLoaders -} from './event-stream.js'; - -import { - loadSystemInfo, - updateTimeDisplay, - loadProviders, - openProviderManager, - showAuthModal, - executeGenerateAuthUrl, - handleGenerateAuthUrl -} from './provider-manager.js'; - -import { - loadConfiguration, - saveConfiguration, - generateApiKey -} from './config-manager.js'; - -import { - showProviderManagerModal, - refreshProviderConfig -} from './modal.js'; - -import { - initRoutingExamples -} from './routing-examples.js'; - -import { - initUploadConfigManager, - loadConfigList, - viewConfig, - deleteConfig, - closeConfigModal, - copyConfigContent, - reloadConfig -} from './upload-config-manager.js'; - -import { - initUsageManager, - refreshUsage -} from './usage-manager.js'; - -import { - initImageZoom -} from './image-zoom.js'; - -import { - initPluginManager, - togglePlugin -} from './plugin-manager.js'; - -import { - initTutorialManager -} from './tutorial-manager.js'; - -/** - * 加载初始数据 - */ -function loadInitialData() { - loadSystemInfo(); - loadProviders(); - loadConfiguration(); - // showToast('数据已刷新', 'success'); -} - -/** - * 初始化应用 - */ -function initApp() { - // 设置数据加载器 - setDataLoaders(loadInitialData, saveConfiguration); - - // 设置reloadConfig函数 - setReloadConfig(reloadConfig); - - // 设置提供商加载器 - setProviderLoaders(loadProviders, refreshProviderConfig); - - // 设置配置加载器 - setConfigLoaders(loadConfigList); - - // 初始化各个模块 - initNavigation(); - initEventListeners(); - initEventStream(); - initFileUpload(); // 初始化文件上传功能 - initRoutingExamples(); // 初始化路径路由示例功能 - initUploadConfigManager(); // 初始化配置管理功能 - initUsageManager(); // 初始化用量管理功能 - initImageZoom(); // 初始化图片放大功能 - initPluginManager(); // 初始化插件管理功能 - initTutorialManager(); // 初始化教程管理功能 - initMobileMenu(); // 初始化移动端菜单 - loadInitialData(); - - // 显示欢迎消息 - showToast(t('common.success'), t('common.welcome'), 'success'); - - // 每5秒更新服务器时间和运行时间显示 - setInterval(() => { - updateTimeDisplay(); - }, 5000); - - // 定期刷新系统信息 - setInterval(() => { - loadProviders(); - - if (providerStats.activeProviders > 0) { - const stats = getProviderStats(providerStats); - console.log('=== 提供商统计报告 ==='); - console.log(`活跃提供商: ${stats.activeProviders}`); - console.log(`健康提供商: ${stats.healthyProviders} (${stats.healthRatio})`); - console.log(`总账户数: ${stats.totalAccounts}`); - console.log(`总请求数: ${stats.totalRequests}`); - console.log(`总错误数: ${stats.totalErrors}`); - console.log(`成功率: ${stats.successRate}`); - console.log(`平均每提供商请求数: ${stats.avgUsagePerProvider}`); - console.log('========================'); - } - }, REFRESH_INTERVALS.SYSTEM_INFO); - -} - -/** - * 初始化移动端菜单 - */ -function initMobileMenu() { - const mobileMenuToggle = document.getElementById('mobileMenuToggle'); - const headerControls = document.getElementById('headerControls'); - - if (!mobileMenuToggle || !headerControls) { - console.log('Mobile menu elements not found'); - return; - } - - // 默认隐藏header-controls - headerControls.style.display = 'none'; - - let isMenuOpen = false; - - mobileMenuToggle.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - - console.log('Mobile menu toggle clicked, current state:', isMenuOpen); - - isMenuOpen = !isMenuOpen; - - if (isMenuOpen) { - headerControls.style.display = 'flex'; - mobileMenuToggle.innerHTML = ''; - console.log('Menu opened'); - } else { - headerControls.style.display = 'none'; - mobileMenuToggle.innerHTML = ''; - console.log('Menu closed'); - } - }); - - // 点击页面其他地方关闭菜单 - document.addEventListener('click', (e) => { - if (isMenuOpen && !mobileMenuToggle.contains(e.target) && !headerControls.contains(e.target)) { - isMenuOpen = false; - headerControls.style.display = 'none'; - mobileMenuToggle.innerHTML = ''; - console.log('Menu closed by clicking outside'); - } - }); -} - -// 等待组件加载完成后初始化应用 -// 组件加载器会在所有组件加载完成后触发 'componentsLoaded' 事件 -window.addEventListener('componentsLoaded', initApp); - -// 如果组件已经加载完成(例如页面刷新后),也需要初始化 -// 检查是否有组件已经存在 -document.addEventListener('DOMContentLoaded', () => { - // 如果 sidebar 和 content 已经有内容,说明组件已加载 - const sidebarContainer = document.getElementById('sidebar-container'); - const contentContainer = document.getElementById('content-container'); - - // 如果容器不存在或为空,说明使用的是组件加载方式,等待 componentsLoaded 事件 - // 如果容器已有内容,说明是静态 HTML,直接初始化 - if (sidebarContainer && contentContainer) { - const hasContent = sidebarContainer.children.length > 0 || contentContainer.children.length > 0; - if (hasContent) { - // 静态 HTML 方式,直接初始化 - initApp(); - } - // 否则等待 componentsLoaded 事件 - } -}); - -// 导出全局函数供其他模块使用 -window.loadProviders = loadProviders; -window.openProviderManager = openProviderManager; -window.showProviderManagerModal = showProviderManagerModal; -window.refreshProviderConfig = refreshProviderConfig; -window.fileUploadHandler = fileUploadHandler; -window.showAuthModal = showAuthModal; -window.executeGenerateAuthUrl = executeGenerateAuthUrl; -window.handleGenerateAuthUrl = handleGenerateAuthUrl; - -// 配置管理相关全局函数 -window.viewConfig = viewConfig; -window.deleteConfig = deleteConfig; -window.loadConfigList = loadConfigList; -window.closeConfigModal = closeConfigModal; -window.copyConfigContent = copyConfigContent; -window.reloadConfig = reloadConfig; -window.generateApiKey = generateApiKey; - -// 用量管理相关全局函数 -window.refreshUsage = refreshUsage; - -// 插件管理相关全局函数 -window.togglePlugin = togglePlugin; - -// 导出调试函数 -window.getProviderStats = () => getProviderStats(providerStats); - -console.log('AIClient2API 管理控制台已加载 - 模块化版本'); diff --git a/static/app/auth.js b/static/app/auth.js deleted file mode 100644 index c6a50835b673ebd1136e1c0c1ea7026d8f47ad05..0000000000000000000000000000000000000000 --- a/static/app/auth.js +++ /dev/null @@ -1,329 +0,0 @@ -// 认证模块 - 处理token管理和API调用封装 -/** - * 认证管理类 - */ -class AuthManager { - constructor() { - this.tokenKey = 'authToken'; - this.expiryKey = 'authTokenExpiry'; - this.baseURL = window.location.origin; - } - - /** - * 获取存储的token - */ - getToken() { - return localStorage.getItem(this.tokenKey); - } - - /** - * 获取token过期时间 - */ - getTokenExpiry() { - const expiry = localStorage.getItem(this.expiryKey); - return expiry ? parseInt(expiry) : null; - } - - /** - * 检查token是否有效 - */ - isTokenValid() { - const token = this.getToken(); - const expiry = this.getTokenExpiry(); - - if (!token) return false; - - // 如果设置了过期时间,检查是否过期 - if (expiry && Date.now() > expiry) { - this.clearToken(); - return false; - } - - return true; - } - - /** - * 保存token到本地存储 - */ - saveToken(token, rememberMe = false) { - localStorage.setItem(this.tokenKey, token); - - if (rememberMe) { - const expiryTime = Date.now() + (7 * 24 * 60 * 60 * 1000); // 7天 - localStorage.setItem(this.expiryKey, expiryTime.toString()); - } - } - - /** - * 清除token - */ - clearToken() { - localStorage.removeItem(this.tokenKey); - localStorage.removeItem(this.expiryKey); - } - - /** - * 登出 - */ - async logout() { - this.clearToken(); - window.location.href = '/login.html'; - } -} - -/** - * API调用封装类 - */ -class ApiClient { - constructor() { - this.authManager = new AuthManager(); - this.baseURL = window.location.origin; - } - - /** - * 获取带认证的请求头 - */ - getAuthHeaders() { - const token = this.authManager.getToken(); - return token ? { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json' - } : { - 'Content-Type': 'application/json' - }; - } - - /** - * 处理401错误重定向到登录页 - */ - handleUnauthorized() { - this.authManager.clearToken(); - window.location.href = '/login.html'; - } - - /** - * 通用API请求方法 - */ - async request(endpoint, options = {}) { - const url = `${this.baseURL}/api${endpoint}`; - const headers = { - ...this.getAuthHeaders(), - ...options.headers - }; - - const config = { - ...options, - headers - }; - - try { - const response = await fetch(url, config); - - // 如果是401错误,重定向到登录页 - if (response.status === 401) { - this.handleUnauthorized(); - throw new Error('未授权访问'); - } - - const contentType = response.headers.get('content-type'); - if (contentType && contentType.includes('application/json')) { - return await response.json(); - } else { - return await response.text(); - } - } catch (error) { - if (error.message === '未授权访问') { - // 已经在handleUnauthorized中处理了重定向 - throw error; - } - console.error('API请求错误:', error); - throw error; - } - } - - /** - * GET请求 - */ - async get(endpoint, params = {}) { - const queryString = new URLSearchParams(params).toString(); - const url = queryString ? `${endpoint}?${queryString}` : endpoint; - return this.request(url, { method: 'GET' }); - } - - /** - * POST请求 - */ - async post(endpoint, data = {}) { - return this.request(endpoint, { - method: 'POST', - body: JSON.stringify(data) - }); - } - - /** - * PUT请求 - */ - async put(endpoint, data = {}) { - return this.request(endpoint, { - method: 'PUT', - body: JSON.stringify(data) - }); - } - - /** - * DELETE请求 - */ - async delete(endpoint) { - return this.request(endpoint, { method: 'DELETE' }); - } - - /** - * POST请求(支持FormData上传) - */ - async upload(endpoint, formData) { - const url = `${this.baseURL}/api${endpoint}`; - - // 获取认证token - const token = this.authManager.getToken(); - const headers = {}; - - // 如果有token,添加Authorization头部 - if (token) { - headers['Authorization'] = `Bearer ${token}`; - } - - // 对于FormData请求,不添加Content-Type头部,让浏览器自动设置 - const config = { - method: 'POST', - headers, - body: formData - }; - - try { - const response = await fetch(url, config); - - // 如果是401错误,重定向到登录页 - if (response.status === 401) { - this.handleUnauthorized(); - throw new Error('未授权访问'); - } - - const contentType = response.headers.get('content-type'); - if (contentType && contentType.includes('application/json')) { - return await response.json(); - } else { - return await response.text(); - } - } catch (error) { - if (error.message === '未授权访问') { - // 已经在handleUnauthorized中处理了重定向 - throw error; - } - console.error('API请求错误:', error); - throw error; - } - } -} - -/** - * 初始化认证检查 - */ -async function initAuth() { - const authManager = new AuthManager(); - - // 检查是否已经有有效的token - if (authManager.isTokenValid()) { - // 验证token是否仍然有效(发送一个测试请求) - try { - const apiClient = new ApiClient(); - await apiClient.get('/health'); - return true; - } catch (error) { - // Token无效,清除并重定向到登录页 - authManager.clearToken(); - window.location.href = '/login.html'; - return false; - } - } else { - // 没有有效token,重定向到登录页 - window.location.href = '/login.html'; - return false; - } -} - -/** - * 登出函数 - */ -async function logout() { - const authManager = new AuthManager(); - await authManager.logout(); -} - -/** - * 登录函数(供登录页面使用) - */ -async function login(password, rememberMe = false) { - try { - const response = await fetch('/api/login', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - password, - rememberMe - }) - }); - - const data = await response.json(); - - if (data.success) { - // 保存token - const authManager = new AuthManager(); - authManager.saveToken(data.token, rememberMe); - return { success: true }; - } else { - return { success: false, message: data.message }; - } - } catch (error) { - console.error('登录错误:', error); - return { success: false, message: '登录失败,请检查网络连接' }; - } -} - -// 创建单例实例 -const authManager = new AuthManager(); -const apiClient = new ApiClient(); - -/** - * 获取带认证的请求头(便捷函数) - * @returns {Object} 包含认证信息的请求头 - */ -function getAuthHeaders() { - return apiClient.getAuthHeaders(); -} - -// 导出实例到 window(兼容旧代码) -window.authManager = authManager; -window.apiClient = apiClient; -window.initAuth = initAuth; -window.logout = logout; -window.login = login; - -// 导出认证管理器类和API客户端类供其他模块使用 -window.AuthManager = AuthManager; -window.ApiClient = ApiClient; - -// ES6 模块导出 -export { - AuthManager, - ApiClient, - authManager, - apiClient, - initAuth, - logout, - login, - getAuthHeaders -}; - -console.log('认证模块已加载'); \ No newline at end of file diff --git a/static/app/base.css b/static/app/base.css deleted file mode 100644 index 74f14a9d04a674a778d870ff3ec1624daf7be436..0000000000000000000000000000000000000000 --- a/static/app/base.css +++ /dev/null @@ -1,694 +0,0 @@ -/* CSS变量 - 亮色主题(默认) */ -:root { - /* 主色调 */ - --primary-color: #059669; - --primary-hover: #047857; - --primary-light: #34d399; - - /* 辅助色 */ - --secondary-color: #10b981; - --success-color: #10b981; - --danger-color: #ef4444; - --warning-color: #f59e0b; - --info-color: #3b82f6; - - /* 背景色 */ - --bg-primary: #ffffff; - --bg-secondary: #f8fafc; - --bg-tertiary: #f1f5f9; - --bg-glass: rgba(255, 255, 255, 0.8); - --bg-glass-strong: rgba(255, 255, 255, 0.95); - - /* 文本色 */ - --text-primary: #0f172a; - --text-secondary: #475569; - --text-tertiary: #94a3b8; - - /* 边框和分割线 */ - --border-color: #e2e8f0; - --border-hover: #cbd5e1; - - /* 阴影 - 更加柔和现代 */ - --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); - --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03); - --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.05), 0 4px 6px -2px rgba(0, 0, 0, 0.025); - --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.05), 0 10px 10px -5px rgba(0, 0, 0, 0.02); - --shadow-glass: 0 8px 32px 0 rgba(31, 38, 135, 0.07); - - /* 圆角 */ - --radius-sm: 0.375rem; - --radius-md: 0.5rem; - --radius-lg: 0.75rem; - --radius-xl: 1rem; - --radius-full: 9999px; - - /* 动画 */ - --transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); - - /* 品牌色 */ - --indigo-500: #6366f1; - --indigo-600: #4f46e5; - - /* 辅助色透明度版本 */ - --primary-10: rgba(5, 150, 105, 0.1); - --primary-20: rgba(5, 150, 105, 0.2); - --primary-30: rgba(5, 150, 105, 0.3); - --primary-40: rgba(5, 150, 105, 0.4); - - --success-10: rgba(16, 185, 129, 0.1); - - --warning-15: rgba(245, 158, 11, 0.15); - --warning-20: rgba(245, 158, 11, 0.2); - --warning-25: rgba(245, 158, 11, 0.25); - --warning-30: rgba(245, 158, 11, 0.3); - --warning-40: rgba(245, 158, 11, 0.4); - --warning-50: rgba(245, 158, 11, 0.5); - - --danger-30: rgba(220, 53, 69, 0.3); - --danger-40: rgba(220, 53, 69, 0.4); - --danger-70: rgba(220, 53, 69, 0.7); - - --neutral-shadow-sm: rgba(0, 0, 0, 0.05); - --neutral-shadow-md: rgba(0, 0, 0, 0.1); - --neutral-shadow-lg: rgba(0, 0, 0, 0.15); - --neutral-shadow-20: rgba(0, 0, 0, 0.2); - --neutral-shadow-30: rgba(0, 0, 0, 0.3); - --neutral-shadow-40: rgba(0, 0, 0, 0.4); - --neutral-shadow-50: rgba(0, 0, 0, 0.5); - --neutral-shadow-85: rgba(0, 0, 0, 0.85); - --neutral-shadow-95: rgba(0, 0, 0, 0.95); - - --indigo-30: rgba(99, 102, 241, 0.3); - --indigo-40: rgba(99, 102, 241, 0.4); - - --white-20: rgba(255, 255, 255, 0.2); - - /* 基础颜色 */ - --white: #ffffff; - --black: #000000; - - /* 遮罩背景 */ - --overlay-bg: rgba(0, 0, 0, 0.6); - - /* 代码块和日志区域 */ - --code-bg: #1e1e1e; - --code-text: #d4d4d4; - - /* 主题切换按钮 */ - --theme-toggle-bg: var(--bg-tertiary); - --theme-toggle-icon: var(--text-secondary); - - /* 警告/高亮颜色 */ - --warning-bg: #fef3c7; - --warning-bg-light: #fde68a; - --warning-bg-dark: #78350f; - --warning-border: #fbbf24; - --warning-text: #92400e; - --warning-text-dark: #d97706; - --warning-text-darker: #b45309; - --warning-bg-alt: #fffbeb; - - /* 成功/健康颜色 */ - --success-bg: #d1fae5; - --success-bg-light: #ecfdf5; - --success-bg-alt: #f0fdf4; - --success-text: #065f46; - --success-text-light: #6ee7b7; - - /* 错误/危险颜色 */ - --danger-bg: #fee2e2; - --danger-bg-light: #fef2f2; - --danger-bg-alt: #fff5f5; - --danger-bg-medium: #fed7d7; - --danger-border: #fca5a5; - --danger-border-light: #feb2b2; - --danger-border-dark: #fecaca; - --danger-text: #991b1b; - --danger-text-light: #7f1d1d; - --danger-text-dark: #742a2a; - --danger-icon: #e53e3e; - --danger-label: #c53030; - --danger-alt: #dc3545; - --danger-secondary: #fd7e14; - - /* 信息/蓝色颜色 */ - --info-bg: #dbeafe; - --info-bg-light: #e0f2fe; - --info-bg-lighter: #eff6ff; - --info-bg-alt: #f0f9ff; - --info-text: #1e40af; - --info-text-dark: #0369a1; - --info-text-darker: #075985; - --info-border: #0ea5e9; - --info-hover: #bae6fd; - --info-color: #3b82f6; - --info-color-dark: #2563eb; - - /* 中性灰色 */ - --neutral-100: #f8f9fa; - --neutral-200: #e9ecef; - --neutral-300: #dee2e6; - --neutral-400: #adb5bd; - --neutral-500: #6c757d; - --neutral-600: #495057; - --neutral-700: #2c3e50; - --neutral-800: #8b95a5; - --neutral-alt: #f1f3f4; - - /* 日志颜色 */ - --log-time: #858585; - --log-info: #4ec9b0; - --log-error: #f48771; - --log-warn: #dcdcaa; - - /* 按钮颜色 */ - --btn-success: #28a745; - --btn-success-secondary: #20c997; - --btn-primary-hover: #047857; -} - -/* CSS变量 - 暗黑主题 */ -[data-theme="dark"] { - --primary-color: #34d399; - --primary-hover: #10b981; - --primary-light: #6ee7b7; - - --secondary-color: #34d399; - --success-color: #34d399; - --danger-color: #f87171; - --warning-color: #fbbf24; - --info-color: #60a5fa; - - --bg-primary: #0f172a; - --bg-secondary: #1e293b; - --bg-tertiary: #334155; - --bg-glass: rgba(15, 23, 42, 0.8); - --bg-glass-strong: rgba(15, 23, 42, 0.95); - - --text-primary: #f8fafc; - --text-secondary: #cbd5e1; - --text-tertiary: #64748b; - - --border-color: #334155; - --border-hover: #475569; - - --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3); - --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4); - --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5); - --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.6); - --shadow-glass: 0 8px 32px 0 rgba(0, 0, 0, 0.3); - - /* 辅助色透明度版本 - 暗色 */ - --primary-10: rgba(16, 185, 129, 0.1); - --primary-20: rgba(16, 185, 129, 0.2); - --primary-30: rgba(16, 185, 129, 0.3); - --primary-40: rgba(16, 185, 129, 0.4); - - --success-10: rgba(16, 185, 129, 0.1); - - --warning-15: rgba(251, 191, 36, 0.15); - --warning-20: rgba(251, 191, 36, 0.2); - --warning-25: rgba(251, 191, 36, 0.25); - --warning-30: rgba(251, 191, 36, 0.3); - --warning-40: rgba(251, 191, 36, 0.4); - --warning-50: rgba(251, 191, 36, 0.5); - - --danger-30: rgba(248, 113, 113, 0.3); - --danger-40: rgba(248, 113, 113, 0.4); - --danger-70: rgba(248, 113, 113, 0.7); - - /* 基础颜色 */ - --white: #ffffff; - --black: #000000; - - /* 遮罩背景 */ - --overlay-bg: rgba(0, 0, 0, 0.8); - - /* 代码块和日志区域 */ - --code-bg: #0d1117; - --code-text: #e6edf3; - - /* 主题切换按钮 */ - --theme-toggle-bg: var(--bg-tertiary); - --theme-toggle-icon: #fbbf24; - - /* 警告/高亮颜色 */ - --warning-bg: #78350f; - --warning-bg-light: #92400e; - --warning-bg-dark: #78350f; - --warning-border: #b45309; - --warning-text: #fef3c7; - --warning-text-dark: #fde68a; - --warning-text-darker: #fde68a; - --warning-bg-alt: #78350f; - - /* 成功/健康颜色 */ - --success-bg: #064e3b; - --success-bg-light: #065f46; - --success-bg-alt: #064e3b; - --success-text: #6ee7b7; - --success-text-light: #6ee7b7; - - /* 错误/危险颜色 */ - --danger-bg: #7f1d1d; - --danger-bg-light: #7f1d1d; - --danger-bg-alt: #7f1d1d; - --danger-bg-medium: #991b1b; - --danger-border: #dc2626; - --danger-border-light: #dc2626; - --danger-border-dark: #dc2626; - --danger-text: #fca5a5; - --danger-text-light: #fecaca; - --danger-text-dark: #fecaca; - --danger-icon: #fca5a5; - --danger-label: #fca5a5; - --danger-alt: #dc2626; - --danger-secondary: #dc2626; - - /* 信息/蓝色颜色 */ - --info-bg: #1e3a5f; - --info-bg-light: #1e3a5f; - --info-bg-lighter: #1e3a5f; - --info-bg-alt: #1e3a5f; - --info-text: #93c5fd; - --info-text-dark: #93c5fd; - --info-text-darker: #93c5fd; - --info-border: #3b82f6; - --info-hover: #1e3a5f; - --info-color: #3b82f6; - --info-color-dark: #3b82f6; - - /* 中性灰色 */ - --neutral-100: var(--bg-secondary); - --neutral-200: var(--border-color); - --neutral-300: var(--border-color); - --neutral-400: var(--text-secondary); - --neutral-500: var(--text-secondary); - --neutral-600: var(--text-primary); - --neutral-700: var(--text-primary); - --neutral-800: var(--text-secondary); - --neutral-alt: var(--bg-tertiary); - - /* 日志颜色 */ - --log-time: #858585; - --log-info: #4ec9b0; - --log-error: #f48771; - --log-warn: #dcdcaa; - - /* 按钮颜色 */ - --btn-success: #28a745; - --btn-success-secondary: #20c997; - --btn-primary-hover: #047857; -} - -/* 基础样式 */ -* { - margin: 0; - padding: 0; - box-sizing: border-box; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -body { - font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; - background: var(--bg-secondary); - color: var(--text-primary); - line-height: 1.6; - background-image: - radial-gradient(at 0% 0%, rgba(var(--primary-rgb), 0.05) 0px, transparent 50%), - radial-gradient(at 100% 0%, rgba(var(--indigo-rgb), 0.05) 0px, transparent 50%); -} - -/* 滚动条美化 */ -::-webkit-scrollbar { - width: 8px; - height: 8px; -} - -::-webkit-scrollbar-track { - background: transparent; -} - -::-webkit-scrollbar-thumb { - background: var(--border-color); - border-radius: 4px; -} - -::-webkit-scrollbar-thumb:hover { - background: var(--text-tertiary); -} - -/* 容器 */ -.container { - display: flex; - flex-direction: column; - min-height: 100vh; -} - -/* 主要内容区域 */ -.main-content { - display: flex; - flex: 1; - max-width: 1600px; - width: 100%; - margin: 0 auto; - padding: 1.5rem; - gap: 1.5rem; -} - -/* 内容区域 */ -.content { - flex: 1; - padding: 0; - overflow-x: hidden; -} - -.section { - display: none; -} - -.section.active { - display: block; -} - -.section h2 { - font-size: 1.5rem; - font-weight: 700; - margin-bottom: 1.5rem; - color: var(--text-primary); - letter-spacing: -0.025em; - display: flex; - align-items: center; - gap: 0.75rem; -} - -/* 按钮基础样式 */ -.btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 0.5rem; - padding: 0.625rem 1.25rem; - border: 1px solid transparent; - border-radius: var(--radius-lg); - font-size: 0.875rem; - font-weight: 600; - cursor: pointer; - transition: var(--transition); - text-decoration: none; - line-height: 1.25; -} - -.btn:active { - transform: scale(0.98); -} - -.btn-primary { - background: var(--primary-color); - color: #ffffff; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); -} - -.btn-primary:hover { - background: var(--primary-hover); - box-shadow: 0 4px 6px -1px var(--primary-20); -} - -.btn-secondary { - background: var(--bg-primary); - color: var(--text-primary); - border-color: var(--border-color); -} - -.btn-secondary:hover { - background: var(--bg-secondary); - border-color: var(--text-secondary); -} - -.btn-success { - background: var(--success-color); - color: white; -} - -.btn-success:hover { - filter: brightness(1.1); - box-shadow: 0 4px 6px -1px var(--success-10); -} - -.btn-danger { - background: var(--bg-primary); - color: var(--danger-color); - border-color: var(--danger-border); -} - -.btn-danger:hover { - background: var(--danger-bg); - border-color: var(--danger-color); -} - -.btn-small { - padding: 8px 12px; - font-size: 12px; - border: none; - border-radius: 0.375rem; - cursor: pointer; - display: flex; - align-items: center; - gap: 6px; - font-weight: 500; - transition: all 0.3s ease; -} - -.btn-sm { - padding: 4px 10px; - font-size: 12px; -} - -/* 文本辅助类 */ -.text-success { color: var(--success-color) !important; } -.text-warning { color: var(--warning-color) !important; } -.text-danger { color: var(--danger-color) !important; } - -/* 动画 */ -@keyframes fadeIn { - from { opacity: 0; } - to { opacity: 1; } -} - -@keyframes slideIn { - from { transform: scale(0.95); opacity: 0; } - to { transform: scale(1); opacity: 1; } -} - -@keyframes pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.5; } -} - -/* 主题切换动画 */ -body { - transition: background-color 0.3s ease, color 0.3s ease; -} - -.header, .sidebar, .content, .stat-card, .config-panel, .providers-container, -.routing-examples-panel, .system-info-panel, .upload-config-panel, .usage-panel, -.logs-container, .toast, .modal-content, .provider-modal-content { - transition: background-color 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease; -} - -/* 通用通知容器 */ -.toast-container { - position: fixed; - top: 1rem; - right: 1rem; - z-index: 1001; - display: flex; - flex-direction: column; - gap: 0.5rem; - pointer-events: none; -} - -.toast { - background: var(--bg-primary); - border: 1px solid var(--border-color); - border-radius: 0.5rem; - padding: 1rem 1.5rem; - box-shadow: var(--shadow-lg); - min-width: 300px; - animation: slideIn 0.3s ease; - pointer-events: auto; -} - -.toast.success { border-left: 4px solid var(--success-color); } -.toast.error { border-left: 4px solid var(--danger-color); } - -/* 复选框和单选框通用样式 */ -.checkbox-item, .radio-label { - display: flex; - align-items: center; - gap: 10px; - cursor: pointer; -} - -.form-control { - width: 100%; - padding: 0.75rem 1rem; - border: 1px solid var(--border-color); - border-radius: var(--radius-lg); - font-size: 0.9rem; - transition: var(--transition); - background: var(--bg-secondary); - color: var(--text-primary); -} - -.form-control:focus { - outline: none; - border-color: var(--primary-color); - background: var(--bg-primary); - box-shadow: 0 0 0 4px var(--primary-10); -} - -/* 主题切换按钮 */ -.theme-toggle { - display: inline-flex; - align-items: center; - justify-content: center; - width: 40px; - height: 40px; - padding: 0; - background: var(--theme-toggle-bg); - color: var(--theme-toggle-icon); - border: 1px solid var(--border-color); - border-radius: 50%; - cursor: pointer; - font-size: 1.125rem; - transition: var(--transition); - position: relative; - overflow: hidden; -} - -.theme-toggle:hover { - background: var(--bg-secondary); - border-color: var(--primary-color); - transform: translateY(-2px); - box-shadow: var(--shadow-md); -} - -.theme-toggle:active { - transform: translateY(0); -} - -.theme-toggle .fa-sun { display: none; } -.theme-toggle .fa-moon { display: inline-block; } -[data-theme="dark"] .theme-toggle .fa-sun { display: inline-block; } -[data-theme="dark"] .theme-toggle .fa-moon { display: none; } - -/* 语言切换器通用部分 */ -.language-switcher { - position: relative; - display: inline-block; -} - -.language-btn { - display: inline-flex; - align-items: center; - gap: 0.5rem; - padding: 0.5rem 1rem; - background: transparent; - color: var(--text-secondary); - border: 1px solid var(--border-color); - border-radius: 0.375rem; - cursor: pointer; - font-size: 0.875rem; - font-weight: 500; - transition: var(--transition); -} - -.language-btn:hover { - background: var(--bg-tertiary); - color: var(--primary-color); - border-color: var(--primary-color); - transform: translateY(-1px); -} - -.language-dropdown { - position: absolute; - top: calc(100% + 0.5rem); - right: 0; - background: var(--bg-primary); - border: 1px solid var(--border-color); - border-radius: 0.5rem; - box-shadow: var(--shadow-lg); - min-width: 150px; - opacity: 0; - visibility: hidden; - transform: translateY(-10px); - transition: all 0.3s ease; - z-index: 1000; -} - -.language-dropdown.show { - opacity: 1; - visibility: visible; - transform: translateY(0); -} - -.language-option { - display: flex; - align-items: center; - gap: 0.75rem; - width: 100%; - padding: 0.75rem 1rem; - background: none; - border: none; - text-align: left; - cursor: pointer; - font-size: 0.875rem; - color: var(--text-primary); - transition: var(--transition); -} - -.language-option:hover { - background: var(--bg-secondary); -} - -.language-option.active i { - opacity: 1; -} - -.language-option i { - font-size: 0.875rem; - color: var(--primary-color); - opacity: 0; - transition: opacity 0.3s ease; -} - -.status-used { - background: var(--success-bg); - color: var(--success-text); -} - -.status-unused { - background: var(--warning-bg); - color: var(--warning-text); -} - -.status-invalid { - background: var(--danger-bg); - color: var(--danger-text); -} - -.status-success { color: var(--success-color); } -.status-error { color: var(--danger-color); } - -/* 响应式调整通用部分 */ -@media (max-width: 768px) { - .main-content { - flex-direction: column; - } - - .form-row { - grid-template-columns: 1fr; - } -} diff --git a/static/app/component-loader.js b/static/app/component-loader.js deleted file mode 100644 index 52768edbb3409ee666d70b313e75ed14ad2b7797..0000000000000000000000000000000000000000 --- a/static/app/component-loader.js +++ /dev/null @@ -1,139 +0,0 @@ -/** - * 组件加载器 - 用于动态加载 HTML 组件片段 - * Component Loader - For dynamically loading HTML component fragments - */ - -// 组件缓存 -const componentCache = new Map(); - -/** - * 加载单个组件 - * @param {string} componentPath - 组件文件路径 - * @returns {Promise} - 组件 HTML 内容 - */ -async function loadComponent(componentPath) { - // 检查缓存 - if (componentCache.has(componentPath)) { - return componentCache.get(componentPath); - } - - try { - const response = await fetch(componentPath); - if (!response.ok) { - throw new Error(`Failed to load component: ${componentPath} (${response.status})`); - } - const html = await response.text(); - // 缓存组件 - componentCache.set(componentPath, html); - return html; - } catch (error) { - console.error(`Error loading component ${componentPath}:`, error); - throw error; - } -} - -/** - * 将组件插入到指定容器 - * @param {string} componentPath - 组件文件路径 - * @param {string|HTMLElement} container - 容器选择器或元素 - * @param {string} position - 插入位置: 'replace', 'append', 'prepend', 'beforeend', 'afterbegin' - * @returns {Promise} - */ -async function insertComponent(componentPath, container, position = 'beforeend') { - const html = await loadComponent(componentPath); - - const containerElement = typeof container === 'string' - ? document.querySelector(container) - : container; - - if (!containerElement) { - throw new Error(`Container not found: ${container}`); - } - - if (position === 'replace') { - containerElement.innerHTML = html; - } else { - containerElement.insertAdjacentHTML(position, html); - } -} - -/** - * 批量加载多个组件 - * @param {Array<{path: string, container: string, position?: string}>} components - 组件配置数组 - * @returns {Promise} - */ -async function loadComponents(components) { - const promises = components.map(({ path, container, position }) => - insertComponent(path, container, position) - ); - await Promise.all(promises); -} - -/** - * 初始化页面组件 - * 加载所有页面组件并插入到相应位置 - * @returns {Promise} - */ -async function initializeComponents() { - const basePath = 'components/'; - - // 定义组件配置 - const componentConfigs = [ - { path: `${basePath}header.html`, container: '.container', position: 'afterbegin' }, - { path: `${basePath}sidebar.html`, container: '#sidebar-container', position: 'replace' }, - { path: `${basePath}section-dashboard.html`, container: '#content-container', position: 'beforeend' }, - { path: `${basePath}section-config.html`, container: '#content-container', position: 'beforeend' }, - { path: `${basePath}section-upload-config.html`, container: '#content-container', position: 'beforeend' }, - { path: `${basePath}section-providers.html`, container: '#content-container', position: 'beforeend' }, - { path: `${basePath}section-usage.html`, container: '#content-container', position: 'beforeend' }, - { path: `${basePath}section-logs.html`, container: '#content-container', position: 'beforeend' }, - { path: `${basePath}section-plugins.html`, container: '#content-container', position: 'beforeend' }, - ]; - - try { - // 首先加载 header - await insertComponent(`${basePath}header.html`, '.container', 'afterbegin'); - - // 然后加载 sidebar - await insertComponent(`${basePath}sidebar.html`, '#sidebar-container', 'replace'); - - // 最后加载所有 section 组件 - const sectionComponents = [ - { path: `${basePath}section-dashboard.html`, container: '#content-container', position: 'beforeend' }, - { path: `${basePath}section-guide.html`, container: '#content-container', position: 'beforeend' }, - { path: `${basePath}section-tutorial.html`, container: '#content-container', position: 'beforeend' }, - { path: `${basePath}section-config.html`, container: '#content-container', position: 'beforeend' }, - { path: `${basePath}section-upload-config.html`, container: '#content-container', position: 'beforeend' }, - { path: `${basePath}section-providers.html`, container: '#content-container', position: 'beforeend' }, - { path: `${basePath}section-usage.html`, container: '#content-container', position: 'beforeend' }, - { path: `${basePath}section-logs.html`, container: '#content-container', position: 'beforeend' }, - { path: `${basePath}section-plugins.html`, container: '#content-container', position: 'beforeend' }, - ]; - - await loadComponents(sectionComponents); - - console.log('All components loaded successfully'); - // 触发组件加载完成事件 - window.dispatchEvent(new CustomEvent('componentsLoaded')); - - } catch (error) { - console.error('Failed to initialize components:', error); - throw error; - } -} - -/** - * 清除组件缓存 - */ -function clearComponentCache() { - componentCache.clear(); -} - -// 导出函数 -export { - loadComponent, - insertComponent, - loadComponents, - initializeComponents, - clearComponentCache -}; \ No newline at end of file diff --git a/static/app/config-manager.js b/static/app/config-manager.js deleted file mode 100644 index 68e99cc32b9ca2bee5d5595f2fcff9b6b17f6d7d..0000000000000000000000000000000000000000 --- a/static/app/config-manager.js +++ /dev/null @@ -1,409 +0,0 @@ -// 配置管理模块 - -import { showToast, formatUptime } from './utils.js'; -import { handleProviderChange, handleGeminiCredsTypeChange, handleKiroCredsTypeChange } from './event-handlers.js'; -import { loadProviders } from './provider-manager.js'; -import { t } from './i18n.js'; - -// 提供商配置缓存 -let currentProviderConfigs = null; - -/** - * 更新提供商配置并重新渲染配置页面的提供商选择标签 - * @param {Array} configs - 提供商配置列表 - */ -function updateConfigProviderConfigs(configs) { - currentProviderConfigs = configs; - - // 渲染基础设置中的模型提供商选择 - const modelProviderEl = document.getElementById('modelProvider'); - if (modelProviderEl) { - renderProviderTags(modelProviderEl, configs, true); - } - - // 渲染代理设置中的提供商选择 - const proxyProvidersEl = document.getElementById('proxyProviders'); - if (proxyProvidersEl) { - renderProviderTags(proxyProvidersEl, configs, false); - } - - // 渲染 TLS Sidecar 设置中的提供商选择 - const tlsSidecarProvidersEl = document.getElementById('tlsSidecarProviders'); - if (tlsSidecarProvidersEl) { - renderProviderTags(tlsSidecarProvidersEl, configs, false); - } - - // 重新加载当前配置以恢复选中状态 - loadConfiguration(); -} - -/** - * 渲染提供商标签按钮 - * @param {HTMLElement} container - 容器元素 - * @param {Array} configs - 提供商配置列表 - * @param {boolean} isRequired - 是否至少需要选择一个(用于点击事件逻辑) - */ -function renderProviderTags(container, configs, isRequired) { - // 过滤掉不可见的提供商 - const visibleConfigs = configs.filter(c => c.visible !== false); - - container.innerHTML = visibleConfigs.map(c => ` - - `).join(''); - - // 为新生成的标签添加点击事件 - const tags = container.querySelectorAll('.provider-tag'); - tags.forEach(tag => { - tag.addEventListener('click', (e) => { - e.preventDefault(); - const isSelected = tag.classList.contains('selected'); - - if (isRequired) { - const selectedCount = container.querySelectorAll('.provider-tag.selected').length; - // 如果当前是选中状态且只剩一个选中的,不允许取消 - if (isSelected && selectedCount === 1) { - showToast(t('common.warning'), t('config.modelProviderRequired'), 'warning'); - return; - } - } - - // 切换选中状态 - tag.classList.toggle('selected'); - }); - }); -} - -/** - * 加载配置 - */ -async function loadConfiguration() { - try { - const data = await window.apiClient.get('/config'); - - // 基础配置 - const apiKeyEl = document.getElementById('apiKey'); - const hostEl = document.getElementById('host'); - const portEl = document.getElementById('port'); - const modelProviderEl = document.getElementById('modelProvider'); - const systemPromptEl = document.getElementById('systemPrompt'); - - if (apiKeyEl) apiKeyEl.value = data.REQUIRED_API_KEY || ''; - if (hostEl) hostEl.value = data.HOST || '127.0.0.1'; - if (portEl) portEl.value = data.SERVER_PORT || 3000; - - if (modelProviderEl) { - // 处理多选 MODEL_PROVIDER - const providers = Array.isArray(data.DEFAULT_MODEL_PROVIDERS) - ? data.DEFAULT_MODEL_PROVIDERS - : (typeof data.MODEL_PROVIDER === 'string' ? data.MODEL_PROVIDER.split(',') : []); - - const tags = modelProviderEl.querySelectorAll('.provider-tag'); - tags.forEach(tag => { - const value = tag.getAttribute('data-value'); - if (providers.includes(value)) { - tag.classList.add('selected'); - } else { - tag.classList.remove('selected'); - } - }); - - // 如果没有任何选中的,默认选中第一个(保持兼容性) - const anySelected = Array.from(tags).some(tag => tag.classList.contains('selected')); - if (!anySelected && tags.length > 0) { - tags[0].classList.add('selected'); - } - } - - if (systemPromptEl) systemPromptEl.value = data.systemPrompt || ''; - - // 高级配置参数 - const systemPromptFilePathEl = document.getElementById('systemPromptFilePath'); - const systemPromptModeEl = document.getElementById('systemPromptMode'); - const promptLogBaseNameEl = document.getElementById('promptLogBaseName'); - const promptLogModeEl = document.getElementById('promptLogMode'); - const requestMaxRetriesEl = document.getElementById('requestMaxRetries'); - const requestBaseDelayEl = document.getElementById('requestBaseDelay'); - const cronNearMinutesEl = document.getElementById('cronNearMinutes'); - const cronRefreshTokenEl = document.getElementById('cronRefreshToken'); - const loginExpiryEl = document.getElementById('loginExpiry'); - const providerPoolsFilePathEl = document.getElementById('providerPoolsFilePath'); - - const maxErrorCountEl = document.getElementById('maxErrorCount'); - const warmupTargetEl = document.getElementById('warmupTarget'); - const refreshConcurrencyPerProviderEl = document.getElementById('refreshConcurrencyPerProvider'); - const providerFallbackChainEl = document.getElementById('providerFallbackChain'); - const modelFallbackMappingEl = document.getElementById('modelFallbackMapping'); - - if (systemPromptFilePathEl) systemPromptFilePathEl.value = data.SYSTEM_PROMPT_FILE_PATH || 'configs/input_system_prompt.txt'; - if (systemPromptModeEl) systemPromptModeEl.value = data.SYSTEM_PROMPT_MODE || 'append'; - if (promptLogBaseNameEl) promptLogBaseNameEl.value = data.PROMPT_LOG_BASE_NAME || 'prompt_log'; - if (promptLogModeEl) promptLogModeEl.value = data.PROMPT_LOG_MODE || 'none'; - if (requestMaxRetriesEl) requestMaxRetriesEl.value = data.REQUEST_MAX_RETRIES || 3; - if (requestBaseDelayEl) requestBaseDelayEl.value = data.REQUEST_BASE_DELAY || 1000; - - // 坏凭证切换最大重试次数 - const credentialSwitchMaxRetriesEl = document.getElementById('credentialSwitchMaxRetries'); - if (credentialSwitchMaxRetriesEl) credentialSwitchMaxRetriesEl.value = data.CREDENTIAL_SWITCH_MAX_RETRIES || 5; - - if (cronNearMinutesEl) cronNearMinutesEl.value = data.CRON_NEAR_MINUTES || 1; - if (cronRefreshTokenEl) cronRefreshTokenEl.checked = data.CRON_REFRESH_TOKEN || false; - if (loginExpiryEl) loginExpiryEl.value = data.LOGIN_EXPIRY || 3600; - if (providerPoolsFilePathEl) providerPoolsFilePathEl.value = data.PROVIDER_POOLS_FILE_PATH; - if (maxErrorCountEl) maxErrorCountEl.value = data.MAX_ERROR_COUNT || 10; - if (warmupTargetEl) warmupTargetEl.value = data.WARMUP_TARGET || 0; - if (refreshConcurrencyPerProviderEl) refreshConcurrencyPerProviderEl.value = data.REFRESH_CONCURRENCY_PER_PROVIDER || 1; - - // 加载 Fallback 链配置 - if (providerFallbackChainEl) { - if (data.providerFallbackChain && typeof data.providerFallbackChain === 'object') { - providerFallbackChainEl.value = JSON.stringify(data.providerFallbackChain, null, 2); - } else { - providerFallbackChainEl.value = ''; - } - } - - // 加载 Model Fallback 映射配置 - if (modelFallbackMappingEl) { - if (data.modelFallbackMapping && typeof data.modelFallbackMapping === 'object') { - modelFallbackMappingEl.value = JSON.stringify(data.modelFallbackMapping, null, 2); - } else { - modelFallbackMappingEl.value = ''; - } - } - - // 加载代理配置 - const proxyUrlEl = document.getElementById('proxyUrl'); - if (proxyUrlEl) proxyUrlEl.value = data.PROXY_URL || ''; - - // 加载启用代理的提供商 (标签按钮) - const proxyProvidersEl = document.getElementById('proxyProviders'); - if (proxyProvidersEl) { - const enabledProviders = data.PROXY_ENABLED_PROVIDERS || []; - const proxyTags = proxyProvidersEl.querySelectorAll('.provider-tag'); - - proxyTags.forEach(tag => { - const value = tag.getAttribute('data-value'); - if (enabledProviders.includes(value)) { - tag.classList.add('selected'); - } else { - tag.classList.remove('selected'); - } - }); - } - - // 加载日志配置 - const logEnabledEl = document.getElementById('logEnabled'); - const logOutputModeEl = document.getElementById('logOutputMode'); - const logLevelEl = document.getElementById('logLevel'); - const logDirEl = document.getElementById('logDir'); - const logIncludeRequestIdEl = document.getElementById('logIncludeRequestId'); - const logIncludeTimestampEl = document.getElementById('logIncludeTimestamp'); - const logMaxFileSizeEl = document.getElementById('logMaxFileSize'); - const logMaxFilesEl = document.getElementById('logMaxFiles'); - - if (logEnabledEl) logEnabledEl.checked = data.LOG_ENABLED !== false; - if (logOutputModeEl) logOutputModeEl.value = data.LOG_OUTPUT_MODE || 'all'; - if (logLevelEl) logLevelEl.value = data.LOG_LEVEL || 'info'; - if (logDirEl) logDirEl.value = data.LOG_DIR || 'logs'; - if (logIncludeRequestIdEl) logIncludeRequestIdEl.checked = data.LOG_INCLUDE_REQUEST_ID !== false; - if (logIncludeTimestampEl) logIncludeTimestampEl.checked = data.LOG_INCLUDE_TIMESTAMP !== false; - if (logMaxFileSizeEl) logMaxFileSizeEl.value = data.LOG_MAX_FILE_SIZE || 10485760; - if (logMaxFilesEl) logMaxFilesEl.value = data.LOG_MAX_FILES || 10; - - // TLS Sidecar 配置 - const tlsSidecarEnabledEl = document.getElementById('tlsSidecarEnabled'); - const tlsSidecarPortEl = document.getElementById('tlsSidecarPort'); - const tlsSidecarProxyUrlEl = document.getElementById('tlsSidecarProxyUrl'); - const tlsSidecarProvidersEl = document.getElementById('tlsSidecarProviders'); - - if (tlsSidecarEnabledEl) tlsSidecarEnabledEl.checked = data.TLS_SIDECAR_ENABLED || false; - if (tlsSidecarPortEl) tlsSidecarPortEl.value = data.TLS_SIDECAR_PORT || 9090; - if (tlsSidecarProxyUrlEl) tlsSidecarProxyUrlEl.value = data.TLS_SIDECAR_PROXY_URL || ''; - - if (tlsSidecarProvidersEl) { - const enabledProviders = data.TLS_SIDECAR_ENABLED_PROVIDERS || []; - const tags = tlsSidecarProvidersEl.querySelectorAll('.provider-tag'); - tags.forEach(tag => { - const value = tag.getAttribute('data-value'); - if (enabledProviders.includes(value)) { - tag.classList.add('selected'); - } else { - tag.classList.remove('selected'); - } - }); - } - - } catch (error) { - console.error('Failed to load configuration:', error); - } -} - -/** - * 保存配置 - */ -async function saveConfiguration() { - const modelProviderEl = document.getElementById('modelProvider'); - let selectedProviders = []; - if (modelProviderEl) { - // 从标签按钮中获取选中的提供商 - selectedProviders = Array.from(modelProviderEl.querySelectorAll('.provider-tag.selected')) - .map(tag => tag.getAttribute('data-value')); - } - - // 校验:必须至少勾选一个 - if (selectedProviders.length === 0) { - showToast(t('common.error'), t('config.modelProviderRequired'), 'error'); - return; - } - - const config = { - REQUIRED_API_KEY: document.getElementById('apiKey')?.value || '', - HOST: document.getElementById('host')?.value || '127.0.0.1', - SERVER_PORT: parseInt(document.getElementById('port')?.value || 3000), - MODEL_PROVIDER: selectedProviders.length > 0 ? selectedProviders.join(',') : 'gemini-cli-oauth', - systemPrompt: document.getElementById('systemPrompt')?.value || '', - }; - - // 获取后台登录密码(如果有输入) - const adminPassword = document.getElementById('adminPassword')?.value || ''; - - // 保存高级配置参数 - config.SYSTEM_PROMPT_FILE_PATH = document.getElementById('systemPromptFilePath')?.value || 'configs/input_system_prompt.txt'; - config.SYSTEM_PROMPT_MODE = document.getElementById('systemPromptMode')?.value || 'append'; - config.PROMPT_LOG_BASE_NAME = document.getElementById('promptLogBaseName')?.value || ''; - config.PROMPT_LOG_MODE = document.getElementById('promptLogMode')?.value || ''; - config.REQUEST_MAX_RETRIES = parseInt(document.getElementById('requestMaxRetries')?.value || 3); - config.REQUEST_BASE_DELAY = parseInt(document.getElementById('requestBaseDelay')?.value || 1000); - config.CREDENTIAL_SWITCH_MAX_RETRIES = parseInt(document.getElementById('credentialSwitchMaxRetries')?.value || 5); - config.CRON_NEAR_MINUTES = parseInt(document.getElementById('cronNearMinutes')?.value || 1); - config.CRON_REFRESH_TOKEN = document.getElementById('cronRefreshToken')?.checked || false; - config.LOGIN_EXPIRY = parseInt(document.getElementById('loginExpiry')?.value || 3600); - config.PROVIDER_POOLS_FILE_PATH = document.getElementById('providerPoolsFilePath')?.value || ''; - config.MAX_ERROR_COUNT = parseInt(document.getElementById('maxErrorCount')?.value || 10); - config.WARMUP_TARGET = parseInt(document.getElementById('warmupTarget')?.value || 0); - config.REFRESH_CONCURRENCY_PER_PROVIDER = parseInt(document.getElementById('refreshConcurrencyPerProvider')?.value || 1); - - // 保存 Fallback 链配置 - const fallbackChainValue = document.getElementById('providerFallbackChain')?.value?.trim() || ''; - if (fallbackChainValue) { - try { - config.providerFallbackChain = JSON.parse(fallbackChainValue); - } catch (e) { - showToast(t('common.error'), t('config.advanced.fallbackChainInvalid') || 'Fallback 链配置格式无效,请输入有效的 JSON', 'error'); - return; - } - } else { - config.providerFallbackChain = {}; - } - - // 保存 Model Fallback 映射配置 - const modelFallbackMappingValue = document.getElementById('modelFallbackMapping')?.value?.trim() || ''; - if (modelFallbackMappingValue) { - try { - config.modelFallbackMapping = JSON.parse(modelFallbackMappingValue); - } catch (e) { - showToast(t('common.error'), t('config.advanced.modelFallbackMappingInvalid') || 'Model Fallback 映射配置格式无效,请输入有效的 JSON', 'error'); - return; - } - } else { - config.modelFallbackMapping = {}; - } - - // 保存代理配置 - config.PROXY_URL = document.getElementById('proxyUrl')?.value?.trim() || null; - - // 获取启用代理的提供商列表 (从标签按钮) - const proxyProvidersEl = document.getElementById('proxyProviders'); - if (proxyProvidersEl) { - config.PROXY_ENABLED_PROVIDERS = Array.from(proxyProvidersEl.querySelectorAll('.provider-tag.selected')) - .map(tag => tag.getAttribute('data-value')); - } else { - config.PROXY_ENABLED_PROVIDERS = []; - } - - // 保存日志配置 - config.LOG_ENABLED = document.getElementById('logEnabled')?.checked !== false; - config.LOG_OUTPUT_MODE = document.getElementById('logOutputMode')?.value || 'all'; - config.LOG_LEVEL = document.getElementById('logLevel')?.value || 'info'; - config.LOG_DIR = document.getElementById('logDir')?.value || 'logs'; - config.LOG_INCLUDE_REQUEST_ID = document.getElementById('logIncludeRequestId')?.checked !== false; - config.LOG_INCLUDE_TIMESTAMP = document.getElementById('logIncludeTimestamp')?.checked !== false; - config.LOG_MAX_FILE_SIZE = parseInt(document.getElementById('logMaxFileSize')?.value || 10485760); - config.LOG_MAX_FILES = parseInt(document.getElementById('logMaxFiles')?.value || 10); - - // TLS Sidecar 配置 - config.TLS_SIDECAR_ENABLED = document.getElementById('tlsSidecarEnabled')?.checked || false; - config.TLS_SIDECAR_PORT = parseInt(document.getElementById('tlsSidecarPort')?.value || 9090); - config.TLS_SIDECAR_PROXY_URL = document.getElementById('tlsSidecarProxyUrl')?.value?.trim() || null; - - const tlsSidecarProvidersEl = document.getElementById('tlsSidecarProviders'); - if (tlsSidecarProvidersEl) { - config.TLS_SIDECAR_ENABLED_PROVIDERS = Array.from(tlsSidecarProvidersEl.querySelectorAll('.provider-tag.selected')) - .map(tag => tag.getAttribute('data-value')); - } else { - config.TLS_SIDECAR_ENABLED_PROVIDERS = []; - } - - try { - await window.apiClient.post('/config', config); - - // 如果输入了新密码,单独保存密码 - if (adminPassword) { - try { - await window.apiClient.post('/admin-password', { password: adminPassword }); - // 清空密码输入框 - const adminPasswordEl = document.getElementById('adminPassword'); - if (adminPasswordEl) adminPasswordEl.value = ''; - showToast(t('common.success'), t('common.passwordUpdated'), 'success'); - } catch (pwdError) { - console.error('Failed to save admin password:', pwdError); - showToast(t('common.error'), t('common.error') + ': ' + pwdError.message, 'error'); - } - } - - await window.apiClient.post('/reload-config'); - showToast(t('common.success'), t('common.configSaved'), 'success'); - - // 检查当前是否在提供商池管理页面,如果是则刷新数据 - const providersSection = document.getElementById('providers'); - if (providersSection && providersSection.classList.contains('active')) { - // 当前在提供商池页面,刷新数据 - await loadProviders(); - showToast(t('common.success'), t('common.providerPoolRefreshed'), 'success'); - } - } catch (error) { - console.error('Failed to save configuration:', error); - showToast(t('common.error'), t('common.error') + ': ' + error.message, 'error'); - } -} - -/** - * 自动生成 API 密钥 - */ -function generateApiKey() { - const apiKeyEl = document.getElementById('apiKey'); - if (!apiKeyEl) return; - - // 生成 32 位 16 进制随机字符串 - const array = new Uint8Array(16); - window.crypto.getRandomValues(array); - const randomKey = 'sk-' + Array.from(array, byte => byte.toString(16).padStart(2, '0')).join(''); - - apiKeyEl.value = randomKey; - - showToast(t('common.success'), t('config.apiKey.generated') || '已生成新的 API 密钥', 'success'); - - // 触发输入框的 change 事件 - apiKeyEl.dispatchEvent(new Event('input', { bubbles: true })); - apiKeyEl.dispatchEvent(new Event('change', { bubbles: true })); -} - -export { - loadConfiguration, - saveConfiguration, - updateConfigProviderConfigs, - generateApiKey -}; diff --git a/static/app/constants.js b/static/app/constants.js deleted file mode 100644 index 3f08aa989b51e445b2839c89a9d5be2c0a32a6df..0000000000000000000000000000000000000000 --- a/static/app/constants.js +++ /dev/null @@ -1,67 +0,0 @@ -// 全局变量 -let eventSource = null; -let autoScroll = true; -let logs = []; - -// 提供商统计全局变量 -let providerStats = { - totalRequests: 0, - totalErrors: 0, - activeProviders: 0, - healthyProviders: 0, - totalAccounts: 0, - lastUpdateTime: null, - providerTypeStats: {} // 详细按类型统计 -}; - -// DOM元素 - 使用 getter 延迟获取,以支持动态加载的组件 -const elements = { - get serverStatus() { return document.getElementById('serverStatus'); }, - get restartBtn() { return document.getElementById('restartBtn'); }, - get sections() { return document.querySelectorAll('.section'); }, - get navItems() { return document.querySelectorAll('.nav-item'); }, - get logsContainer() { return document.getElementById('logsContainer'); }, - get clearLogsBtn() { return document.getElementById('clearLogs'); }, - get downloadLogsBtn() { return document.getElementById('downloadLogs'); }, - get toggleAutoScrollBtn() { return document.getElementById('toggleAutoScroll'); }, - get saveConfigBtn() { return document.getElementById('saveConfig'); }, - get resetConfigBtn() { return document.getElementById('resetConfig'); }, - get toastContainer() { return document.getElementById('toastContainer'); }, - get modelProvider() { return document.getElementById('modelProvider'); }, -}; - -// 定期刷新间隔 -const REFRESH_INTERVALS = { - SYSTEM_INFO: 10000 -}; - -// 导出所有常量 -export { - eventSource, - autoScroll, - logs, - providerStats, - elements, - REFRESH_INTERVALS -}; - -// 更新函数 -export function setEventSource(source) { - eventSource = source; -} - -export function setAutoScroll(value) { - autoScroll = value; -} - -export function addLog(log) { - logs.push(log); -} - -export function clearLogs() { - logs = []; -} - -export function updateProviderStats(newStats) { - providerStats = { ...providerStats, ...newStats }; -} \ No newline at end of file diff --git a/static/app/event-handlers.js b/static/app/event-handlers.js deleted file mode 100644 index 283bdcffc7167410890a98cc22043ab1b3bcd5f2..0000000000000000000000000000000000000000 --- a/static/app/event-handlers.js +++ /dev/null @@ -1,582 +0,0 @@ -// 事件监听器模块 - -import { elements, autoScroll, setAutoScroll, clearLogs } from './constants.js'; -import { showToast } from './utils.js'; -import { t } from './i18n.js'; -import { checkUpdate, performUpdate } from './provider-manager.js'; - -/** - * 初始化所有事件监听器 - */ -function initEventListeners() { - // 重启按钮 - if (elements.restartBtn) { - elements.restartBtn.addEventListener('click', handleRestart); - } - - // 清空日志 - if (elements.clearLogsBtn) { - elements.clearLogsBtn.addEventListener('click', async () => { - // 显示确认对话框,明确提示会清空本地日志文件 - const confirmed = confirm(t('logs.clear.confirm.msg')); - - if (!confirmed) { - return; - } - - try { - const token = window.authManager.getToken(); - if (!token) { - showToast(t('common.error'), '请先登录', 'error'); - return; - } - - // 调用后端 API 清空日志文件 - const response = await fetch(`${window.location.origin}/api/system/clear-log`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json' - } - }); - - if (response.status === 401) { - showToast(t('common.error'), '认证失败,请重新登录', 'error'); - window.authManager.clearToken(); - window.location.href = '/login.html'; - return; - } - - const result = await response.json(); - - if (result.success) { - // 清空前端日志显示 - clearLogs(); - if (elements.logsContainer) { - elements.logsContainer.innerHTML = ''; - } - - // 显示成功提示,明确说明已清空本地日志文件 - showToast( - t('logs.clear.success.title'), - t('logs.clear.success.msg'), - 'success', - 5000 // 显示 5 秒 - ); - } else { - showToast(t('common.error'), t('logs.clear.failed'), 'error'); - } - } catch (error) { - console.error('清空日志失败:', error); - showToast(t('common.error'), t('logs.clear.failed') + ': ' + error.message, 'error'); - } - }); - } - - // 下载日志 - if (elements.downloadLogsBtn) { - elements.downloadLogsBtn.addEventListener('click', async () => { - try { - const token = window.authManager.getToken(); - if (!token) { - showToast(t('common.error'), '请先登录', 'error'); - return; - } - - // 使用带认证的方式下载文件 - const url = `${window.location.origin}/api/system/download-log`; - const response = await fetch(url, { - method: 'GET', - headers: { - 'Authorization': `Bearer ${token}` - } - }); - - if (response.status === 401) { - showToast(t('common.error'), '认证失败,请重新登录', 'error'); - window.authManager.clearToken(); - window.location.href = '/login.html'; - return; - } - - if (!response.ok) { - const errorData = await response.json(); - showToast(t('common.error'), errorData.error?.message || '下载失败', 'error'); - return; - } - - // 获取文件名 - const contentDisposition = response.headers.get('Content-Disposition'); - let filename = 'app.log'; - if (contentDisposition) { - const matches = /filename="?([^"]+)"?/.exec(contentDisposition); - if (matches && matches[1]) { - filename = matches[1]; - } - } - - // 下载文件 - const blob = await response.blob(); - const downloadUrl = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = downloadUrl; - a.download = filename; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - window.URL.revokeObjectURL(downloadUrl); - - showToast(t('common.success'), '日志下载成功', 'success'); - } catch (error) { - console.error('下载日志失败:', error); - showToast(t('common.error'), '下载失败: ' + error.message, 'error'); - } - }); - } - - // 自动滚动切换 - if (elements.toggleAutoScrollBtn) { - elements.toggleAutoScrollBtn.addEventListener('click', () => { - const newAutoScroll = !autoScroll; - setAutoScroll(newAutoScroll); - elements.toggleAutoScrollBtn.dataset.enabled = newAutoScroll; - const statusText = newAutoScroll ? t('logs.autoScroll.on') : t('logs.autoScroll.off'); - elements.toggleAutoScrollBtn.innerHTML = ` - - ${statusText} - `; - }); - } - - // 保存配置 - if (elements.saveConfigBtn) { - elements.saveConfigBtn.addEventListener('click', () => { - if (window.saveConfiguration) { - window.saveConfiguration(); - } else if (saveConfiguration) { - saveConfiguration(); - } - }); - } - - // 重置配置 - if (elements.resetConfigBtn) { - elements.resetConfigBtn.addEventListener('click', loadInitialData); - } - - // 模型提供商切换 - if (elements.modelProvider) { - elements.modelProvider.addEventListener('change', handleProviderChange); - } - - // Gemini凭据类型切换 - document.querySelectorAll('input[name="geminiCredsType"]').forEach(radio => { - radio.addEventListener('change', handleGeminiCredsTypeChange); - }); - - // Kiro凭据类型切换 - document.querySelectorAll('input[name="kiroCredsType"]').forEach(radio => { - radio.addEventListener('change', handleKiroCredsTypeChange); - }); - - // 密码显示/隐藏切换 - document.querySelectorAll('.password-toggle').forEach(button => { - button.addEventListener('click', handlePasswordToggle); - }); - - // 生成 API 密钥按钮监听 - const generateApiKeyBtn = document.getElementById('generateApiKey'); - if (generateApiKeyBtn) { - generateApiKeyBtn.addEventListener('click', () => { - if (window.generateApiKey) { - window.generateApiKey(); - } else { - console.error('generateApiKey function not found'); - } - }); - } - - // 生成凭据按钮监听 - document.querySelectorAll('.generate-creds-btn').forEach(button => { - button.addEventListener('click', handleGenerateCreds); - }); - - // 提供商池配置监听 - // const providerPoolsInput = document.getElementById('providerPoolsFilePath'); - // if (providerPoolsInput) { - // providerPoolsInput.addEventListener('input', handleProviderPoolsConfigChange); - // } - - // 检查更新按钮 - const checkUpdateBtn = document.getElementById('checkUpdateBtn'); - if (checkUpdateBtn) { - checkUpdateBtn.addEventListener('click', () => checkUpdate(false)); - } - - // 执行更新按钮 - const performUpdateBtn = document.getElementById('performUpdateBtn'); - if (performUpdateBtn) { - performUpdateBtn.addEventListener('click', performUpdate); - } - - // 日志容器滚动 - if (elements.logsContainer) { - elements.logsContainer.addEventListener('scroll', () => { - if (autoScroll) { - const isAtBottom = elements.logsContainer.scrollTop + elements.logsContainer.clientHeight - >= elements.logsContainer.scrollHeight - 5; - if (!isAtBottom) { - setAutoScroll(false); - elements.toggleAutoScrollBtn.dataset.enabled = false; - elements.toggleAutoScrollBtn.innerHTML = ` - - ${t('logs.autoScroll.off')} - `; - } - } - }); - } -} - -/** - * 提供商配置切换处理 - */ -function handleProviderChange() { - const selectedProvider = elements.modelProvider?.value; - if (!selectedProvider) return; - - const allProviderConfigs = document.querySelectorAll('.provider-config'); - - // 隐藏所有提供商配置 - allProviderConfigs.forEach(config => { - config.style.display = 'none'; - }); - - // 显示当前选中的提供商配置 - const targetConfig = document.querySelector(`[data-provider="${selectedProvider}"]`); - if (targetConfig) { - targetConfig.style.display = 'block'; - } -} - -/** - * Gemini凭据类型切换 - * @param {Event} event - 事件对象 - */ -function handleGeminiCredsTypeChange(event) { - const selectedType = event.target.value; - const base64Group = document.getElementById('geminiCredsBase64Group'); - const fileGroup = document.getElementById('geminiCredsFileGroup'); - - if (selectedType === 'base64') { - if (base64Group) base64Group.style.display = 'block'; - if (fileGroup) fileGroup.style.display = 'none'; - } else { - if (base64Group) base64Group.style.display = 'none'; - if (fileGroup) fileGroup.style.display = 'block'; - } -} - -/** - * Kiro凭据类型切换 - * @param {Event} event - 事件对象 - */ -function handleKiroCredsTypeChange(event) { - const selectedType = event.target.value; - const base64Group = document.getElementById('kiroCredsBase64Group'); - const fileGroup = document.getElementById('kiroCredsFileGroup'); - - if (selectedType === 'base64') { - if (base64Group) base64Group.style.display = 'block'; - if (fileGroup) fileGroup.style.display = 'none'; - } else { - if (base64Group) base64Group.style.display = 'none'; - if (fileGroup) fileGroup.style.display = 'block'; - } -} - -/** - * 密码显示/隐藏切换处理 - * @param {Event} event - 事件对象 - */ -function handlePasswordToggle(event) { - const button = event.target.closest('.password-toggle'); - if (!button) return; - - const targetId = button.getAttribute('data-target'); - const input = document.getElementById(targetId); - const icon = button.querySelector('i'); - - if (!input || !icon) return; - - if (input.type === 'password') { - input.type = 'text'; - icon.className = 'fas fa-eye-slash'; - } else { - input.type = 'password'; - icon.className = 'fas fa-eye'; - } -} - -/** - * 处理生成凭据逻辑 - * @param {Event} event - 事件对象 - */ -async function handleGenerateCreds(event) { - const button = event.target.closest('.generate-creds-btn'); - if (!button) return; - - const providerType = button.getAttribute('data-provider'); - const targetInputId = button.getAttribute('data-target'); - - try { - // 如果是 Kiro OAuth,先显示认证方式选择对话框 - if (providerType === 'claude-kiro-oauth') { - const modal = document.createElement('div'); - modal.className = 'modal-overlay'; - modal.style.display = 'flex'; - - modal.innerHTML = ` - - `; - - document.body.appendChild(modal); - - const closeModal = () => modal.remove(); - modal.querySelector('.modal-close').onclick = closeModal; - modal.querySelector('.modal-cancel').onclick = closeModal; - - modal.querySelectorAll('.auth-method-btn').forEach(btn => { - btn.onclick = async () => { - const method = btn.dataset.method; - closeModal(); - await proceedWithAuth(providerType, targetInputId, { method }); - }; - }); - return; - } - - await proceedWithAuth(providerType, targetInputId, {}); - } catch (error) { - console.error('生成凭据失败:', error); - showToast(t('common.error'), t('modal.provider.auth.failed') + `: ${error.message}`, 'error'); - } -} - -/** - * 实际执行授权逻辑 - */ -async function proceedWithAuth(providerType, targetInputId, extraOptions = {}) { - if (window.executeGenerateAuthUrl) { - await window.executeGenerateAuthUrl(providerType, { - targetInputId, - ...extraOptions - }); - } else { - console.error('executeGenerateAuthUrl not found'); - } -} - -/** - * 提供商池配置变化处理 - * @param {Event} event - 事件对象 - */ -function handleProviderPoolsConfigChange(event) { - const filePath = event.target.value.trim(); - const providersMenuItem = document.querySelector('.nav-item[data-section="providers"]'); - - if (filePath) { - // 显示提供商池菜单 - if (providersMenuItem) providersMenuItem.style.display = 'flex'; - } else { - // 隐藏提供商池菜单 - if (providersMenuItem) providersMenuItem.style.display = 'none'; - - // 如果当前在提供商池页面,切换到仪表盘 - if (providersMenuItem && providersMenuItem.classList.contains('active')) { - const dashboardItem = document.querySelector('.nav-item[data-section="dashboard"]'); - const dashboardSection = document.getElementById('dashboard'); - - // 更新导航状态 - document.querySelectorAll('.nav-item').forEach(nav => nav.classList.remove('active')); - document.querySelectorAll('.section').forEach(section => section.classList.remove('active')); - - if (dashboardItem) dashboardItem.classList.add('active'); - if (dashboardSection) dashboardSection.classList.add('active'); - } - } -} - -/** - * 密码显示/隐藏切换处理(用于模态框中的密码输入框) - * @param {HTMLElement} button - 按钮元素 - */ -function handleProviderPasswordToggle(button) { - const targetKey = button.getAttribute('data-target'); - const input = button.parentNode.querySelector(`input[data-config-key="${targetKey}"]`); - const icon = button.querySelector('i'); - - if (!input || !icon) return; - - if (input.type === 'password') { - input.type = 'text'; - icon.className = 'fas fa-eye-slash'; - } else { - input.type = 'password'; - icon.className = 'fas fa-eye'; - } -} - -// 数据加载函数(需要从主模块导入) -let loadInitialData; -let saveConfiguration; -let reloadConfig; - -// 当前服务模式(由 provider-manager.js 设置) -let currentServiceMode = 'worker'; - -/** - * 设置当前服务模式 - * @param {string} mode - 服务模式 ('worker' 或 'standalone') - */ -export function setServiceMode(mode) { - currentServiceMode = mode; -} - -/** - * 获取当前服务模式 - * @returns {string} 当前服务模式 - */ -export function getServiceMode() { - return currentServiceMode; -} - -// 重启/重载服务处理函数 -async function handleRestart() { - try { - // 根据服务模式执行不同操作 - if (currentServiceMode === 'standalone') { - // 独立模式:执行重载配置 - await handleReloadConfig(); - } else { - // 子进程模式:执行重启服务 - await handleRestartService(); - } - } catch (error) { - console.error('Operation failed:', error); - const errorKey = currentServiceMode === 'standalone' ? 'header.reload.failed' : 'header.restart.failed'; - showToast(t('common.error'), t(errorKey) + ': ' + error.message, 'error'); - } -} - -/** - * 重载配置(独立模式) - */ -async function handleReloadConfig() { - // 确认重载操作 - if (!confirm(t('header.reload.confirm'))) { - return; - } - - showToast(t('common.info'), t('header.reload.requesting'), 'info'); - - // 先刷新基础数据 - if (loadInitialData) { - loadInitialData(); - } - - // 如果reloadConfig函数可用,则也刷新配置 - if (reloadConfig) { - await reloadConfig(); - } -} - -/** - * 重启服务(子进程模式) - */ -async function handleRestartService() { - // 确认重启操作 - if (!confirm(t('header.restart.confirm'))) { - return; - } - - showToast(t('common.info'), t('header.restart.requesting'), 'info'); - - const result = await window.apiClient.post('/restart-service'); - - if (result.success) { - showToast(t('common.success'), result.message || t('header.restart.success'), 'success'); - - // 如果是 worker 模式,服务会自动重启,等待几秒后刷新页面 - if (result.mode === 'worker') { - setTimeout(() => { - showToast(t('common.info'), t('header.restart.reconnecting'), 'info'); - // 等待服务重启后刷新页面 - setTimeout(() => { - window.location.reload(); - }, 3000); - }, 2000); - } - } else { - // 显示错误信息 - const errorMsg = result.message || result.error?.message || t('header.restart.failed'); - showToast(t('common.error'), errorMsg, 'error'); - - // 如果是独立模式,显示提示 - if (result.mode === 'standalone') { - showToast(t('common.info'), result.hint, 'warning'); - } - } -} - -export function setDataLoaders(dataLoader, configSaver) { - loadInitialData = dataLoader; - saveConfiguration = configSaver; -} - -export function setReloadConfig(configReloader) { - reloadConfig = configReloader; -} - -export { - initEventListeners, - handleProviderChange, - handleGeminiCredsTypeChange, - handleKiroCredsTypeChange, - handlePasswordToggle, - handleProviderPoolsConfigChange, - handleProviderPasswordToggle -}; \ No newline at end of file diff --git a/static/app/event-stream.js b/static/app/event-stream.js deleted file mode 100644 index cbd969c7648f681ef5650b27b1ecd101f5115b07..0000000000000000000000000000000000000000 --- a/static/app/event-stream.js +++ /dev/null @@ -1,194 +0,0 @@ -// Server-Sent Events处理模块 - -import { eventSource, setEventSource, elements, addLog, autoScroll } from './constants.js'; -import { t } from './i18n.js'; - -/** - * Server-Sent Events初始化 - */ -function initEventStream() { - if (eventSource) { - eventSource.close(); - } - - const newEventSource = new EventSource('/api/events'); - setEventSource(newEventSource); - - newEventSource.onopen = () => { - updateServerStatus(true); - console.log('EventStream connected'); - }; - - newEventSource.onerror = () => { - updateServerStatus(false); - console.log('EventStream disconnected'); - }; - - newEventSource.addEventListener('log', (event) => { - const data = JSON.parse(event.data); - addLogEntry(data); - }); - - newEventSource.addEventListener('provider', (event) => { - const data = JSON.parse(event.data); - updateProviderStatus(data); - }); - - newEventSource.addEventListener('oauth_success', (event) => { - const data = JSON.parse(event.data); - showToast(t('common.success'), `${t('common.success')} (${data.provider})`, 'success'); - // 发送自定义事件,以便其他模块(如生成凭据逻辑)可以接收到详细信息 - window.dispatchEvent(new CustomEvent('oauth_success_event', { detail: data })); - }); - - newEventSource.addEventListener('provider_update', (event) => { - const data = JSON.parse(event.data); - handleProviderUpdate(data); - }); - - newEventSource.addEventListener('config_update', (event) => { - const data = JSON.parse(event.data); - handleConfigUpdate(data); - }); -} - -/** - * 添加日志条目 - * @param {Object} logData - 日志数据 - */ -function addLogEntry(logData) { - addLog(logData); - - if (!elements.logsContainer) return; - - const logEntry = document.createElement('div'); - logEntry.className = 'log-entry'; - - const time = new Date(logData.timestamp).toLocaleTimeString(); - const levelClass = `log-level-${logData.level}`; - - logEntry.innerHTML = ` - ${escapeHtml(logData.message)} - `; - - elements.logsContainer.appendChild(logEntry); - - if (autoScroll) { - elements.logsContainer.scrollTop = elements.logsContainer.scrollHeight; - } -} - -/** - * 更新服务器状态 - * @param {boolean} connected - 连接状态 - */ -function updateServerStatus(connected) { - if (!elements.serverStatus) return; - - const statusBadge = elements.serverStatus; - const icon = statusBadge.querySelector('i'); - const text = statusBadge.querySelector('span') || statusBadge.childNodes[1]; - - if (connected) { - statusBadge.classList.remove('error'); - icon.style.color = 'var(--success-color)'; - statusBadge.innerHTML = ` ${t('header.status.connected')}`; - } else { - statusBadge.classList.add('error'); - icon.style.color = 'var(--danger-color)'; - statusBadge.innerHTML = ` ${t('header.status.disconnected')}`; - } -} - -/** - * 更新提供商状态 - * @param {Object} data - 提供商数据 - */ -function updateProviderStatus(data) { - // 触发重新加载提供商列表 - if (typeof loadProviders === 'function') { - loadProviders(); - } -} - -/** - * 处理提供商更新事件 - * @param {Object} data - 更新数据 - */ -function handleProviderUpdate(data) { - if (data.action && data.providerType) { - // 如果当前打开的模态框是更新事件的提供商类型,则刷新该模态框 - const modal = document.querySelector('.provider-modal'); - if (modal && modal.getAttribute('data-provider-type') === data.providerType) { - if (typeof refreshProviderConfig === 'function') { - refreshProviderConfig(data.providerType); - } - } else { - // 否则更新主界面的提供商列表 - if (typeof loadProviders === 'function') { - loadProviders(); - } - } - } -} - -// 导入工具函数 -import { escapeHtml, showToast } from './utils.js'; - -// 需要从其他模块导入的函数 -let loadProviders; -let refreshProviderConfig; -let loadConfigList; - -export function setProviderLoaders(providerLoader, providerRefresher) { - loadProviders = providerLoader; - refreshProviderConfig = providerRefresher; -} - -export function setConfigLoaders(configLoader) { - loadConfigList = configLoader; -} - -/** - * 处理配置更新事件 - * @param {Object} data - 更新数据 - */ -function handleConfigUpdate(data) { - console.log('[ConfigUpdate] 收到配置更新事件:', data); - - // 根据操作类型进行相应处理 - switch (data.action) { - case 'delete': - // 文件删除事件,直接刷新配置文件列表 - if (loadConfigList) { - loadConfigList(); - console.log('[ConfigUpdate] 配置文件列表已刷新(文件删除)'); - } - break; - - case 'add': - case 'update': - // 文件添加或更新事件,刷新配置文件列表 - if (loadConfigList) { - loadConfigList(); - console.log('[ConfigUpdate] 配置文件列表已刷新(文件更新)'); - } - break; - - default: - // 未知操作类型,也刷新列表以确保同步 - if (loadConfigList) { - loadConfigList(); - console.log('[ConfigUpdate] 配置文件列表已刷新(默认)'); - } - break; - } -} - -export { - initEventStream, - addLogEntry, - updateServerStatus, - updateProviderStatus, - handleProviderUpdate -}; diff --git a/static/app/file-upload.js b/static/app/file-upload.js deleted file mode 100644 index a6dd6100e686190da806929df2a92e627086f90f..0000000000000000000000000000000000000000 --- a/static/app/file-upload.js +++ /dev/null @@ -1,220 +0,0 @@ -// 文件上传功能模块 - -import { showToast } from './utils.js'; -import { t } from './i18n.js'; - -/** - * 文件上传处理器类 - */ -class FileUploadHandler { - constructor() { - this.currentProvider = 'gemini'; // 默认提供商 - this.initEventListeners(); - } - - /** - * 初始化事件监听器 - */ - initEventListeners() { - // 监听所有上传按钮的点击事件 - document.addEventListener('click', (event) => { - if (event.target.closest('.upload-btn')) { - const button = event.target.closest('.upload-btn'); - const targetInputId = button.getAttribute('data-target'); - if (targetInputId) { - // 尝试从模态框获取 providerType - const modal = button.closest('.provider-modal'); - const providerType = modal ? modal.getAttribute('data-provider-type') : null; - this.handleFileUpload(button, targetInputId, providerType); - } - } - }); - - // 监听提供商切换事件 - const modelProvider = document.getElementById('modelProvider'); - if (modelProvider) { - modelProvider.addEventListener('change', (event) => { - this.updateCurrentProvider(event.target.value); - }); - } - } - - /** - * 更新当前提供商 - * @param {string} provider - 选择的提供商 - */ - updateCurrentProvider(provider) { - this.currentProvider = this.getProviderKey(provider); - } - - /** - * 获取提供商对应的键名 - * @param {string} provider - 提供商名称 - * @returns {string} - 提供商标识 - */ - getProviderKey(provider) { - const providerMap = { - 'gemini-cli-oauth': 'gemini', - 'gemini-antigravity': 'antigravity', - 'claude-kiro-oauth': 'kiro', - 'openai-qwen-oauth': 'qwen', - 'openai-iflow': 'iflow' - }; - return providerMap[provider] || 'gemini'; - } - - /** - * 处理文件上传 - * @param {HTMLElement} button - 上传按钮元素 - * @param {string} targetInputId - 目标输入框ID - * @param {string} providerType - 提供商类型 - */ - async handleFileUpload(button, targetInputId, providerType) { - // 创建隐藏的文件输入元素 - const fileInput = this.createFileInput(); - - // 设置文件选择回调 - fileInput.onchange = async (event) => { - const file = event.target.files[0]; - - if (file) { - // 只有文件被实际选择后才显示加载状态并上传 - this.setButtonLoading(button, true); - await this.uploadFile(file, targetInputId, button, providerType); - } - - // 清理临时文件输入元素 - fileInput.remove(); - }; - - // 触发文件选择 - fileInput.click(); - } - - /** - * 创建文件输入元素 - * @returns {HTMLInputElement} - 文件输入元素 - */ - createFileInput() { - const fileInput = document.createElement('input'); - fileInput.type = 'file'; - fileInput.accept = '.json,.txt,.key,.pem,.p12,.pfx'; - fileInput.style.display = 'none'; - document.body.appendChild(fileInput); - return fileInput; - } - - /** - * 上传文件到服务器 - * @param {File} file - 要上传的文件 - * @param {string} targetInputId - 目标输入框ID - * @param {HTMLElement} button - 上传按钮 - * @param {string} providerType - 提供商类型 - */ - async uploadFile(file, targetInputId, button, providerType) { - try { - // 验证文件类型 - if (!this.validateFileType(file)) { - showToast(t('common.error'), t('common.fileType'), 'error'); - this.setButtonLoading(button, false); - return; - } - - // 验证文件大小 (5MB 限制) - if (file.size > 5 * 1024 * 1024) { - showToast(t('common.error'), t('common.fileSize'), 'error'); - this.setButtonLoading(button, false); - return; - } - - // 使用传入的 providerType 或回退到 currentProvider - const provider = providerType ? this.getProviderKey(providerType) : this.currentProvider; - - // 创建 FormData - const formData = new FormData(); - formData.append('file', file); - formData.append('provider', provider); - formData.append('targetInputId', targetInputId); - - // 使用封装接口发送上传请求 - const result = await window.apiClient.upload('/upload-oauth-credentials', formData); - - // 成功上传,设置文件路径到输入框 - this.setFilePathToInput(targetInputId, result.filePath); - showToast(t('common.success'), t('common.uploadSuccess'), 'success'); - - } catch (error) { - console.error('文件上传错误:', error); - showToast(t('common.error'), t('common.uploadFailed') + ': ' + error.message, 'error'); - } finally { - this.setButtonLoading(button, false); - } - } - - /** - * 验证文件类型 - * @param {File} file - 要验证的文件 - * @returns {boolean} - 是否为有效文件类型 - */ - validateFileType(file) { - const allowedExtensions = ['.json', '.txt', '.key', '.pem', '.p12', '.pfx']; - const fileName = file.name.toLowerCase(); - return allowedExtensions.some(ext => fileName.endsWith(ext)); - } - - /** - * 设置按钮加载状态 - * @param {HTMLElement} button - 按钮元素 - * @param {boolean} isLoading - 是否加载中 - */ - setButtonLoading(button, isLoading) { - const icon = button.querySelector('i'); - if (isLoading) { - button.disabled = true; - icon.className = 'fas fa-spinner fa-spin'; - } else { - button.disabled = false; - icon.className = 'fas fa-upload'; - } - } - - /** - * 设置文件路径到输入框 - * @param {string} inputId - 输入框ID - * @param {string} filePath - 文件路径 - */ - setFilePathToInput(inputId, filePath) { - // console.log('设置文件路径到输入框:', inputId, filePath); - let input = document.getElementById(inputId); - if (input) { - // console.log('输入框元素存在,设置文件路径:', filePath); - input.value = filePath; - // 同时更新data-config-value属性(用于编辑模式) - if (input.hasAttribute('data-config-value')) { - input.setAttribute('data-config-value', filePath); - console.log('更新data-config-value属性:', filePath); - } - // 触发输入事件,通知其他监听器 - input.dispatchEvent(new Event('input', { bubbles: true })); - } else { - console.error('无法找到输入框:', inputId); - } - } -} - -/** - * 初始化文件上传功能 - */ -function initFileUpload() { - // 文件上传功能是自初始化的单例 - console.log('文件上传功能已初始化'); -} - -// 导出单例实例 -const fileUploadHandler = new FileUploadHandler(); - -export { - fileUploadHandler, - FileUploadHandler, - initFileUpload -}; \ No newline at end of file diff --git a/static/app/i18n.js b/static/app/i18n.js deleted file mode 100644 index 2d1739b68f36cdea64e2ce6c79d186fd9d34cb70..0000000000000000000000000000000000000000 --- a/static/app/i18n.js +++ /dev/null @@ -1,1910 +0,0 @@ -// 多语言配置 -const translations = { - 'zh-CN': { - // Header - 'header.title': 'AIClient2API 管理控制台', - 'header.description': 'AIClient2API 管理控制台 - 统一管理 AI 服务提供商', - 'header.github': 'GitHub 仓库', - 'header.themeToggle': '切换主题', - 'header.status.connecting': '连接中...', - 'header.status.connected': '已连接', - 'header.status.disconnected': '连接断开', - 'header.logout': '登出', - 'header.reload': '重载', - 'header.reload.confirm': '确定要重载配置吗?这将重新加载所有配置文件。', - 'header.reload.requesting': '正在重载配置...', - 'header.reload.success': '配置重载成功', - 'header.reload.failed': '配置重载失败', - 'header.refresh': '重载', - 'header.restart': '重启', - 'header.restart.confirm': '确定要重启服务吗?服务将短暂中断。', - 'header.restart.requesting': '正在请求重启服务...', - 'header.restart.success': '重启请求已发送,服务即将重启', - 'header.restart.reconnecting': '正在重新连接...', - 'header.restart.failed': '重启服务失败', - - // Navigation - 'nav.main': '主导航', - 'nav.dashboard': '仪表盘', - 'nav.guide': '使用指南', - 'nav.tutorial': '配置教程', - 'nav.config': '配置管理', - 'nav.providers': '提供商池管理', - 'nav.upload': '凭据文件管理', - 'nav.usage': '用量查询', - 'nav.logs': '实时日志', - 'nav.plugins': '插件管理', - 'nav.models': '可用模型', - - // Dashboard - 'dashboard.title': '系统概览', - 'dashboard.uptime': '运行时间', - 'dashboard.systemInfo': '系统信息', - 'dashboard.version': '版本号', - 'dashboard.update.check': '检查更新', - 'dashboard.update.checkTitle': '检查是否有新版本可用', - 'dashboard.update.perform': '立即更新', - 'dashboard.update.performTitle': '更新到最新版本', - 'dashboard.update.checking': '正在检查...', - 'dashboard.update.upToDate': '已是最新', - 'dashboard.update.hasUpdate': '发现新版本: {version}', - 'dashboard.update.updating': '正在更新...', - 'dashboard.update.success': '更新成功', - 'dashboard.update.needsRestart': '代码已更新,请点击右上角「重启」按钮使更改生效', - 'dashboard.update.restartTitle': '更新完成', - 'dashboard.update.restartMsg': '代码已更新到版本 {version},请点击页面右上角的「重启」按钮使新代码生效。', - 'dashboard.update.failed': '更新失败: {error}', - 'dashboard.update.confirmTitle': '确认更新', - 'dashboard.update.confirmMsg': '确定要更新到版本 {version} 吗?更新期间服务可能会短暂不可用。', - 'dashboard.nodeVersion': 'Node.js版本', - 'dashboard.serverTime': '服务器时间', - 'dashboard.memoryUsage': '内存使用', - 'dashboard.cpuUsage': 'CPU 使用', - 'dashboard.serviceMode': '运行模式', - 'dashboard.serviceMode.worker': '子进程模式', - 'dashboard.serviceMode.standalone': '独立模式', - 'dashboard.serviceMode.canRestart': '支持自动重启', - 'dashboard.processPid': '进程 PID', - 'dashboard.platform': '操作系统', - 'dashboard.routing.title': '路径路由调用示例', - 'dashboard.routing.description': '通过不同路径路由访问不同的AI模型提供商,支持灵活的模型切换', - 'dashboard.routing.oauth': '突破限制', - 'dashboard.routing.official': '官方API/三方', - 'dashboard.routing.experimental': '突破限制/实验性', - 'dashboard.routing.free': '突破限制/免费使用', - 'dashboard.routing.endpoint': '端点路径:', - 'dashboard.routing.example': '使用示例', - 'dashboard.routing.exampleOpenAI': '使用示例 (OpenAI格式):', - 'dashboard.routing.exampleClaude': '使用示例 (Claude格式):', - 'dashboard.routing.openai': 'OpenAI协议', - 'dashboard.routing.claude': 'Claude协议', - 'dashboard.routing.tips': '使用提示', - 'dashboard.routing.tip1': '即时切换: 通过修改URL路径即可切换不同的AI模型提供商', - 'dashboard.routing.tip2': '客户端配置: 在Cherry-Studio、NextChat、Cline等客户端中设置API端点为对应路径', - 'dashboard.routing.tip3': '跨协议调用: 支持OpenAI协议调用Claude模型,或Claude协议调用OpenAI模型', - 'dashboard.routing.nodeName.gemini': 'Gemini CLI OAuth', - 'dashboard.routing.nodeName.antigravity': 'Gemini Antigravity', - 'dashboard.routing.nodeName.claude': 'Claude Custom', - 'dashboard.routing.nodeName.kiro': 'Claude Kiro OAuth', - 'dashboard.routing.nodeName.openai': 'OpenAI Custom', - 'dashboard.routing.nodeName.qwen': 'Qwen OAuth', - 'dashboard.routing.nodeName.iflow': 'iFlow OAuth', - 'dashboard.routing.nodeName.codex': 'OpenAI Codex OAuth', - 'dashboard.routing.nodeName.grok': 'Grok Reverse', - 'dashboard.contact.title': '联系与赞助', - 'dashboard.contact.wechat': '扫码进群,注明来意', - 'dashboard.contact.wechatDesc': '添加微信获取更多技术支持和交流', - 'dashboard.contact.x': '关注 X.com', - 'dashboard.contact.xDesc': '在 X 上关注我们获取最新动态', - 'dashboard.contact.sponsor': '扫码赞助', - 'dashboard.contact.sponsorDesc': '您的赞助是项目持续发展的动力', - 'dashboard.contact.coffee': 'Buy me a coffee', - 'dashboard.contact.coffeeDesc': 'If you like this project, buy me a coffee!', - - // OAuth - 'oauth.modal.title': 'OAuth 授权', - 'oauth.modal.provider': '提供商:', - 'oauth.modal.requiredPort': '需要开放端口:', - 'oauth.modal.portNote': '请确保此端口可被外部访问,用于接收授权回调', - 'oauth.modal.steps': '授权步骤:', - 'oauth.modal.step1': '点击下方按钮在浏览器中打开授权页面', - 'oauth.modal.step2.qwen': '完成授权后,系统会自动获取凭据文件', - 'oauth.modal.step2.google': '使用您的Google账号登录并授权', - 'oauth.modal.step3': '凭据文件可在上传配置管理中查看和管理', - 'oauth.modal.step4.qwen': '授权有效期: {min} 分钟', - 'oauth.modal.step4.google': '授权完成后,凭据文件会自动保存', - 'oauth.modal.urlLabel': '授权链接:', - 'oauth.modal.copyTitle': '复制', - 'oauth.modal.openInBrowser': '在浏览器中打开', - 'oauth.manual.title': '自动监听受阻?', - 'oauth.manual.desc': '如果授权窗口重定向后显示“无法访问”,请将该窗口地址栏的 完整 URL 粘贴到下方:', - 'oauth.manual.placeholder': '粘贴回调 URL (包含 code=...)', - 'oauth.manual.submit': '提交', - 'oauth.success.msg': '授权链接已复制到剪贴板', - 'oauth.window.blocked': '授权窗口被浏览器拦截,请允许弹出窗口', - 'oauth.window.opened': '已打开授权窗口,请在窗口中完成操作', - 'oauth.processing': '正在完成授权...', - 'oauth.invalid.url': '该 URL 似乎不包含有效的授权代码', - 'oauth.error.format': '无效的 URL 格式', - 'oauth.kiro.selectMethod': '选择认证方式', - 'oauth.kiro.google': 'Google 账号登录', - 'oauth.kiro.googleDesc': '使用 Google 账号进行社交登录', - 'oauth.kiro.github': 'GitHub 账号登录', - 'oauth.kiro.githubDesc': '使用 GitHub 账号进行社交登录', - 'oauth.kiro.awsBuilder': 'AWS Builder ID', - 'oauth.kiro.awsBuilderDesc': '使用 AWS Builder ID 进行设备码授权', - 'oauth.kiro.authMethodLabel': '认证方式:', - 'oauth.kiro.step1': '点击下方按钮在浏览器中打开授权链接', - 'oauth.kiro.step2': '使用您的 {method} 账号登录', - 'oauth.kiro.step3': '授权完成后页面会自动关闭', - 'oauth.kiro.step4': '刷新本页面查看凭据文件', - 'oauth.kiro.batchImport': '批量导入 Google/Github RefreshToken', - 'oauth.kiro.batchImportDesc': '批量导入已有的 refreshToken 生成凭据文件,该模式不支持 AWS 账号。', - 'oauth.kiro.batchImportInstructions': '请输入 refreshToken,每行一个。系统将自动刷新并生成凭据文件。', - 'oauth.kiro.awsImport': '导入 AWS 账号', - 'oauth.kiro.awsImportDesc': '从 AWS SSO cache 目录导入凭据文件,适用于 AWS Builder ID 模式。', - 'oauth.kiro.awsImportInstructions': '请上传 AWS SSO cache 目录中的 JSON 文件,需包含 clientId、clientSecret、accessToken、refreshToken 四个字段。如果是AWS企业用户,需增加 idcRegion 字段。', - 'oauth.kiro.awsModeFile': '文件上传', - 'oauth.kiro.awsModeJson': 'JSON 粘贴', - 'oauth.kiro.awsUploadFiles': '上传凭据文件', - 'oauth.kiro.awsDragDrop': '拖拽文件到此处', - 'oauth.kiro.awsClickUpload': '或点击选择文件', - 'oauth.kiro.awsFileHint': '如果一个文件不包含全部字段,可以多次上传不同的文件进行补全', - 'oauth.kiro.awsSelectedFiles': '已选择的文件', - 'oauth.kiro.awsClearFiles': '清空全部', - 'oauth.kiro.awsFileReplaced': '已替换同名文件: {filename}', - 'oauth.kiro.awsJsonInput': '粘贴 JSON 凭据', - 'oauth.kiro.awsJsonPlaceholderSimple': '在此粘贴包含 clientId、clientSecret、accessToken、refreshToken 的 JSON...', - 'oauth.kiro.awsJsonExample': '查看 JSON 格式示例', - 'oauth.kiro.awsJsonHint': '可以直接粘贴合并后的 JSON,或从 AWS SSO cache 文件复制内容', - 'oauth.kiro.awsJsonParseError': 'JSON 格式错误', - 'oauth.kiro.awsParseError': '解析文件 {filename} 失败', - 'oauth.kiro.awsValidationSuccess': '验证通过!已找到全部必需字段', - 'oauth.kiro.awsValidationFailed': '验证失败!缺少必需字段', - 'oauth.kiro.awsMissingFields': '缺少 {count} 个字段', - 'oauth.kiro.awsUploadMore': '请上传包含缺失字段的文件,或切换到 JSON 模式手动补全', - 'oauth.kiro.awsPreviewJson': '合并后的凭据预览', - 'oauth.kiro.awsConfirmImport': '确认导入', - 'oauth.kiro.awsNoCredentials': '没有可导入的凭据', - 'oauth.kiro.awsImporting': '正在导入...', - 'oauth.kiro.awsImportSuccess': 'AWS 凭据导入成功!', - 'oauth.kiro.awsImportFailed': 'AWS 凭据导入失败', - 'oauth.kiro.awsImportAllFailed': '导入失败!共 {count} 个凭据导入失败', - 'oauth.kiro.refreshTokensLabel': 'RefreshToken 列表', - 'oauth.kiro.refreshTokensPlaceholder': '每行输入一个 refreshToken\n例如:\naorAxxxxxxxx\naorAyyyyyyyy\naorAzzzzzzzz', - 'oauth.kiro.tokenCount': '待导入数量:', - 'oauth.kiro.importing': '正在导入中,请稍候...', - 'oauth.kiro.importingProgress': '正在导入 {current}/{total}...', - 'oauth.kiro.startImport': '开始导入', - 'oauth.kiro.noTokens': '请输入至少一个 refreshToken', - 'oauth.kiro.importSuccess': '导入成功!共 {count} 个凭据已生成', - 'oauth.kiro.importAllFailed': '导入失败!共 {count} 个 token 刷新失败', - 'oauth.kiro.importPartial': '部分成功:{success} 个成功,{failed} 个失败', - 'oauth.kiro.importError': '导入出错', - 'oauth.kiro.duplicateToken': '重复凭据 - 此 refreshToken 已存在', - 'oauth.gemini.selectMethod': '选择授权方式', - 'oauth.gemini.oauth': 'OAuth 授权', - 'oauth.gemini.oauthDesc': '通过 Google 账号进行标准 OAuth 授权', - 'oauth.gemini.batchImport': '批量导入', - 'oauth.gemini.batchImportDesc': '批量导入多个 Token JSON 数据', - 'oauth.gemini.tokensLabel': 'Token 数据 (JSON 数组)', - 'oauth.gemini.tokensPlaceholder': '请粘贴包含 access_token 和 refresh_token 的 JSON 数组...', - 'oauth.gemini.importInstructions': '请粘贴从浏览器或 CLI 获取的 Token JSON 数据。支持单个对象或对象数组。', - 'oauth.gemini.noTokens': '请输入有效的 Token 数据', - 'oauth.gemini.importing': '正在导入...', - 'oauth.gemini.importingProgress': '正在处理: {current} / {total}', - 'oauth.gemini.importSuccess': '成功导入 {count} 个凭据', - 'oauth.gemini.importAllFailed': '所有 {count} 个凭据导入失败', - 'oauth.gemini.importPartial': '部分导入成功: {success} 成功, {failed} 失败', - 'oauth.gemini.importError': '导入过程中出错', - 'oauth.gemini.tokenCount': 'Token 数量', - 'oauth.gemini.startImport': '开始导入', - 'oauth.gemini.jsonExample': '查看 JSON 格式示例', - 'oauth.gemini.jsonHint': '请确保 JSON 包含 access_token 和 refresh_token', - 'oauth.codex.batchImport': '批量导入 Codex Token', - 'oauth.codex.batchImportDesc': '批量导入多个 Codex Token JSON 数据', - 'oauth.codex.tokensLabel': 'Token 数据 (JSON 数组)', - 'oauth.codex.tokensPlaceholder': '请粘贴包含 access_token 和 id_token 的 JSON 数组...', - 'oauth.codex.importInstructions': '请粘贴从浏览器或 CLI 获取的 Codex Token JSON 数据。支持单个对象或对象数组。', - 'oauth.codex.noTokens': '请输入有效的 Token 数据', - 'oauth.codex.importing': '正在导入...', - 'oauth.codex.importingProgress': '正在处理: {current} / {total}', - 'oauth.codex.importSuccess': '成功导入 {count} 个凭据', - 'oauth.codex.importAllFailed': '所有 {count} 个凭据导入失败', - 'oauth.codex.importPartial': '部分导入成功: {success} 成功, {failed} 失败', - 'oauth.codex.importError': '导入过程中出错', - 'oauth.codex.tokenCount': 'Token 数量', - 'oauth.codex.startImport': '开始导入', - 'oauth.codex.jsonExample': '查看 JSON 格式示例', - 'oauth.codex.jsonHint': '请确保 JSON 包含 access_token 和 id_token', - 'oauth.kiro.duplicateCredentials': '该凭据已存在,请勿重复导入', - 'oauth.kiro.builderIDStartURL': 'Builder ID Start URL', - 'oauth.kiro.builderIDStartURLHint': '如果您使用 AWS IAM Identity Center,请输入您的 Start URL', - 'oauth.iflow.step1': '点击下方按钮在浏览器中打开 iFlow 授权页面', - 'oauth.iflow.step2': '使用您的 iFlow 账号登录并授权', - 'oauth.iflow.step3': '授权完成后,系统会自动获取 API Key', - 'oauth.iflow.step4': '凭据文件可在上传配置管理中查看和管理', - - // Config - 'config.title': '配置管理', - 'config.apiKey': 'API密钥', - 'config.apiKey.generate': '生成', - 'config.apiKey.generateTitle': '自动生成API密钥', - 'config.apiKey.generated': '已生成新的 API 密钥', - 'config.apiKeyPlaceholder': '请输入API密钥', - 'config.host': '监听地址', - 'config.hostPlaceholder': '例如: 127.0.0.1', - 'config.port': '端口', - 'config.portPlaceholder': '3000', - 'config.basic.title': '基础设置', - 'config.governance.title': '服务治理', - 'config.oauth.title': 'OAuth & 令牌', - 'config.modelProvider': '模型提供商', - 'config.modelProviderHelp': '勾选启动时初始化的模型提供商 (必须至少勾选一个)', - 'config.modelProviderRequired': '必须至少勾选一个模型提供商', - 'config.optional': '(选填)', - 'config.gemini.baseUrl': 'Gemini Base URL', - 'config.gemini.baseUrlPlaceholder': 'https://cloudcode-pa.googleapis.com', - 'config.gemini.projectId': '项目ID', - 'config.gemini.projectIdPlaceholder': 'Google Cloud项目ID', - 'config.gemini.oauthCreds': 'OAuth凭据', - 'config.gemini.credsType.file': '文件路径', - 'config.gemini.credsType.base64': 'Base64编码', - 'config.gemini.credsBase64': 'OAuth凭据 (Base64)', - 'config.gemini.credsBase64Placeholder': '请输入Base64编码的OAuth凭据', - 'config.gemini.credsFilePath': 'OAuth凭据文件路径', - 'config.gemini.credsFilePathPlaceholder': '例如: ~/.gemini/oauth_creds.json', - 'config.antigravity.dailyUrl': 'Daily Base URL', - 'config.antigravity.dailyUrlPlaceholder': 'https://daily-cloudcode-pa.sandbox.googleapis.com', - 'config.antigravity.autopushUrl': 'Autopush Base URL', - 'config.antigravity.autopushUrlPlaceholder': 'https://autopush-cloudcode-pa.sandbox.googleapis.com', - 'config.antigravity.credsFilePath': 'OAuth凭据文件路径', - 'config.antigravity.credsFilePathPlaceholder': '例如: ~/.antigravity/oauth_creds.json', - 'config.antigravity.note': 'Antigravity 使用 Google OAuth 认证,需要提供凭据文件路径', - 'config.openai.apiKey': 'OpenAI API Key', - 'config.openai.apiKeyPlaceholder': 'sk-...', - 'config.openai.baseUrl': 'OpenAI Base URL', - 'config.openai.baseUrlPlaceholder': '例如: https://api.openai.com/v1', - 'config.claude.apiKey': 'Claude API Key', - 'config.claude.apiKeyPlaceholder': 'sk-ant-...', - 'config.claude.baseUrl': 'Claude Base URL', - 'config.claude.baseUrlPlaceholder': '例如: https://api.anthropic.com', - 'config.kiro.baseUrl': 'Base URL', - 'config.kiro.baseUrlPlaceholder': 'https://codewhisperer.{{region}}.amazonaws.com/generateAssistantResponse', - 'config.kiro.refreshUrl': 'Refresh URL', - 'config.kiro.refreshUrlPlaceholder': 'https://prod.{{region}}.auth.desktop.kiro.dev/refreshToken', - 'config.kiro.refreshIdcUrl': 'Refresh IDC URL', - 'config.kiro.refreshIdcUrlPlaceholder': 'https://oidc.{{region}}.amazonaws.com/token', - 'config.kiro.credsFilePath': 'OAuth凭据文件路径', - 'config.kiro.credsFilePathPlaceholder': '例如: ~/.aws/sso/cache/kiro-auth-token.json', - 'config.kiro.note': '使用 AWS 登录方式时,请确保授权文件中包含 clientId 和 clientSecret 字段', - 'config.qwen.baseUrl': 'Qwen Base URL', - 'config.qwen.baseUrlPlaceholder': 'https://portal.qwen.ai/v1', - 'config.qwen.oauthBaseUrl': 'OAuth Base URL', - 'config.qwen.oauthBaseUrlPlaceholder': 'https://chat.qwen.ai', - 'config.qwen.credsFilePath': 'OAuth凭据文件路径', - 'config.qwen.credsFilePathPlaceholder': '例如: ~/.qwen/oauth_creds.json', - 'config.advanced.title': '高级配置', - 'config.advanced.systemPromptFile': '系统提示文件路径', - 'config.advanced.systemPromptFilePlaceholder': '例如: configs/input_system_prompt.txt', - 'config.advanced.systemPromptMode': '系统提示模式', - 'config.advanced.systemPromptMode.append': '追加 (append)', - 'config.advanced.systemPromptMode.overwrite': '覆盖 (overwrite)', - 'config.advanced.promptLogBaseName': '提示日志基础名称', - 'config.advanced.promptLogBaseNamePlaceholder': '例如: prompt_log', - 'config.advanced.promptLogMode': '提示日志模式', - 'config.advanced.promptLogMode.none': '无 (none)', - 'config.advanced.promptLogMode.console': '控制台 (console)', - 'config.advanced.promptLogMode.file': '文件 (file)', - 'config.advanced.maxRetries': '提供商内最大重试次数', - 'config.advanced.baseDelay': '重试基础延迟(毫秒)', - 'config.advanced.credentialSwitchMaxRetries': '坏凭证切换最大重试次数', - 'config.advanced.credentialSwitchMaxRetriesNote': '认证错误(401/403)后切换凭证的最大重试次数,默认 5 次', - 'config.advanced.warmupTarget': '系统预热节点数', - 'config.advanced.warmupTargetNote': '系统启动时自动刷新的节点数量,默认为 0', - 'config.advanced.refreshConcurrencyPerProvider': '提供商内刷新并发数', - 'config.advanced.refreshConcurrencyPerProviderNote': '每个提供商内部最大并行刷新任务数,默认为 1', - 'config.advanced.cronInterval': 'OAuth令牌刷新间隔(分钟)', - 'config.advanced.cronEnabled': '启用OAuth令牌自动刷新(需重启服务)', - 'config.advanced.loginExpiry': '登录过期时间(秒)', - 'config.advanced.loginExpiryNote': '管理后台登录后的 Token 有效期,默认 3600 秒 (1小时)', - 'config.advanced.poolFilePath': '提供商池配置文件路径(不能为空)', - 'config.advanced.poolFilePathPlaceholder': '默认: configs/provider_pools.json', - 'config.advanced.poolNote': '如使用客户端默认授权配置需使用空节点', - 'config.advanced.maxErrorCount': '提供商最大错误次数', - 'config.advanced.maxErrorCountPlaceholder': '默认: 10', - 'config.advanced.maxErrorCountNote': '提供商连续错误达到此次数后将被标记为不健康,默认为 10 次', - 'config.advanced.poolSizeLimit': '账号池轮询上限', - 'config.advanced.poolSizeLimitPlaceholder': '默认: 0 (不限制)', - 'config.advanced.poolSizeLimitNote': '每个提供商类型参与轮询的最大健康凭证数量,0 表示不限制,使用所有健康凭证', - 'config.advanced.credentialSwitchMaxRetries': '坏凭证切换最大重试次数', - 'config.advanced.credentialSwitchMaxRetriesNote': '认证错误(401/403)后切换凭证的最大重试次数,默认 5 次', - 'config.advanced.fallbackChain': '跨类型 Fallback 链配置', - 'config.advanced.fallbackChainPlaceholder': '例如:\n{\n "gemini-cli-oauth": ["gemini-antigravity"],\n "gemini-antigravity": ["gemini-cli-oauth"],\n "claude-kiro-oauth": ["claude-custom"]\n}', - 'config.advanced.fallbackChainNote': '当某一 Provider Type 所有账号都不健康时,自动切换到配置的 Fallback 类型。JSON 格式,键为主类型,值为 Fallback 类型数组(按优先级排序)', - 'config.advanced.fallbackChainInvalid': 'Fallback 链配置格式无效,请输入有效的 JSON', - 'config.advanced.modelFallbackMapping': '跨协议模型 Fallback 映射', - 'config.advanced.modelFallbackMappingPlaceholder': '例如:\n{\n "gemini-claude-opus-4-5-thinking": {\n "targetProviderType": "claude-kiro-oauth",\n "targetModel": "claude-opus-4-5"\n }\n}', - 'config.advanced.modelFallbackMappingNote': '当主 Provider 不可用时,根据模型名映射到其他协议的 Provider 和模型。优先级低于上方的 Fallback 链配置。JSON 格式。', - 'config.advanced.modelFallbackMappingInvalid': 'Model Fallback 映射配置格式无效,请输入有效的 JSON', - 'config.advanced.systemPrompt': '系统提示', - 'config.advanced.systemPromptPlaceholder': '输入系统提示...', - 'config.advanced.adminPassword': '后台登录密码', - 'config.advanced.adminPasswordPlaceholder': '设置后台登录密码(留空则不修改)', - 'config.advanced.adminPasswordNote': '用于保护管理控制台的访问,修改后需要重新登录', - 'config.proxy.title': '代理设置', - 'config.proxy.url': '代理地址', - 'config.proxy.urlPlaceholder': '例如: http://127.0.0.1:7890 或 socks5://127.0.0.1:1080', - 'config.proxy.urlNote': '支持 HTTP、HTTPS 和 SOCKS5 代理,留空则不使用代理', - 'config.proxy.enabledProviders': '启用代理的提供商', - 'config.proxy.enabledProvidersNote': '选择需要通过代理访问的提供商,未选中的提供商将直接连接', - 'config.proxy.tlsSidecarEnabled': 'TLS 指纹伪装 (uTLS Sidecar)', - 'config.proxy.tlsSidecarPort': 'Sidecar 端口', - 'config.proxy.tlsSidecarProxyUrl': 'Sidecar 上游代理', - 'config.proxy.tlsSidecarEnabledProviders': '启用 TLS Sidecar 的提供商', - 'config.proxy.tlsSidecarNote': '启用后选中的提供商请求将通过 Go uTLS sidecar 转发,完美模拟 Chrome TLS/H2 指纹绕过 Cloudflare(需重启服务)', - 'config.log.title': '日志设置', - 'config.log.enabled': '启用日志', - 'config.log.outputMode': '日志输出模式', - 'config.log.outputMode.all': '全部 (控制台+文件)', - 'config.log.outputMode.console': '仅控制台', - 'config.log.outputMode.file': '仅文件', - 'config.log.outputMode.none': '禁用', - 'config.log.level': '日志级别', - 'config.log.level.debug': '调试 (debug)', - 'config.log.level.info': '信息 (info)', - 'config.log.level.warn': '警告 (warn)', - 'config.log.level.error': '错误 (error)', - 'config.log.dir': '日志目录', - 'config.log.dirPlaceholder': '例如: logs', - 'config.log.includeRequestId': '包含请求ID', - 'config.log.includeTimestamp': '包含时间戳', - 'config.log.maxFileSize': '最大文件大小(字节)', - 'config.log.maxFileSizeNote': '默认 10MB (10485760 字节)', - 'config.log.maxFiles': '最大保留文件数', - 'config.log.maxFilesNote': '保留最近的日志文件数量', - 'config.save': '保存配置', - 'config.reset': '重置', - 'config.placeholder.nodeName': '例如: 我的节点1', - 'config.placeholder.model': '例如: gpt-3.5-turbo', - - // Upload Config - 'upload.title': '凭据文件管理', - 'upload.search': '搜索配置', - 'upload.searchPlaceholder': '输入文件名', - 'upload.providerFilter': '提供商类型', - 'upload.providerFilter.all': '全部提供商', - 'upload.providerFilter.kiro': 'Kiro OAuth', - 'upload.providerFilter.gemini': 'Gemini OAuth', - 'upload.providerFilter.qwen': 'Qwen OAuth', - 'upload.providerFilter.antigravity': 'Antigravity', - 'upload.providerFilter.codex': 'Codex OAuth', - 'upload.providerFilter.iflow': 'iFlow OAuth', - 'upload.providerFilter.grok': 'Grok Reverse', - 'upload.providerFilter.other': '其他/未识别', - 'upload.statusFilter': '关联状态', - 'upload.statusFilter.all': '全部状态', - 'upload.statusFilter.used': '已关联', - 'upload.statusFilter.unused': '未关联', - 'upload.refresh': '刷新', - 'upload.downloadAll': '打包下载', - 'upload.listTitle': '配置文件列表', - 'upload.count': '共 {count} 个配置文件', - 'upload.usedCount': '已关联: {count}', - 'upload.unusedCount': '未关联: {count}', - 'upload.batchLink': '自动关联oauth', - 'upload.noConfigs': '未找到匹配的配置文件', - 'upload.detail.path': '文件路径', - 'upload.detail.size': '文件大小', - 'upload.detail.modified': '最后修改', - 'upload.detail.status': '关联状态', - 'upload.action.view': '查看', - 'upload.action.download': '下载', - 'upload.action.delete': '删除', - 'upload.usage.title': '关联详情 ({type})', - 'upload.usage.mainConfig': '主要配置', - 'upload.usage.providerPool': '提供商池', - 'upload.usage.multiple': '多种用途', - 'upload.delete.confirmTitle': '删除配置文件', - 'upload.delete.confirmTitleUsed': '删除已关联配置', - 'upload.delete.warningUsedTitle': '⚠️ 此配置已被系统使用', - 'upload.delete.warningUsedDesc': '删除已关联的配置文件可能会影响系统正常运行。请确保您了解删除的后果。', - 'upload.delete.warningUnusedTitle': '🗑️ 确认删除配置文件', - 'upload.delete.warningUnusedDesc': '此操作将永久删除配置文件,且无法撤销。', - 'upload.delete.fileName': '文件名:', - 'upload.delete.usageAlertTitle': '关联详情', - 'upload.delete.usageAlertDesc': '此配置文件正在被系统使用,删除后可能会导致:', - 'upload.delete.usageAlertItem1': '相关的AI服务无法正常工作', - 'upload.delete.usageAlertItem2': '配置管理中的设置失效', - 'upload.delete.usageAlertItem3': '提供商池配置丢失', - 'upload.delete.usageAlertAdvice': '建议:请先在配置管理中解除文件引用后再删除。', - 'upload.delete.forceDelete': '强制删除', - 'upload.delete.confirmDelete': '确认删除', - 'upload.batchLink.confirm': '确定要批量关联 {count} 个配置吗?\n\n{summary}', - 'upload.refresh.success': '刷新成功', - 'upload.action.view.failed': '查看失败', - 'upload.action.delete.failed': '删除失败', - 'upload.action.quickLink': '一键关联', - 'upload.config.notExist': '配置文件不存在', - 'upload.link.identifying': '正在识别提供商类型...', - 'upload.link.failed.identify': '无法识别配置文件对应的提供商类型', - 'upload.link.processing': '正在关联配置到 {name}...', - 'upload.link.success': '配置关联成功', - 'upload.link.failed': '关联失败', - 'upload.batchLink.none': '没有需要关联的配置文件', - 'upload.batchLink.processing': '正在批量关联 {count} 个配置...', - 'upload.batchLink.success': '成功关联 {count} 个配置', - 'upload.batchLink.partial': '关联完成: 成功 {success} 个, 失败 {fail} 个', - 'upload.deleteUnbound': '删除未关联', - 'upload.deleteUnbound.none': '没有可删除的未关联配置文件(仅删除 configs/子目录/ 下的文件)', - 'upload.deleteUnbound.confirm': '确定要删除 {count} 个未关联的配置文件吗?\n\n注意:仅删除 configs/子目录/ 下的未关联文件,configs/ 根目录下的文件不会被删除。\n\n此操作不可撤销!', - 'upload.deleteUnbound.processing': '正在删除未关联的配置文件...', - 'upload.deleteUnbound.success': '成功删除 {count} 个未关联的配置文件', - 'upload.deleteUnbound.partial': '删除完成: 成功 {success} 个, 失败 {fail} 个', - 'upload.deleteUnbound.failed': '删除未关联配置失败', - - // Providers - 'providers.title': '提供商池管理', - 'providers.note': '如使用客户端默认授权配置需使用空节点', - 'providers.activeConnections': '活动连接', - 'providers.activeProviders': '活跃提供商', - 'providers.healthyProviders': '健康提供商', - 'providers.status.healthy': '{healthy}/{total} 可用', - 'providers.status.empty': '0/0 节点', - 'providers.stat.totalAccounts': '总账户', - 'providers.stat.healthyAccounts': '健康账户', - 'providers.stat.usageCount': '使用次数', - 'providers.stat.errorCount': '错误次数', - 'providers.auth.generate': '生成授权', - 'providers.auth.importToken': '导入 Token', - - // Modal Provider Manager - 'modal.provider.manage': '管理 {type} 提供商配置', - 'modal.provider.totalAccounts': '总账户数:', - 'modal.provider.healthyAccounts': '健康账户:', - 'modal.provider.add': '添加新提供商', - 'modal.provider.resetHealth': '重置为健康', - 'modal.provider.healthCheck': '检测不健康', - 'modal.provider.resetHealthConfirm': '确定要将 {type} 的所有节点重置为健康状态吗?\n\n这将清除所有节点的错误计数和错误时间。', - 'modal.provider.healthCheckConfirm': '确定要对 {type} 的不健康节点执行健康检测吗?\n\n这将向不健康节点发送测试请求来验证其可用性。', - 'modal.provider.deleteConfirm': '确定要删除这个提供商配置吗?此操作不可恢复。', - 'modal.provider.disableConfirm': '确定要禁用这个提供商配置吗?禁用后该提供商将不会被选中使用。', - 'modal.provider.enableConfirm': '确定要启用这个提供商配置吗?', - 'modal.provider.edit': '编辑', - 'modal.provider.delete': '删除', - 'modal.provider.save': '保存', - 'modal.provider.cancel': '取消', - 'modal.provider.status.healthy': '正常', - 'modal.provider.status.unhealthy': '异常', - 'modal.provider.status.disabled': '已禁用', - 'modal.provider.status.enabled': '已启用', - 'modal.provider.lastError': '最后错误:', - 'modal.provider.lastUsed': '最后使用:', - 'modal.provider.lastCheck': '最后检测:', - 'modal.provider.checkModel': '检测模型:', - 'modal.provider.concurrencyLimit': '并发限制', - 'modal.provider.queueLimit': '队列限制', - 'modal.provider.usageCount': '使用次数:', - 'modal.provider.errorCount': '失败次数:', - 'modal.provider.neverUsed': '从未使用', - 'modal.provider.neverChecked': '从未检测', - 'modal.provider.noModels': '该提供商类型暂无可用模型列表', - 'modal.provider.loadingModels': '加载模型列表...', - 'modal.provider.unsupportedModels': '不支持的模型', - 'modal.provider.unsupportedModelsHelp': '选择此提供商不支持的模型,系统会自动排除这些模型', - 'modal.provider.addTitle': '添加新提供商配置', - 'modal.provider.customName': '自定义名称', - 'modal.provider.checkModelName': '检查模型名称', - 'modal.provider.healthCheckLabel': '健康检查', - 'modal.provider.enabled': '启用', - 'modal.provider.disabled': '禁用', - 'modal.provider.noProviderType': '不支持的提供商类型', - 'modal.provider.refreshUuid': '刷新uuid', - 'modal.provider.refreshUuidConfirm': '确定要刷新此提供商的uuid吗?\n\n旧uuid: {oldUuid}\n\n刷新后将生成新的uuid,请确保没有其他系统依赖此uuid。', - 'modal.provider.refreshUuid.success': 'uuid刷新成功\n\n旧uuid: {oldUuid}\n新uuid: {newUuid}', - 'modal.provider.refreshUuid.failed': 'uuid刷新失败', - 'modal.provider.field.projectId': '项目 ID', - 'modal.provider.field.oauthPath': 'OAuth 凭据文件路径', - 'modal.provider.field.baseUrl': 'Base URL', - 'modal.provider.field.refreshUrl': 'Refresh URL', - 'modal.provider.field.refreshIdcUrl': 'Refresh IDC URL', - 'modal.provider.field.oauthBaseUrl': 'OAuth Base URL', - 'modal.provider.field.dailyBaseUrl': 'Daily Base URL', - 'modal.provider.field.autopushBaseUrl': 'Autopush Base URL', - 'modal.provider.field.headerName': 'Header 名称', - 'modal.provider.field.headerPrefix': 'Header 值前缀', - 'modal.provider.field.useSystemProxy': '使用系统代理', - 'modal.provider.field.ssoToken': 'SSO Token (Cookie)', - 'modal.provider.field.cfClearance': 'CF Clearance (Cookie)', - 'modal.provider.field.userAgent': 'User-Agent (浏览器指纹)', - 'modal.provider.field.iflowBaseUrl': 'iFlow Base URL', - 'modal.provider.field.grokBaseUrl': 'Grok Base URL', - 'modal.provider.field.codexBaseUrl': 'Codex Base URL', - 'modal.provider.field.apiKey': 'API 密钥', - 'modal.provider.field.apiKey.placeholder': '请输入 API 密钥', - 'modal.provider.field.projectId.placeholder': 'Google Cloud 项目 ID (留空自动发现)', - 'modal.provider.field.projectId.optional.placeholder': 'Google Cloud 项目 ID (留空自动发现)', - 'modal.provider.field.oauthPath.gemini.placeholder': '例如: ~/.gemini/oauth_creds.json', - 'modal.provider.field.oauthPath.kiro.placeholder': '例如: ~/.aws/sso/cache/kiro-auth-token.json', - 'modal.provider.field.oauthPath.qwen.placeholder': '例如: ~/.qwen/oauth_creds.json', - 'modal.provider.field.oauthPath.antigravity.placeholder': '例如: ~/.antigravity/oauth_creds.json', - 'modal.provider.field.oauthPath.iflow.placeholder': '例如: configs/iflow/oauth_creds.json', - 'modal.provider.field.oauthPath.codex.placeholder': '例如: configs/codex/oauth_creds.json', - 'modal.provider.field.email': '邮箱', - 'modal.provider.field.email.placeholder': '你的邮箱@example.com', - - 'modal.provider.load.failed': '加载提供商详情失败', - 'modal.provider.auth.initializing': '正在初始化凭据生成...', - 'modal.provider.auth.success': '凭据已生成并自动填充路径', - 'modal.provider.auth.window': '请在打开的窗口中完成授权', - 'modal.provider.auth.failed': '初始化凭据生成失败', - 'modal.provider.save.success': '保存成功', - 'modal.provider.save.failed': '保存失败', - 'modal.provider.delete.success': '删除成功', - 'modal.provider.delete.failed': '删除失败', - 'modal.provider.add.success': '添加成功', - 'modal.provider.add.failed': '添加失败', - 'modal.provider.resetHealth.success': '成功重置 {count} 个节点的健康状态', - 'modal.provider.resetHealth.failed': '重置健康状态失败', - 'modal.provider.deleteUnhealthy': '删除不健康节点', - 'modal.provider.deleteUnhealthyBtn': '删除不健康', - 'modal.provider.deleteUnhealthyConfirm': '确定要删除 {count} 个不健康节点吗?此操作不可恢复。', - 'modal.provider.deleteUnhealthy.noUnhealthy': '没有不健康节点', - 'modal.provider.deleteUnhealthy.deleting': '正在删除...', - 'modal.provider.deleteUnhealthy.success': '已删除 {count} 个节点', - 'modal.provider.deleteUnhealthy.failed': '删除失败', - 'modal.provider.healthCheck.complete': '健康检查完成: {success} 变为健康', - 'modal.provider.healthCheck.abnormal': ', {fail} 异常', - 'modal.provider.healthCheck.skipped': ', {skipped} 跳过(未启用)', - 'modal.provider.refreshUnhealthyUuids': '刷新不健康UUID', - 'modal.provider.refreshUnhealthyUuidsBtn': '刷新UUID', - 'modal.provider.refreshUnhealthyUuidsConfirm': '确定要刷新 {count} 个不健康节点的UUID吗?', - 'modal.provider.refreshUnhealthyUuids.noUnhealthy': '没有不健康节点', - 'modal.provider.refreshUnhealthyUuids.refreshing': '正在刷新...', - 'modal.provider.refreshUnhealthyUuids.success': '已刷新 {count} 个节点的UUID', - 'modal.provider.refreshUnhealthyUuids.failed': '刷新失败', - 'modal.provider.kiroAuthHint': '使用 AWS Builder ID 登录方式时,需要 clientIdclientSecret 字段,可在同文件夹下的另一个 JSON 文件中获取', - - // Pagination - 'pagination.showing': '显示 {start}-{end} / 共 {total} 条', - 'pagination.jumpTo': '跳转到', - 'pagination.page': '页', - - // Usage - 'usage.title': '用量查询', - 'usage.refresh': '刷新用量', - 'usage.serverTime': '服务端时间', - 'usage.lastUpdate': '上次更新: {time}', - 'usage.lastUpdateCache': '缓存时间: {time}', - 'usage.supportedProvidersPrefix': '支持用量查询的提供商:', - 'usage.loading': '正在加载用量数据...', - 'usage.empty': '点击"刷新用量"按钮获取授权文件用量信息', - 'usage.noData': '暂无用量数据', - 'usage.noInstances': '暂无已初始化的服务实例', - 'usage.group.instances': '{count} 个实例', - 'usage.group.success': '{count}/{total} 成功', - 'usage.card.status.disabled': '已禁用', - 'usage.card.status.healthy': '健康', - 'usage.card.status.unhealthy': '异常', - 'usage.card.totalUsage': '总用量', - 'usage.card.resetAt': '将在 {time} 重置', - 'usage.card.downloadConfig': '下载授权文件', - 'usage.card.downloadSuccess': '授权文件下载成功', - 'usage.card.downloadFailed': '授权文件下载失败', - 'usage.card.freeTrial': '免费试用', - 'usage.card.bonus': '奖励', - 'usage.card.expires': '到期: {time}', - 'usage.doubleClickToRefresh': '双击刷新该提供商用量', - 'usage.refreshingProvider': '正在刷新 {name} 用量...', - 'usage.group.expandAll': '展开所有卡片', - 'usage.group.collapseAll': '折叠所有卡片', - 'usage.failedToLoad': '加载失败', - 'usage.loadFailed': '获取支持的提供商列表失败', - 'usage.resetInfo': '将在 {time} 后重置', - 'usage.weeklyLimit': '每周限制', - 'usage.time.days': '{days}天{hours}小时', - 'usage.time.hours': '{hours}小时{minutes}分', - 'usage.time.minutes': '{minutes}分钟', - 'usage.time.soon': '即将', - - // Logs - 'logs.title': '实时日志', - 'logs.clear': '清空日志', - 'logs.download': '下载日志', - 'logs.autoScroll': '自动滚动', - 'logs.autoScroll.on': '自动滚动: 开', - 'logs.autoScroll.off': '自动滚动: 关', - 'logs.clear.confirm.title': '警告', - 'logs.clear.confirm.msg': '此操作将清空当日的本地日志文件!\n\n• 前端页面的实时日志将被清空\n• 服务器上当日的日志文件也将被清空\n• 此操作不可恢复\n\n确定要继续吗?', - 'logs.clear.success.title': '清空成功', - 'logs.clear.success.msg': '前端实时日志和服务器当日日志文件已全部清空', - 'logs.clear.failed': '清空日志失败', - - // Plugins - 'plugins.title': '插件管理', - 'plugins.description': '插件系统允许您扩展系统功能,启用或禁用插件需要重启服务才能生效', - 'plugins.stats.total': '总插件数', - 'plugins.stats.enabled': '已启用', - 'plugins.stats.disabled': '已禁用', - 'plugins.refresh': '刷新插件列表', - 'plugins.loading': '正在加载插件列表...', - 'plugins.empty': '暂无已安装的插件', - 'plugins.noDescription': '暂无描述', - 'plugins.status.enabled': '已启用', - 'plugins.status.disabled': '已禁用', - 'plugins.badge.middleware.title': '包含中间件', - 'plugins.badge.routes.title': '包含路由', - 'plugins.badge.hooks.title': '包含钩子', - 'plugins.toggle.success': '插件 {name} {status}', - 'plugins.toggle.failed': '切换插件状态失败', - 'plugins.load.failed': '加载插件列表失败', - 'plugins.restart.required': '更改已保存', - - // Models - 'models.title': '可用模型列表', - 'models.note': '点击模型名称可直接复制到剪贴板', - 'models.empty': '暂无可用模型', - 'models.loadError': '加载模型列表失败', - 'models.copied': '已复制', - 'models.clickToCopy': '点击复制', - - // Guide - 'guide.title': '使用指南', - 'guide.intro.title': '项目简介', - 'guide.intro.desc': 'AIClient2API 是一个突破客户端限制的 API 代理服务,将 Gemini、Antigravity、Qwen Code、Kiro 等原本仅限客户端内使用的免费大模型,转换为可供任何应用调用的标准 OpenAI 兼容接口。', - 'guide.intro.feature1.title': '统一接入', - 'guide.intro.feature1.desc': '通过标准 OpenAI 兼容协议,一次配置即可接入多种大模型', - 'guide.intro.feature2.title': '突破限制', - 'guide.intro.feature2.desc': '利用 OAuth 授权机制,有效突破免费 API 速率和配额限制', - 'guide.intro.feature3.title': '协议转换', - 'guide.intro.feature3.desc': '支持 OpenAI、Claude、Gemini 三大协议间的智能转换', - 'guide.intro.feature4.title': '账号池管理', - 'guide.intro.feature4.desc': '支持多账号轮询、自动故障转移和配置降级', - 'guide.providers.title': '支持的模型提供商', - 'guide.providers.badge.oauth': 'OAuth 授权', - 'guide.providers.badge.experimental': '实验性', - 'guide.providers.badge.free': '免费使用', - 'guide.providers.badge.official': '官方 API', - 'guide.providers.gemini.desc': '通过 Google OAuth 认证访问 Gemini 模型,支持 gemini-3.1-pro-preview 等模型', - 'guide.providers.antigravity.desc': '通过 Google 内部接口访问 Gemini 3 Pro、Claude Sonnet 4.5 等模型', - 'guide.providers.kiro.desc': '通过 Kiro 客户端免费使用 Claude Opus 4.5、Claude Sonnet 4.5 等模型', - 'guide.providers.qwen.desc': '通过阿里云 OAuth 认证访问 Qwen3 Coder Plus 等模型', - 'guide.providers.claude.desc': '使用 Claude 官方 API 或第三方代理访问 Claude 系列模型', - 'guide.providers.openai.desc': '使用 OpenAI 官方 API 或第三方代理访问 GPT 系列模型', - 'guide.providers.iflow.desc': '通过 iFlow OAuth 认证访问 Qwen、Kimi、DeepSeek、GLM 等模型', - 'guide.providers.grok.desc': '通过 Grok 逆向接口访问 Grok-3、Grok-4 等模型,支持生图与视频生成', - 'guide.client.title': '客户端配置指南', - 'guide.client.desc': '以下是常见 AI 客户端的配置方法,将 API 端点设置为本服务地址即可使用:', - 'guide.client.cherry.step1': '打开设置 → 模型服务商', - 'guide.client.cherry.step2': '添加自定义服务商', - 'guide.client.cherry.step3': '设置 API 地址为: http://localhost:3000/{provider}', - 'guide.client.cherry.step4': '填入 API Key(配置文件中的 REQUIRED_API_KEY)', - 'guide.client.cline.step1': '打开 VS Code 设置', - 'guide.client.cline.step2': '搜索 Cline 或 Continue 配置', - 'guide.client.cline.step3': '设置 API Base URL 为: http://localhost:3000/{provider}/v1', - 'guide.client.cline.step4': '填入 API Key 和模型名称', - 'guide.client.note': '提示:将 {provider} 替换为实际的提供商路径,如 gemini-cli-oauth、claude-kiro-oauth 等。可在仪表盘的路由示例中查看完整路径。', - 'guide.faq.title': '常见问题', - 'guide.faq.q1': 'Q: 请求返回 404 错误怎么办?', - 'guide.faq.a1': 'A: 检查接口路径是否正确。某些客户端会自动在 Base URL 后追加路径,导致路径重复。请查看控制台中的实际请求 URL,移除多余的路径部分。', - 'guide.faq.q2': 'Q: 请求返回 429 错误怎么办?', - 'guide.faq.a2': 'A: 429 表示请求频率过高。建议配置多个账号到提供商池,启用轮询机制;或配置 Fallback 链实现跨类型降级。', - 'guide.faq.q3': 'Q: OAuth 授权失败怎么办?', - 'guide.faq.a3': 'A: 确保 OAuth 回调端口可访问(Gemini: 8085, Antigravity: 8086, Kiro: 19876-19880)。Docker 用户需确保已正确映射这些端口。', - 'guide.faq.q4': 'Q: 如何查看可用的模型列表?', - 'guide.faq.a4': 'A: 在侧边栏点击"可用模型"页面,可以查看所有已配置提供商支持的模型列表。点击模型名称可直接复制。', - 'guide.faq.q5': 'Q: 流式响应中断怎么办?', - 'guide.faq.a5': 'A: 检查网络稳定性,增加客户端请求超时时间。如使用代理,确保代理支持长连接。', - 'guide.faq.q6': 'Q: 请求返回 "No available and healthy providers" 错误怎么办?', - 'guide.faq.a6': 'A: 这表示对应类型的提供商都不可用。请在"提供商池"页面检查提供商健康状态,确认 OAuth 凭据未过期,或配置 Fallback 链实现自动切换到备用提供商。', - 'guide.faq.q7': 'Q: 请求返回 403 Forbidden 错误怎么办?', - 'guide.faq.a7': 'A: 403 表示访问被拒绝。首先检查"提供商池"页面中节点状态,如果节点健康检查正常,可以忽略此报错。其他可能原因包括:账号权限不足、API Key 权限受限、地区访问限制、凭据已失效等。', - - // Guide - Flow - 'guide.flow.title': '操作流程图', - 'guide.flow.step1.title': '配置管理', - 'guide.flow.step1.desc': '在「配置管理」页面设置基本参数', - 'guide.flow.step1.item1': '设置 API Key', - 'guide.flow.step1.item2': '选择启动时初始化的模型提供商', - 'guide.flow.step1.item3': '配置高级选项', - 'guide.flow.step2.title': '生成授权', - 'guide.flow.step2.desc': '在「提供商池管理」页面生成 OAuth 授权', - 'guide.flow.step2.method1': '方式一:OAuth 授权', - 'guide.flow.step2.method1.item1': '点击「生成授权」按钮', - 'guide.flow.step2.method1.item2': '在弹窗中完成 OAuth 登录', - 'guide.flow.step2.method1.item3': '凭据自动保存', - 'guide.flow.step2.or': '或', - 'guide.flow.step2.method2': '方式二:手动上传', - 'guide.flow.step2.method2.item1': '新增提供商节点', - 'guide.flow.step2.method2.item2': '上传已有的授权文件', - 'guide.flow.step2.method2.item3': '手动关联凭据路径', - 'guide.flow.step2.method3': '方式三:对接提供商 API', - 'guide.flow.step2.method3.item1': '在「提供商池管理」新增对应协议的节点', - 'guide.flow.step2.method3.item2': '填入 API Key 和端点 (Base URL)', - 'guide.flow.step2.method3.item3': '无需生成或上传 OAuth 授权文件', - 'guide.flow.step3.title': '管理凭据', - 'guide.flow.step3.desc': '在「凭据文件管理」页面查看和管理凭据(非授权提供商可忽略)', - 'guide.flow.step3.item1': '查看已生成的凭据文件', - 'guide.flow.step3.item2': '自动关联到提供商池', - 'guide.flow.step3.item3': '删除无效凭据', - 'guide.flow.step4.title': '开始使用', - 'guide.flow.step4.desc': '在「仪表盘」查看路由示例并开始调用 API', - 'guide.flow.step4.item1': '查看路由调用示例', - 'guide.flow.step4.item2': '复制 API 端点地址', - 'guide.flow.step4.item3': '在客户端中配置使用', - - // Tutorial - 'tutorial.title': '配置教程', - 'tutorial.config.title': '配置文件说明', - 'tutorial.config.desc': '所有配置文件都存放在 configs/ 目录下。主要配置文件包括:', - 'tutorial.config.badge.required': '必需', - 'tutorial.config.badge.optional': '可选', - 'tutorial.config.file.config': '主配置文件,包含 API Key、端口、模型提供商等核心设置 (保存配置管理后自动新建)', - 'tutorial.config.file.pools': '提供商池配置,用于多账号轮询和故障转移 (保存节点后自动新建)', - 'tutorial.config.file.plugins': '插件配置,用于启用或禁用系统插件', - 'tutorial.config.file.pwd': '后台登录密码文件,默认密码为 admin123', - 'tutorial.main.title': '主配置详解 (config.json)', - 'tutorial.main.table.param': '参数', - 'tutorial.main.table.type': '类型', - 'tutorial.main.table.default': '默认值', - 'tutorial.main.table.desc': '说明', - 'tutorial.main.basic.title': '基础配置', - 'tutorial.main.basic.apikey': '访问本服务所需的 API Key', - 'tutorial.main.basic.port': '服务监听端口', - 'tutorial.main.basic.host': '服务监听地址', - 'tutorial.main.basic.provider': '默认模型提供商', - 'tutorial.main.prompt.title': '系统提示配置', - 'tutorial.main.prompt.file': '系统提示文件路径', - 'tutorial.main.prompt.mode': '系统提示模式:overwrite(覆盖) 或 append(追加)', - 'tutorial.main.retry.title': '重试配置', - 'tutorial.main.retry.max': '提供商内最大重试次数', - 'tutorial.main.retry.delay': '重试基础延迟(毫秒)', - 'tutorial.main.retry.credentialSwitch': '坏凭证切换最大重试次数', - 'tutorial.main.retry.error': '提供商最大错误次数,超过后标记为不健康', - 'tutorial.main.governance.warmup': '系统启动时自动刷新的节点数量', - 'tutorial.main.governance.concurrency': '每个提供商内部最大并行刷新任务数', - 'tutorial.main.governance.poolLimit': '每个提供商类型参与轮询的最大健康凭证数量', - 'tutorial.main.example.title': '配置示例', - 'tutorial.pool.title': '提供商池配置 (provider_pools.json)', - 'tutorial.pool.desc': '提供商池用于配置多个账号,实现负载均衡和故障转移。每个提供商类型可以配置多个账号节点。', - 'tutorial.pool.node.title': '节点配置参数', - 'tutorial.pool.node.uuid': '节点唯一标识,自动生成', - 'tutorial.pool.node.name': '节点自定义名称', - 'tutorial.pool.node.oauth': 'OAuth 凭据文件路径', - 'tutorial.pool.node.health': '是否启用健康检查', - 'tutorial.pool.node.model': '健康检查使用的模型', - 'tutorial.pool.node.unsupported': '该节点不支持的模型列表', - 'tutorial.pool.node.disabled': '是否禁用该节点', - 'tutorial.pool.example.title': '配置示例', - 'tutorial.fallback.title': 'Fallback 降级配置', - 'tutorial.fallback.desc': '当某一提供商类型的所有账号都不可用时,可以自动切换到配置的备用提供商。', - 'tutorial.fallback.chain.title': '跨类型 Fallback 链', - 'tutorial.fallback.chain.desc': '在 config.json 中配置 providerFallbackChain,指定每个提供商类型的备用类型:', - 'tutorial.fallback.model.title': '跨协议模型映射', - 'tutorial.fallback.model.desc': '当主提供商不可用时,可以将特定模型映射到其他协议的提供商:', - 'tutorial.proxy.title': '代理配置', - 'tutorial.proxy.desc': '支持为特定提供商配置代理,用于网络受限环境。', - 'tutorial.proxy.config.title': '代理配置参数', - 'tutorial.proxy.url': '代理地址,支持 HTTP、HTTPS、SOCKS5', - 'tutorial.proxy.providers': '启用代理的提供商列表', - 'tutorial.proxy.example.title': '配置示例', - 'tutorial.proxy.note': '支持的代理类型:HTTP (http://)、HTTPS (https://)、SOCKS5 (socks5://)', - 'tutorial.oauth.title': 'OAuth 授权配置', - 'tutorial.oauth.desc': '各提供商的 OAuth 凭据文件默认存储位置:', - 'tutorial.oauth.note': '推荐通过 Web UI 控制台的"提供商池管理"页面点击"生成授权"按钮进行可视化授权,系统会自动保存凭据文件。', - 'tutorial.log.title': '日志配置', - 'tutorial.log.prompt.title': '提示日志配置', - 'tutorial.log.mode': '日志模式:none(关闭)、console(控制台)、file(文件)', - 'tutorial.log.basename': '日志文件基础名称', - 'tutorial.log.example.title': '配置示例', - - // Common - 'common.confirm': '确定', - 'common.cancel': '取消', - 'common.success': '成功', - 'common.enabled': '已启用', - 'common.disabled': '已禁用', - 'common.error': '错误', - 'common.warning': '警告', - 'common.info': '信息', - 'common.loading': '加载中...', - 'common.upload': '上传', - 'common.generate': '生成', - 'common.optional': '可选', - 'common.found': '已找到', - 'common.missing': '缺失', - 'common.search': '搜索', - 'common.welcome': '欢迎使用AIClient2API管理控制台!', - 'common.fileType': '不支持的文件类型,请选择 JSON、TXT、KEY、PEM、P12 或 PFX 文件', - 'common.fileSize': '文件大小不能超过 5MB', - 'common.uploadSuccess': '文件上传成功', - 'common.uploadFailed': '文件上传失败', - 'common.passwordUpdated': '后台密码已更新,下次登录生效', - 'common.configSaved': '配置已保存', - 'common.providerPoolRefreshed': '提供商池数据已刷新', - 'common.togglePassword': '显示/隐藏密码', - 'common.copy.success': '内容已复制到剪贴板', - 'common.copy.failed': '复制失败,请手动复制', - 'common.refresh.success': '刷新成功', - 'common.refresh.failed': '刷新失败', - - // Login - 'login.title': '登录 - AIClient2API', - 'login.heading': '请登录以继续', - 'login.password': '密码', - 'login.passwordPlaceholder': '请输入密码', - 'login.error.empty': '请输入密码', - 'login.error.incorrect': '密码错误,请重试', - 'login.error.incorrectWithLock': '密码错误。账户已被锁定 {time} 分钟。', - 'login.error.incorrectWithRemaining': '密码错误。还剩 {count} 次尝试机会。', - 'login.error.locked': '账户因多次尝试失败而被暂时锁定。请在 {time} 秒后重试。', - 'login.error.tooFrequent': '请求过于频繁,请稍候再试。', - 'login.error.postOnly': '仅支持 POST 请求', - 'login.error.invalidJson': '请求格式错误 (JSON)', - 'login.error.failed': '登录失败,请检查网络连接', - 'login.button': '登录', - 'login.loggingIn': '登录中...', - }, - 'en-US': { - // Header - 'header.title': 'AIClient2API Management Console', - 'header.description': 'AIClient2API Management Console - Unified management of AI service providers', - 'header.github': 'GitHub Repository', - 'header.themeToggle': 'Toggle Theme', - 'header.status.connecting': 'Connecting...', - 'header.status.connected': 'Connected', - 'header.status.disconnected': 'Disconnected', - 'header.logout': 'Logout', - 'header.reload': 'Reload', - 'header.reload.confirm': 'Are you sure you want to reload the configuration? This will reload all configuration files.', - 'header.reload.requesting': 'Reloading configuration...', - 'header.reload.success': 'Configuration reloaded successfully', - 'header.reload.failed': 'Failed to reload configuration', - 'header.refresh': 'Reload', - 'header.restart': 'Restart', - 'header.restart.confirm': 'Are you sure you want to restart the service? The service will be briefly interrupted.', - 'header.restart.requesting': 'Requesting service restart...', - 'header.restart.success': 'Restart request sent, service will restart shortly', - 'header.restart.reconnecting': 'Reconnecting...', - 'header.restart.failed': 'Failed to restart service', - - // Navigation - 'nav.main': 'Main Navigation', - 'nav.dashboard': 'Dashboard', - 'nav.guide': 'User Guide', - 'nav.tutorial': 'Configuration Tutorial', - 'nav.config': 'Configuration', - 'nav.providers': 'Provider Pools', - 'nav.upload': 'Credential Files', - 'nav.usage': 'Usage Query', - 'nav.logs': 'Real-time Logs', - 'nav.plugins': 'Plugin Management', - 'nav.models': 'Available Models', - - // Dashboard - 'dashboard.title': 'System Overview', - 'dashboard.uptime': 'Uptime', - 'dashboard.systemInfo': 'System Information', - 'dashboard.version': 'Version', - 'dashboard.update.check': 'Check Update', - 'dashboard.update.checkTitle': 'Check for new version', - 'dashboard.update.perform': 'Update Now', - 'dashboard.update.performTitle': 'Update to latest version', - 'dashboard.update.checking': 'Checking...', - 'dashboard.update.upToDate': 'Up to date', - 'dashboard.update.hasUpdate': 'New version available: {version}', - 'dashboard.update.updating': 'Updating...', - 'dashboard.update.success': 'Update successful', - 'dashboard.update.needsRestart': 'Code updated, please click the "Restart" button in the top right corner for changes to take effect', - 'dashboard.update.restartTitle': 'Update Complete', - 'dashboard.update.restartMsg': 'Code has been updated to version {version}. Please click the "Restart" button in the top right corner for the new code to take effect.', - 'dashboard.update.failed': 'Update failed: {error}', - 'dashboard.update.confirmTitle': 'Confirm Update', - 'dashboard.update.confirmMsg': 'Are you sure you want to update to version {version}? Service might be briefly unavailable during update.', - 'dashboard.nodeVersion': 'Node.js Version', - 'dashboard.serverTime': 'Server Time', - 'dashboard.memoryUsage': 'Memory Usage', - 'dashboard.cpuUsage': 'CPU Usage', - 'dashboard.serviceMode': 'Service Mode', - 'dashboard.serviceMode.worker': 'Worker Mode', - 'dashboard.serviceMode.standalone': 'Standalone Mode', - 'dashboard.serviceMode.canRestart': 'Auto-restart supported', - 'dashboard.processPid': 'Process PID', - 'dashboard.platform': 'Platform', - 'dashboard.routing.title': 'Path Routing Examples', - 'dashboard.routing.description': 'Access different AI model providers through different path routes, supporting flexible model switching', - 'dashboard.routing.oauth': 'Limit Breakthrough', - 'dashboard.routing.official': 'Official/Third-party API', - 'dashboard.routing.experimental': 'Limit Breakthrough/Experimental', - 'dashboard.routing.free': 'Limit Breakthrough/Free', - 'dashboard.routing.endpoint': 'Endpoint Path:', - 'dashboard.routing.example': 'Usage Example', - 'dashboard.routing.exampleOpenAI': 'Usage Example (OpenAI):', - 'dashboard.routing.exampleClaude': 'Usage Example (Claude):', - 'dashboard.routing.openai': 'OpenAI Protocol', - 'dashboard.routing.claude': 'Claude Protocol', - 'dashboard.routing.tips': 'Usage Tips', - 'dashboard.routing.tip1': 'Instant Switch: Switch between different AI model providers by modifying the URL path', - 'dashboard.routing.tip2': 'Client Configuration: Set API endpoint to corresponding path in clients like Cherry-Studio, NextChat, Cline', - 'dashboard.routing.tip3': 'Cross-protocol Calls: Support calling Claude models with OpenAI protocol, or OpenAI models with Claude protocol', - 'dashboard.routing.nodeName.gemini': 'Gemini CLI OAuth', - 'dashboard.routing.nodeName.antigravity': 'Gemini Antigravity', - 'dashboard.routing.nodeName.claude': 'Claude Custom', - 'dashboard.routing.nodeName.kiro': 'Claude Kiro OAuth', - 'dashboard.routing.nodeName.openai': 'OpenAI Custom', - 'dashboard.routing.nodeName.qwen': 'Qwen OAuth', - 'dashboard.routing.nodeName.iflow': 'iFlow OAuth', - 'dashboard.routing.nodeName.codex': 'OpenAI Codex OAuth', - 'dashboard.routing.nodeName.grok': 'Grok Reverse', - 'dashboard.contact.title': 'Contact & Support', - 'dashboard.contact.wechat': 'Scan to Join Group', - 'dashboard.contact.wechatDesc': 'Add WeChat for more technical support and communication', - 'dashboard.contact.x': 'Follow on X.com', - 'dashboard.contact.xDesc': 'Follow us on X for latest updates', - 'dashboard.contact.sponsor': 'Scan to Support', - 'dashboard.contact.sponsorDesc': 'Your support is the driving force for the project\'s continuous development', - 'dashboard.contact.coffee': 'Buy me a coffee', - 'dashboard.contact.coffeeDesc': 'If you like this project, buy me a coffee!', - - // OAuth - 'oauth.modal.title': 'OAuth Authorization', - 'oauth.modal.provider': 'Provider:', - 'oauth.modal.requiredPort': 'Required Port:', - 'oauth.modal.portNote': 'Please ensure this port is accessible externally for receiving authorization callbacks', - 'oauth.modal.steps': 'Authorization Steps:', - 'oauth.modal.step1': 'Click the button below to open the authorization page in your browser', - 'oauth.modal.step2.qwen': 'After authorization, the system will automatically fetch the credentials file', - 'oauth.modal.step2.google': 'Log in with your Google account and authorize', - 'oauth.modal.step3': 'Credentials files can be viewed and managed in Upload Config', - 'oauth.modal.step4.qwen': 'Authorization valid for: {min} minutes', - 'oauth.modal.step4.google': 'After authorization, the credentials file will be saved automatically', - 'oauth.modal.urlLabel': 'Auth URL:', - 'oauth.modal.copyTitle': 'Copy', - 'oauth.modal.openInBrowser': 'Open in Browser', - 'oauth.manual.title': 'Auto-listener blocked?', - 'oauth.manual.desc': 'If the auth window shows "Cannot access" after redirect, please paste the Full URL from that window\'s address bar below:', - 'oauth.manual.placeholder': 'Paste callback URL (contains code=...)', - 'oauth.manual.submit': 'Submit', - 'oauth.success.msg': 'Authorization link copied to clipboard', - 'oauth.window.blocked': 'Auth window was blocked by the browser, please allow pop-ups', - 'oauth.window.opened': 'Auth window opened, please complete the process there', - 'oauth.processing': 'Completing authorization...', - 'oauth.invalid.url': 'This URL does not seem to contain a valid auth code', - 'oauth.error.format': 'Invalid URL format', - 'oauth.kiro.selectMethod': 'Select Authentication Method', - 'oauth.kiro.google': 'Google Account Login', - 'oauth.kiro.googleDesc': 'Login with Google account', - 'oauth.kiro.github': 'GitHub Account Login', - 'oauth.kiro.githubDesc': 'Login with GitHub account', - 'oauth.kiro.awsBuilder': 'AWS Builder ID', - 'oauth.kiro.awsBuilderDesc': 'Device code authorization via AWS Builder ID', - 'oauth.kiro.authMethodLabel': 'Auth Method:', - 'oauth.kiro.step1': 'Click the button below to open the authorization link in your browser', - 'oauth.kiro.step2': 'Log in with your {method} account', - 'oauth.kiro.step3': 'The page will close automatically after authorization', - 'oauth.kiro.step4': 'Refresh this page to view the credentials file', - 'oauth.kiro.batchImport': 'Batch Import Google/Github RefreshToken', - 'oauth.kiro.batchImportDesc': 'Batch import existing refresh tokens to generate credential files. This mode does not support AWS accounts.', - 'oauth.kiro.batchImportInstructions': 'Enter refreshTokens, one per line. The system will automatically refresh and generate credential files.', - 'oauth.kiro.awsImport': 'Import AWS Account', - 'oauth.kiro.awsImportDesc': 'Import credential files from AWS SSO cache directory. For AWS Builder ID mode.', - 'oauth.kiro.awsImportInstructions': 'Upload JSON files from AWS SSO cache directory. Must contain clientId, clientSecret, accessToken, and refreshToken. For AWS enterprise users, add the idcRegion field.', - 'oauth.kiro.awsModeFile': 'File Upload', - 'oauth.kiro.awsModeJson': 'Paste JSON', - 'oauth.kiro.awsUploadFiles': 'Upload Credential Files', - 'oauth.kiro.awsDragDrop': 'Drag and drop files here', - 'oauth.kiro.awsClickUpload': 'or click to select files', - 'oauth.kiro.awsFileHint': 'If one file doesn\'t contain all fields, you can upload multiple files to complete them', - 'oauth.kiro.awsSelectedFiles': 'Selected Files', - 'oauth.kiro.awsClearFiles': 'Clear All', - 'oauth.kiro.awsFileReplaced': 'Replaced file: {filename}', - 'oauth.kiro.awsJsonInput': 'Paste JSON Credentials', - 'oauth.kiro.awsJsonPlaceholderSimple': 'Paste JSON containing clientId, clientSecret, accessToken, refreshToken here...', - 'oauth.kiro.awsJsonExample': 'View JSON format example', - 'oauth.kiro.awsJsonHint': 'You can paste merged JSON directly, or copy content from AWS SSO cache files', - 'oauth.kiro.awsJsonParseError': 'Invalid JSON format', - 'oauth.kiro.awsParseError': 'Failed to parse file {filename}', - 'oauth.kiro.awsValidationSuccess': 'Validation passed! All required fields found', - 'oauth.kiro.awsValidationFailed': 'Validation failed! Required fields missing', - 'oauth.kiro.awsMissingFields': '{count} field(s) missing', - 'oauth.kiro.awsUploadMore': 'Please upload files containing the missing fields, or switch to JSON mode to complete manually', - 'oauth.kiro.awsPreviewJson': 'Merged Credentials Preview', - 'oauth.kiro.awsConfirmImport': 'Confirm Import', - 'oauth.kiro.awsNoCredentials': 'No credentials to import', - 'oauth.kiro.awsImporting': 'Importing...', - 'oauth.kiro.awsImportSuccess': 'AWS credentials imported successfully!', - 'oauth.kiro.awsImportFailed': 'AWS credentials import failed', - 'oauth.kiro.awsImportAllFailed': 'Import failed! {count} credentials failed to import', - 'oauth.kiro.refreshTokensLabel': 'RefreshToken List', - 'oauth.kiro.refreshTokensPlaceholder': 'Enter one refreshToken per line\nExample:\naorAxxxxxxxx\naorAyyyyyyyy\naorAzzzzzzzz', - 'oauth.kiro.tokenCount': 'Tokens to import:', - 'oauth.kiro.importing': 'Importing, please wait...', - 'oauth.kiro.importingProgress': 'Importing {current}/{total}...', - 'oauth.kiro.startImport': 'Start Import', - 'oauth.kiro.noTokens': 'Please enter at least one refreshToken', - 'oauth.kiro.importSuccess': 'Import successful! {count} credentials generated', - 'oauth.kiro.importAllFailed': 'Import failed! {count} tokens failed to refresh', - 'oauth.kiro.importPartial': 'Partial success: {success} succeeded, {failed} failed', - 'oauth.kiro.importError': 'Import error', - 'oauth.kiro.duplicateToken': 'Duplicate - this refreshToken already exists', - 'oauth.gemini.selectMethod': 'Select Auth Method', - 'oauth.gemini.oauth': 'OAuth Authorization', - 'oauth.gemini.oauthDesc': 'Standard OAuth via Google account', - 'oauth.gemini.batchImport': 'Batch Import', - 'oauth.gemini.batchImportDesc': 'Import multiple Token JSON objects', - 'oauth.gemini.tokensLabel': 'Token Data (JSON Array)', - 'oauth.gemini.tokensPlaceholder': 'Paste JSON array containing access_token and refresh_token...', - 'oauth.gemini.importInstructions': 'Paste Token JSON from browser or CLI. Supports single object or array.', - 'oauth.gemini.noTokens': 'Please enter valid Token data', - 'oauth.gemini.importing': 'Importing...', - 'oauth.gemini.importingProgress': 'Processing: {current} / {total}', - 'oauth.gemini.importSuccess': 'Successfully imported {count} credentials', - 'oauth.gemini.importAllFailed': 'Failed to import all {count} credentials', - 'oauth.gemini.importPartial': 'Partial success: {success} succeeded, {failed} failed', - 'oauth.gemini.importError': 'Import error', - 'oauth.gemini.tokenCount': 'Token Count', - 'oauth.gemini.startImport': 'Start Import', - 'oauth.gemini.jsonExample': 'View JSON Example', - 'oauth.gemini.jsonHint': 'Ensure JSON contains access_token and refresh_token', - 'oauth.codex.batchImport': 'Batch Import Codex Tokens', - 'oauth.codex.batchImportDesc': 'Import multiple Codex Token JSON objects', - 'oauth.codex.tokensLabel': 'Token Data (JSON Array)', - 'oauth.codex.tokensPlaceholder': 'Paste JSON array containing access_token and id_token...', - 'oauth.codex.importInstructions': 'Paste Codex Token JSON from browser or CLI. Supports single object or array.', - 'oauth.codex.noTokens': 'Please enter valid Token data', - 'oauth.codex.importing': 'Importing...', - 'oauth.codex.importingProgress': 'Processing: {current} / {total}', - 'oauth.codex.importSuccess': 'Successfully imported {count} credentials', - 'oauth.codex.importAllFailed': 'Failed to import all {count} credentials', - 'oauth.codex.importPartial': 'Partial success: {success} succeeded, {failed} failed', - 'oauth.codex.importError': 'Import error', - 'oauth.codex.tokenCount': 'Token Count', - 'oauth.codex.startImport': 'Start Import', - 'oauth.codex.jsonExample': 'View JSON Example', - 'oauth.codex.jsonHint': 'Ensure JSON contains access_token and id_token', - 'oauth.kiro.duplicateCredentials': 'This credential already exists, please do not import duplicates', - 'oauth.kiro.builderIDStartURL': 'Builder ID Start URL', - 'oauth.kiro.builderIDStartURLHint': 'If you use AWS IAM Identity Center, enter your Start URL', - 'oauth.iflow.step1': 'Click the button below to open the iFlow authorization page', - 'oauth.iflow.step2': 'Log in with your iFlow account and authorize', - 'oauth.iflow.step3': 'After authorization, the system will automatically fetch the API Key', - 'oauth.iflow.step4': 'Credentials files can be viewed and managed in Upload Config', - - // Config - 'config.title': 'Configuration Management', - 'config.apiKey': 'API Key', - 'config.apiKey.generate': 'Generate', - 'config.apiKey.generateTitle': 'Automatically generate API key', - 'config.apiKey.generated': 'New API key generated', - 'config.apiKeyPlaceholder': 'Please enter API key', - 'config.host': 'Listen Address', - 'config.hostPlaceholder': 'e.g.: 127.0.0.1', - 'config.port': 'Port', - 'config.portPlaceholder': '3000', - 'config.basic.title': 'Basic Settings', - 'config.governance.title': 'Service Governance', - 'config.oauth.title': 'OAuth & Tokens', - 'config.modelProvider': 'Model Provider', - 'config.modelProviderHelp': 'Check model providers to initialize on startup (must select at least one)', - 'config.modelProviderRequired': 'At least one model provider must be selected', - 'config.optional': '(Optional)', - 'config.gemini.baseUrl': 'Gemini Base URL', - 'config.gemini.baseUrlPlaceholder': 'https://cloudcode-pa.googleapis.com', - 'config.gemini.projectId': 'Project ID', - 'config.gemini.projectIdPlaceholder': 'Google Cloud Project ID', - 'config.gemini.oauthCreds': 'OAuth Credentials', - 'config.gemini.credsType.file': 'File Path', - 'config.gemini.credsType.base64': 'Base64 Encoded', - 'config.gemini.credsBase64': 'OAuth Credentials (Base64)', - 'config.gemini.credsBase64Placeholder': 'Please enter Base64 encoded OAuth credentials', - 'config.gemini.credsFilePath': 'OAuth Credentials File Path', - 'config.gemini.credsFilePathPlaceholder': 'e.g.: ~/.gemini/oauth_creds.json', - 'config.antigravity.dailyUrl': 'Daily Base URL', - 'config.antigravity.dailyUrlPlaceholder': 'https://daily-cloudcode-pa.sandbox.googleapis.com', - 'config.antigravity.autopushUrl': 'Autopush Base URL', - 'config.antigravity.autopushUrlPlaceholder': 'https://autopush-cloudcode-pa.sandbox.googleapis.com', - 'config.antigravity.credsFilePath': 'OAuth Credentials File Path', - 'config.antigravity.credsFilePathPlaceholder': 'e.g.: ~/.antigravity/oauth_creds.json', - 'config.antigravity.note': 'Antigravity uses Google OAuth authentication, requires credentials file path', - 'config.openai.apiKey': 'OpenAI API Key', - 'config.openai.apiKeyPlaceholder': 'sk-...', - 'config.openai.baseUrl': 'OpenAI Base URL', - 'config.openai.baseUrlPlaceholder': 'e.g.: https://api.openai.com/v1', - 'config.claude.apiKey': 'Claude API Key', - 'config.claude.apiKeyPlaceholder': 'sk-ant-...', - 'config.claude.baseUrl': 'Claude Base URL', - 'config.claude.baseUrlPlaceholder': 'e.g.: https://api.anthropic.com', - 'config.kiro.baseUrl': 'Base URL', - 'config.kiro.baseUrlPlaceholder': 'https://codewhisperer.{{region}}.amazonaws.com/generateAssistantResponse', - 'config.kiro.refreshUrl': 'Refresh URL', - 'config.kiro.refreshUrlPlaceholder': 'https://prod.{{region}}.auth.desktop.kiro.dev/refreshToken', - 'config.kiro.refreshIdcUrl': 'Refresh IDC URL', - 'config.kiro.refreshIdcUrlPlaceholder': 'https://oidc.{{region}}.amazonaws.com/token', - 'config.kiro.credsFilePath': 'OAuth Credentials File Path', - 'config.kiro.credsFilePathPlaceholder': 'e.g.: ~/.aws/sso/cache/kiro-auth-token.json', - 'config.kiro.note': 'When using AWS login method, ensure the authorization file contains clientId and clientSecret fields', - 'config.qwen.baseUrl': 'Qwen Base URL', - 'config.qwen.baseUrlPlaceholder': 'https://portal.qwen.ai/v1', - 'config.qwen.oauthBaseUrl': 'OAuth Base URL', - 'config.qwen.oauthBaseUrlPlaceholder': 'https://chat.qwen.ai', - 'config.qwen.credsFilePath': 'OAuth Credentials File Path', - 'config.qwen.credsFilePathPlaceholder': 'e.g.: ~/.qwen/oauth_creds.json', - 'config.advanced.title': 'Advanced Configuration', - 'config.advanced.systemPromptFile': 'System Prompt File Path', - 'config.advanced.systemPromptFilePlaceholder': 'e.g.: configs/input_system_prompt.txt', - 'config.advanced.systemPromptMode': 'System Prompt Mode', - 'config.advanced.systemPromptMode.append': 'Append', - 'config.advanced.systemPromptMode.overwrite': 'Overwrite', - 'config.advanced.promptLogBaseName': 'Prompt Log Base Name', - 'config.advanced.promptLogBaseNamePlaceholder': 'e.g.: prompt_log', - 'config.advanced.promptLogMode': 'Prompt Log Mode', - 'config.advanced.promptLogMode.none': 'None', - 'config.advanced.promptLogMode.console': 'Console', - 'config.advanced.promptLogMode.file': 'File', - 'config.advanced.maxRetries': 'Provider Max Retries', - 'config.advanced.baseDelay': 'Base Retry Delay (ms)', - 'config.advanced.credentialSwitchMaxRetries': 'Credential Switch Max Retries', - 'config.advanced.credentialSwitchMaxRetriesNote': 'Max retry count for switching credentials after auth errors (401/403), default 5', - 'config.advanced.warmupTarget': 'Warmup Target Nodes', - 'config.advanced.warmupTargetNote': 'Number of nodes to refresh on startup, default 0', - 'config.advanced.refreshConcurrencyPerProvider': 'Refresh Concurrency per Provider', - 'config.advanced.refreshConcurrencyPerProviderNote': 'Max parallel refresh tasks per provider, default 1', - 'config.advanced.cronInterval': 'OAuth Token Refresh Interval (minutes)', - 'config.advanced.cronEnabled': 'Enable OAuth Token Auto Refresh (requires restart)', - 'config.advanced.loginExpiry': 'Login Expiry (seconds)', - 'config.advanced.loginExpiryNote': 'Token validity period after management console login, default 3600 seconds (1 hour)', - 'config.advanced.poolFilePath': 'Provider Pool Config File Path (required)', - 'config.advanced.poolFilePathPlaceholder': 'Default: configs/provider_pools.json', - 'config.advanced.poolNote': 'If using default client authorization config, use an empty node', - 'config.advanced.maxErrorCount': 'Provider Max Error Count', - 'config.advanced.maxErrorCountPlaceholder': 'Default: 10', - 'config.advanced.maxErrorCountNote': 'Provider will be marked as unhealthy after consecutive errors reach this count, default is 10', - 'config.advanced.poolSizeLimit': 'Pool Size Limit', - 'config.advanced.poolSizeLimitPlaceholder': 'Default: 0 (no limit)', - 'config.advanced.poolSizeLimitNote': 'Maximum number of healthy credentials per provider type for rotation. 0 means no limit, use all healthy credentials', - 'config.advanced.credentialSwitchMaxRetries': 'Credential Switch Max Retries', - 'config.advanced.credentialSwitchMaxRetriesNote': 'Maximum retries for switching credentials after authentication errors (401/403), default is 5', - 'config.advanced.fallbackChain': 'Cross-Type Fallback Chain Config', - 'config.advanced.fallbackChainPlaceholder': 'Example:\n{\n "gemini-cli-oauth": ["gemini-antigravity"],\n "gemini-antigravity": ["gemini-cli-oauth"],\n "claude-kiro-oauth": ["claude-custom"]\n}', - 'config.advanced.fallbackChainNote': 'When all accounts of a Provider Type are unhealthy, automatically switch to configured Fallback types. JSON format, key is primary type, value is Fallback type array (sorted by priority)', - 'config.advanced.fallbackChainInvalid': 'Invalid Fallback chain config format, please enter valid JSON', - 'config.advanced.modelFallbackMapping': 'Cross-Protocol Model Fallback Mapping', - 'config.advanced.modelFallbackMappingPlaceholder': 'Example:\n{\n "gemini-claude-opus-4-5-thinking": {\n "targetProviderType": "claude-kiro-oauth",\n "targetModel": "claude-opus-4-5"\n }\n}', - 'config.advanced.modelFallbackMappingNote': 'When the primary Provider is unavailable, map to other protocol Providers and models by model name. Priority is lower than the Fallback Chain Config above. JSON format.', - 'config.advanced.modelFallbackMappingInvalid': 'Invalid Model Fallback mapping config format, please enter valid JSON', - 'config.advanced.systemPrompt': 'System Prompt', - 'config.advanced.systemPromptPlaceholder': 'Enter system prompt...', - 'config.advanced.adminPassword': 'Admin Password', - 'config.advanced.adminPasswordPlaceholder': 'Set admin password (leave empty to keep unchanged)', - 'config.advanced.adminPasswordNote': 'Used to protect management console access, requires re-login after modification', - 'config.proxy.title': 'Proxy Settings', - 'config.proxy.url': 'Proxy URL', - 'config.proxy.urlPlaceholder': 'e.g.: http://127.0.0.1:7890 or socks5://127.0.0.1:1080', - 'config.proxy.urlNote': 'Supports HTTP, HTTPS and SOCKS5 proxies. Leave empty to disable proxy', - 'config.proxy.enabledProviders': 'Providers Using Proxy', - 'config.proxy.enabledProvidersNote': 'Select providers that should use the proxy. Unselected providers will connect directly', - 'config.proxy.tlsSidecarEnabled': 'TLS Fingerprint Spoofing (uTLS Sidecar)', - 'config.proxy.tlsSidecarPort': 'Sidecar Port', - 'config.proxy.tlsSidecarProxyUrl': 'Sidecar Upstream Proxy', - 'config.proxy.tlsSidecarEnabledProviders': 'Providers Using TLS Sidecar', - 'config.proxy.tlsSidecarNote': 'When enabled, requests for selected providers are routed through Go uTLS sidecar for perfect Chrome TLS/H2 fingerprint to bypass Cloudflare (requires restart)', - 'config.log.title': 'Log Settings', - 'config.log.enabled': 'Enable Logging', - 'config.log.outputMode': 'Log Output Mode', - 'config.log.outputMode.all': 'All (Console + File)', - 'config.log.outputMode.console': 'Console Only', - 'config.log.outputMode.file': 'File Only', - 'config.log.outputMode.none': 'Disabled', - 'config.log.level': 'Log Level', - 'config.log.level.debug': 'Debug', - 'config.log.level.info': 'Info', - 'config.log.level.warn': 'Warning', - 'config.log.level.error': 'Error', - 'config.log.dir': 'Log Directory', - 'config.log.dirPlaceholder': 'e.g.: logs', - 'config.log.includeRequestId': 'Include Request ID', - 'config.log.includeTimestamp': 'Include Timestamp', - 'config.log.maxFileSize': 'Max File Size (bytes)', - 'config.log.maxFileSizeNote': 'Default 10MB (10485760 bytes)', - 'config.log.maxFiles': 'Max Files to Keep', - 'config.log.maxFilesNote': 'Number of recent log files to keep', - 'config.save': 'Save Configuration', - 'config.reset': 'Reset', - 'config.placeholder.nodeName': 'e.g.: My Node 1', - 'config.placeholder.model': 'e.g.: gpt-3.5-turbo', - - // Upload Config - 'upload.title': 'Credential Files Management', - 'upload.search': 'Search Config', - 'upload.searchPlaceholder': 'Enter filename', - 'upload.providerFilter': 'Provider Type', - 'upload.providerFilter.all': 'All Providers', - 'upload.providerFilter.kiro': 'Kiro OAuth', - 'upload.providerFilter.gemini': 'Gemini OAuth', - 'upload.providerFilter.qwen': 'Qwen OAuth', - 'upload.providerFilter.antigravity': 'Antigravity', - 'upload.providerFilter.codex': 'Codex OAuth', - 'upload.providerFilter.iflow': 'iFlow OAuth', - 'upload.providerFilter.grok': 'Grok Reverse', - 'upload.providerFilter.other': 'Other/Unknown', - 'upload.statusFilter': 'Association Status', - 'upload.statusFilter.all': 'All Status', - 'upload.statusFilter.used': 'Associated', - 'upload.statusFilter.unused': 'Not Associated', - 'upload.refresh': 'Refresh', - 'upload.downloadAll': 'Download All (ZIP)', - 'upload.listTitle': 'Configuration File List', - 'upload.count': 'Total {count} config files', - 'upload.usedCount': 'Associated: {count}', - 'upload.unusedCount': 'Not Associated: {count}', - 'upload.batchLink': 'Auto Link OAuth', - 'upload.noConfigs': 'No matching configuration files found', - 'upload.detail.path': 'File Path', - 'upload.detail.size': 'File Size', - 'upload.detail.modified': 'Last Modified', - 'upload.detail.status': 'Status', - 'upload.action.view': 'View', - 'upload.action.download': 'Download', - 'upload.action.delete': 'Delete', - 'upload.usage.title': 'Association Details ({type})', - 'upload.usage.mainConfig': 'Main Config', - 'upload.usage.providerPool': 'Provider Pool', - 'upload.usage.multiple': 'Multiple Purposes', - 'upload.delete.confirmTitle': 'Delete Config File', - 'upload.delete.confirmTitleUsed': 'Delete Associated Config', - 'upload.delete.warningUsedTitle': '⚠️ This config is currently in use', - 'upload.delete.warningUsedDesc': 'Deleting an associated config file may affect system stability. Please ensure you understand the consequences.', - 'upload.delete.warningUnusedTitle': '🗑️ Confirm deletion', - 'upload.delete.warningUnusedDesc': 'This operation will permanently delete the config file and cannot be undone.', - 'upload.delete.fileName': 'File Name:', - 'upload.delete.usageAlertTitle': 'Association Details', - 'upload.delete.usageAlertDesc': 'This file is being used by the system. Deletion may cause:', - 'upload.delete.usageAlertItem1': 'Related AI services to stop working', - 'upload.delete.usageAlertItem2': 'Settings in Config Management to become invalid', - 'upload.delete.usageAlertItem3': 'Provider pool configurations to be lost', - 'upload.delete.usageAlertAdvice': 'Advice: Please remove file references in Config Management before deleting.', - 'upload.delete.forceDelete': 'Force Delete', - 'upload.delete.confirmDelete': 'Confirm Delete', - 'upload.batchLink.confirm': 'Are you sure you want to link {count} config files?\n\n{summary}', - 'upload.refresh.success': 'Refresh successful', - 'upload.action.view.failed': 'View failed', - 'upload.action.delete.failed': 'Delete failed', - 'upload.action.quickLink': 'Quick Link', - 'upload.config.notExist': 'Configuration file does not exist', - 'upload.link.identifying': 'Identifying provider type...', - 'upload.link.failed.identify': 'Unable to identify provider type for the config file', - 'upload.link.processing': 'Linking configuration to {name}...', - 'upload.link.success': 'Configuration linked successfully', - 'upload.link.failed': 'Link failed', - 'upload.batchLink.none': 'No configuration files to link', - 'upload.batchLink.processing': 'Batch linking {count} configurations...', - 'upload.batchLink.success': 'Successfully linked {count} configurations', - 'upload.batchLink.partial': 'Linking completed: {success} succeeded, {fail} failed', - 'upload.deleteUnbound': 'Delete Unbound', - 'upload.deleteUnbound.none': 'No unbound config files to delete (only files in configs/subdirectory/ are deleted)', - 'upload.deleteUnbound.confirm': 'Are you sure you want to delete {count} unbound config files?\n\nNote: Only unbound files in configs/subdirectory/ will be deleted. Files directly in configs/ root will not be deleted.\n\nThis action cannot be undone!', - 'upload.deleteUnbound.processing': 'Deleting unbound config files...', - 'upload.deleteUnbound.success': 'Successfully deleted {count} unbound config files', - 'upload.deleteUnbound.partial': 'Deletion completed: {success} succeeded, {fail} failed', - 'upload.deleteUnbound.failed': 'Failed to delete unbound configs', - - // Providers - 'providers.title': 'Provider Pool Management', - 'providers.note': 'If using default client authorization config, use an empty node', - 'providers.activeConnections': 'Active Connections', - 'providers.activeProviders': 'Active Providers', - 'providers.healthyProviders': 'Healthy Providers', - 'providers.status.healthy': '{healthy}/{total} Available', - 'providers.status.empty': '0/0 Nodes', - 'providers.stat.totalAccounts': 'Total Accounts', - 'providers.stat.healthyAccounts': 'Healthy Accounts', - 'providers.stat.usageCount': 'Usage Count', - 'providers.stat.errorCount': 'Error Count', - 'providers.auth.generate': 'Gen Auth', - 'providers.auth.importToken': 'Import Token', - - // Modal Provider Manager - 'modal.provider.manage': 'Manage {type} Provider Config', - 'modal.provider.totalAccounts': 'Total Accounts:', - 'modal.provider.healthyAccounts': 'Healthy Accounts:', - 'modal.provider.add': 'Add Provider', - 'modal.provider.resetHealth': 'Reset Health', - 'modal.provider.healthCheck': 'Check Unhealthy', - 'modal.provider.resetHealthConfirm': 'Are you sure you want to reset all {type} nodes to healthy status?\n\nThis will clear error counts and timestamps for all nodes.', - 'modal.provider.healthCheckConfirm': 'Are you sure you want to perform a health check on unhealthy {type} nodes?\n\nThis will send test requests to unhealthy nodes to verify availability.', - 'modal.provider.deleteConfirm': 'Are you sure you want to delete this provider config? This cannot be undone.', - 'modal.provider.disableConfirm': 'Are you sure you want to disable this provider? It will no longer be selected for use.', - 'modal.provider.enableConfirm': 'Are you sure you want to enable this provider?', - 'modal.provider.edit': 'Edit', - 'modal.provider.delete': 'Delete', - 'modal.provider.save': 'Save', - 'modal.provider.cancel': 'Cancel', - 'modal.provider.status.healthy': 'Normal', - 'modal.provider.status.unhealthy': 'Abnormal', - 'modal.provider.status.disabled': 'Disabled', - 'modal.provider.status.enabled': 'Enabled', - 'modal.provider.lastError': 'Last Error:', - 'modal.provider.lastUsed': 'Last Used:', - 'modal.provider.lastCheck': 'Last Check:', - 'modal.provider.checkModel': 'Check Model:', - 'modal.provider.concurrencyLimit': 'Concurrency Limit', - 'modal.provider.queueLimit': 'Queue Limit', - 'modal.provider.usageCount': 'Usage Count:', - 'modal.provider.errorCount': 'Error Count:', - 'modal.provider.neverUsed': 'Never Used', - 'modal.provider.neverChecked': 'Never Checked', - 'modal.provider.noModels': 'No models available for this provider type', - 'modal.provider.loadingModels': 'Loading models...', - 'modal.provider.unsupportedModels': 'Unsupported Models', - 'modal.provider.unsupportedModelsHelp': 'Select models not supported by this provider; they will be excluded automatically', - 'modal.provider.addTitle': 'Add New Provider Config', - 'modal.provider.customName': 'Custom Name', - 'modal.provider.checkModelName': 'Check Model Name', - 'modal.provider.healthCheckLabel': 'Health Check', - 'modal.provider.enabled': 'Enabled', - 'modal.provider.disabled': 'Disabled', - 'modal.provider.noProviderType': 'Unsupported provider type', - 'modal.provider.refreshUuid': 'Refresh uuid', - 'modal.provider.refreshUuidConfirm': 'Are you sure you want to refresh the uuid for this provider?\n\nOld uuid: {oldUuid}\n\nA new uuid will be generated. Make sure no other systems depend on this uuid.', - 'modal.provider.refreshUuid.success': 'uuid refreshed successfully\n\nOld uuid: {oldUuid}\nNew uuid: {newUuid}', - 'modal.provider.refreshUuid.failed': 'Failed to refresh uuid', - 'modal.provider.field.projectId': 'Project ID', - 'modal.provider.field.oauthPath': 'OAuth Credentials File Path', - 'modal.provider.field.baseUrl': 'Base URL', - 'modal.provider.field.refreshUrl': 'Refresh URL', - 'modal.provider.field.refreshIdcUrl': 'Refresh IDC URL', - 'modal.provider.field.oauthBaseUrl': 'OAuth Base URL', - 'modal.provider.field.dailyBaseUrl': 'Daily Base URL', - 'modal.provider.field.autopushBaseUrl': 'Autopush Base URL', - 'modal.provider.field.headerName': 'Header Name', - 'modal.provider.field.headerPrefix': 'Header Value Prefix', - 'modal.provider.field.useSystemProxy': 'Use System Proxy', - 'modal.provider.field.ssoToken': 'SSO Token (Cookie)', - 'modal.provider.field.cfClearance': 'CF Clearance (Cookie)', - 'modal.provider.field.userAgent': 'User-Agent', - 'modal.provider.field.iflowBaseUrl': 'iFlow Base URL', - 'modal.provider.field.grokBaseUrl': 'Grok Base URL', - 'modal.provider.field.codexBaseUrl': 'Codex Base URL', - 'modal.provider.field.apiKey': 'API Key', - 'modal.provider.field.apiKey.placeholder': 'Please enter API Key', - 'modal.provider.field.projectId.placeholder': 'Google Cloud Project ID (Leave blank for discovery)', - 'modal.provider.field.projectId.optional.placeholder': 'Google Cloud Project ID (Leave blank for discovery)', - 'modal.provider.field.oauthPath.gemini.placeholder': 'e.g.: ~/.gemini/oauth_creds.json', - 'modal.provider.field.oauthPath.kiro.placeholder': 'e.g.: ~/.aws/sso/cache/kiro-auth-token.json', - 'modal.provider.field.oauthPath.qwen.placeholder': 'e.g.: ~/.qwen/oauth_creds.json', - 'modal.provider.field.oauthPath.antigravity.placeholder': 'e.g.: ~/.antigravity/oauth_creds.json', - 'modal.provider.field.oauthPath.iflow.placeholder': 'e.g.: configs/iflow/oauth_creds.json', - 'modal.provider.field.oauthPath.codex.placeholder': 'e.g.: configs/codex/oauth_creds.json', - 'modal.provider.field.email': 'Email', - 'modal.provider.field.email.placeholder': 'your-email@example.com', - - 'modal.provider.load.failed': 'Failed to load provider details', - 'modal.provider.auth.initializing': 'Initializing credential generation...', - 'modal.provider.auth.success': 'Credentials generated and path auto-filled', - 'modal.provider.auth.window': 'Please complete authorization in the opened window', - 'modal.provider.auth.failed': 'Failed to initialize credential generation', - 'modal.provider.save.success': 'Save successful', - 'modal.provider.save.failed': 'Save failed', - 'modal.provider.delete.success': 'Delete successful', - 'modal.provider.delete.failed': 'Delete failed', - 'modal.provider.add.success': 'Add successful', - 'modal.provider.add.failed': 'Add failed', - 'modal.provider.resetHealth.success': 'Successfully reset health status for {count} nodes', - 'modal.provider.resetHealth.failed': 'Failed to reset health status', - 'modal.provider.deleteUnhealthy': 'Delete unhealthy nodes', - 'modal.provider.deleteUnhealthyBtn': 'Delete Unhealthy', - 'modal.provider.deleteUnhealthyConfirm': 'Delete {count} unhealthy node(s)? This cannot be undone.', - 'modal.provider.deleteUnhealthy.noUnhealthy': 'No unhealthy nodes', - 'modal.provider.deleteUnhealthy.deleting': 'Deleting...', - 'modal.provider.deleteUnhealthy.success': 'Deleted {count} node(s)', - 'modal.provider.deleteUnhealthy.failed': 'Delete failed', - 'modal.provider.healthCheck.complete': 'Health check complete: {success} became healthy', - 'modal.provider.healthCheck.abnormal': ', {fail} abnormal', - 'modal.provider.healthCheck.skipped': ', {skipped} skipped (disabled)', - 'modal.provider.refreshUnhealthyUuids': 'Refresh unhealthy UUIDs', - 'modal.provider.refreshUnhealthyUuidsBtn': 'Refresh UUIDs', - 'modal.provider.refreshUnhealthyUuidsConfirm': 'Refresh UUIDs for {count} unhealthy node(s)?', - 'modal.provider.refreshUnhealthyUuids.noUnhealthy': 'No unhealthy nodes', - 'modal.provider.refreshUnhealthyUuids.refreshing': 'Refreshing...', - 'modal.provider.refreshUnhealthyUuids.success': 'Refreshed {count} UUID(s)', - 'modal.provider.refreshUnhealthyUuids.failed': 'Refresh failed', - 'modal.provider.kiroAuthHint': 'When using AWS Builder ID login, clientId and clientSecret fields are required, which can be found in another JSON file in the same folder', - - // Pagination - 'pagination.showing': 'Showing {start}-{end} of {total}', - 'pagination.jumpTo': 'Jump to', - 'pagination.page': 'Page', - - // Usage - 'usage.title': 'Usage Query', - 'usage.refresh': 'Refresh Usage', - 'usage.serverTime': 'Server Time', - 'usage.lastUpdate': 'Last Update: {time}', - 'usage.lastUpdateCache': 'Cache Time: {time}', - 'usage.supportedProvidersPrefix': 'Providers supporting usage query:', - 'usage.loading': 'Loading usage data...', - 'usage.empty': 'Click "Refresh Usage" button to get authorization file usage information', - 'usage.noData': 'No usage data available', - 'usage.noInstances': 'No initialized service instances', - 'usage.group.instances': '{count} instances', - 'usage.group.success': '{count}/{total} Success', - 'usage.card.status.disabled': 'Disabled', - 'usage.card.status.healthy': 'Healthy', - 'usage.card.status.unhealthy': 'Abnormal', - 'usage.card.totalUsage': 'Total Usage', - 'usage.card.resetAt': 'Resets at {time}', - 'usage.card.downloadConfig': 'Download Config', - 'usage.card.downloadSuccess': 'Config file downloaded successfully', - 'usage.card.downloadFailed': 'Failed to download config file', - 'usage.card.freeTrial': 'Free Trial', - 'usage.card.bonus': 'Bonus', - 'usage.card.expires': 'Expires: {time}', - 'usage.doubleClickToRefresh': 'Double click to refresh this provider', - 'usage.refreshingProvider': 'Refreshing {name} usage...', - 'usage.group.expandAll': 'Expand All Cards', - 'usage.group.collapseAll': 'Collapse All Cards', - 'usage.failedToLoad': 'Failed to load', - 'usage.loadFailed': 'Failed to load supported providers', - 'usage.resetInfo': 'Resets in {time}', - 'usage.weeklyLimit': 'Weekly Limit', - 'usage.time.days': '{days}d {hours}h', - 'usage.time.hours': '{hours}h {minutes}m', - 'usage.time.minutes': '{minutes}m', - 'usage.time.soon': 'Soon', - - // Logs - 'logs.title': 'Real-time Logs', - 'logs.clear': 'Clear Logs', - 'logs.download': 'Download Logs', - 'logs.autoScroll': 'Auto Scroll', - 'logs.autoScroll.on': 'Auto Scroll: On', - 'logs.autoScroll.off': 'Auto Scroll: Off', - 'logs.clear.confirm.title': 'Warning', - 'logs.clear.confirm.msg': 'This action will clear today\'s local log file!\n\n• Real-time logs on frontend will be cleared\n• Today\'s log file on server will also be cleared\n• This action cannot be undone\n\nAre you sure you want to continue?', - 'logs.clear.success.title': 'Success', - 'logs.clear.success.msg': 'Both real-time logs and today\'s log file on server have been cleared', - 'logs.clear.failed': 'Failed to clear logs', - - // Plugins - 'plugins.title': 'Plugin Management', - 'plugins.description': 'The plugin system allows you to extend system functionality. Enabling or disabling plugins requires a service restart to take effect.', - 'plugins.stats.total': 'Total Plugins', - 'plugins.stats.enabled': 'Enabled', - 'plugins.stats.disabled': 'Disabled', - 'plugins.refresh': 'Refresh Plugins', - 'plugins.loading': 'Loading plugins...', - 'plugins.empty': 'No installed plugins', - 'plugins.noDescription': 'No description', - 'plugins.status.enabled': 'Enabled', - 'plugins.status.disabled': 'Disabled', - 'plugins.badge.middleware.title': 'Contains Middleware', - 'plugins.badge.routes.title': 'Contains Routes', - 'plugins.badge.hooks.title': 'Contains Hooks', - 'plugins.toggle.success': 'Plugin {name} {status}', - 'plugins.toggle.failed': 'Failed to toggle plugin status', - 'plugins.load.failed': 'Failed to load plugins list', - 'plugins.restart.required': 'Changes saved', - - // Models - 'models.title': 'Available Models', - 'models.note': 'Click model name to copy to clipboard', - 'models.empty': 'No models available', - 'models.loadError': 'Failed to load models', - 'models.copied': 'Copied', - 'models.clickToCopy': 'Click to copy', - - // Guide - 'guide.title': 'User Guide', - 'guide.intro.title': 'Introduction', - 'guide.intro.desc': 'AIClient2API is an API proxy service that breaks client restrictions, converting free large models like Gemini, Antigravity, Qwen Code, and Kiro into standard OpenAI-compatible interfaces that any application can call.', - 'guide.intro.feature1.title': 'Unified Access', - 'guide.intro.feature1.desc': 'Access multiple large models with a single configuration through standard OpenAI-compatible protocol', - 'guide.intro.feature2.title': 'Break Limits', - 'guide.intro.feature2.desc': 'Effectively bypass free API rate and quota limits using OAuth authorization', - 'guide.intro.feature3.title': 'Protocol Conversion', - 'guide.intro.feature3.desc': 'Support intelligent conversion between OpenAI, Claude, and Gemini protocols', - 'guide.intro.feature4.title': 'Account Pool', - 'guide.intro.feature4.desc': 'Support multi-account polling, automatic failover, and configuration degradation', - 'guide.providers.title': 'Supported Model Providers', - 'guide.providers.badge.oauth': 'OAuth', - 'guide.providers.badge.experimental': 'Experimental', - 'guide.providers.badge.free': 'Free', - 'guide.providers.badge.official': 'Official API', - 'guide.providers.gemini.desc': 'Access Gemini models via Google OAuth, supporting gemini-3.1-pro-preview and more', - 'guide.providers.antigravity.desc': 'Access Gemini 3 Pro, Claude Sonnet 4.5 via Google internal interface', - 'guide.providers.kiro.desc': 'Free access to Claude Opus 4.5, Claude Sonnet 4.5 via Kiro client', - 'guide.providers.qwen.desc': 'Access Qwen3 Coder Plus via Alibaba Cloud OAuth', - 'guide.providers.claude.desc': 'Access Claude models via official API or third-party proxy', - 'guide.providers.openai.desc': 'Access GPT models via official API or third-party proxy', - 'guide.providers.iflow.desc': 'Access Qwen, Kimi, DeepSeek, GLM via iFlow OAuth', - 'guide.providers.grok.desc': 'Access Grok-3, Grok-4 models via Grok reverse interface, supports image and video generation', - 'guide.client.title': 'Client Configuration Guide', - 'guide.client.desc': 'Here are configuration methods for common AI clients. Set the API endpoint to this service address:', - 'guide.client.cherry.step1': 'Open Settings → Model Providers', - 'guide.client.cherry.step2': 'Add custom provider', - 'guide.client.cherry.step3': 'Set API URL to: http://localhost:3000/{provider}', - 'guide.client.cherry.step4': 'Enter API Key (REQUIRED_API_KEY from config)', - 'guide.client.cline.step1': 'Open VS Code Settings', - 'guide.client.cline.step2': 'Search for Cline or Continue configuration', - 'guide.client.cline.step3': 'Set API Base URL to: http://localhost:3000/{provider}/v1', - 'guide.client.cline.step4': 'Enter API Key and model name', - 'guide.client.note': 'Tip: Replace {provider} with the actual provider path, such as gemini-cli-oauth, claude-kiro-oauth, etc. See the routing examples on the dashboard for full paths.', - 'guide.faq.title': 'FAQ', - 'guide.faq.q1': 'Q: What to do if request returns 404 error?', - 'guide.faq.a1': 'A: Check if the API path is correct. Some clients automatically append paths to Base URL, causing duplication. Check the actual request URL in the console and remove redundant path parts.', - 'guide.faq.q2': 'Q: What to do if request returns 429 error?', - 'guide.faq.a2': 'A: 429 means request rate is too high. Configure multiple accounts in the provider pool with polling; or configure Fallback chain for cross-type degradation.', - 'guide.faq.q3': 'Q: What to do if OAuth authorization fails?', - 'guide.faq.a3': 'A: Ensure OAuth callback ports are accessible (Gemini: 8085, Antigravity: 8086, Kiro: 19876-19880). Docker users need to map these ports correctly.', - 'guide.faq.q4': 'Q: How to view available models?', - 'guide.faq.a4': 'A: Click "Available Models" in the sidebar to view all models supported by configured providers. Click model name to copy.', - 'guide.faq.q5': 'Q: What to do if streaming response is interrupted?', - 'guide.faq.a5': 'A: Check network stability, increase client request timeout. If using proxy, ensure it supports long connections.', - 'guide.faq.q6': 'Q: What to do if request returns "No available and healthy providers" error?', - 'guide.faq.a6': 'A: This means all providers of the corresponding type are unavailable. Check provider health status in "Provider Pools" page, confirm OAuth credentials are not expired, or configure Fallback chain for automatic switching to backup providers.', - 'guide.faq.q7': 'Q: What to do if request returns 403 Forbidden error?', - 'guide.faq.a7': 'A: 403 means access denied. First check node status in "Provider Pools" page. If node health check is normal, you can ignore this error. Other possible causes include: insufficient account permissions, limited API Key permissions, regional access restrictions, expired credentials, etc.', - - // Guide - Flow - 'guide.flow.title': 'Operation Flowchart', - 'guide.flow.step1.title': 'Configuration', - 'guide.flow.step1.desc': 'Set basic parameters in "Configuration" page', - 'guide.flow.step1.item1': 'Set API Key', - 'guide.flow.step1.item2': 'Select model providers to initialize on startup', - 'guide.flow.step1.item3': 'Configure advanced options', - 'guide.flow.step2.title': 'Generate Auth', - 'guide.flow.step2.desc': 'Generate OAuth authorization in "Provider Pools" page', - 'guide.flow.step2.method1': 'Method 1: OAuth Authorization', - 'guide.flow.step2.method1.item1': 'Click "Generate Auth" button', - 'guide.flow.step2.method1.item2': 'Complete OAuth login in popup', - 'guide.flow.step2.method1.item3': 'Credentials saved automatically', - 'guide.flow.step2.or': 'or', - 'guide.flow.step2.method2': 'Method 2: Manual Upload', - 'guide.flow.step2.method2.item1': 'Add new provider node', - 'guide.flow.step2.method2.item2': 'Upload existing authorization file', - 'guide.flow.step2.method2.item3': 'Manually link credential path', - 'guide.flow.step2.method3': 'Method 3: Provider API Integration', - 'guide.flow.step2.method3.item1': 'Add node for corresponding protocol in "Provider Pools"', - 'guide.flow.step2.method3.item2': 'Enter API Key and Endpoint (Base URL)', - 'guide.flow.step2.method3.item3': 'No OAuth credential file generation/upload required', - 'guide.flow.step3.title': 'Manage Credentials', - 'guide.flow.step3.desc': 'View and manage credentials in "Credential Files" (Ignore for non-OAuth providers)', - 'guide.flow.step3.item1': 'View generated credential files', - 'guide.flow.step3.item2': 'Auto-link to provider pool', - 'guide.flow.step3.item3': 'Delete invalid credentials', - 'guide.flow.step4.title': 'Start Using', - 'guide.flow.step4.desc': 'View routing examples in "Dashboard" and start calling API', - 'guide.flow.step4.item1': 'View routing call examples', - 'guide.flow.step4.item2': 'Copy API endpoint address', - 'guide.flow.step4.item3': 'Configure in client application', - - // Tutorial - 'tutorial.title': 'Configuration Tutorial', - 'tutorial.config.title': 'Configuration Files', - 'tutorial.config.desc': 'All configuration files are stored in the configs/ directory. Main configuration files include:', - 'tutorial.config.badge.required': 'Required', - 'tutorial.config.badge.optional': 'Optional', - 'tutorial.config.file.config': 'Main config file with API Key, port, model provider settings (Automatically created after saving configuration management)', - 'tutorial.config.file.pools': 'Provider pool config for multi-account polling and failover (Automatically created after saving nodes)', - 'tutorial.config.file.plugins': 'Plugin config for enabling/disabling system plugins', - 'tutorial.config.file.pwd': 'Admin password file, default password is admin123', - 'tutorial.main.title': 'Main Config Details (config.json)', - 'tutorial.main.table.param': 'Parameter', - 'tutorial.main.table.type': 'Type', - 'tutorial.main.table.default': 'Default', - 'tutorial.main.table.desc': 'Description', - 'tutorial.main.basic.title': 'Basic Configuration', - 'tutorial.main.basic.apikey': 'API Key required to access this service', - 'tutorial.main.basic.port': 'Service listening port', - 'tutorial.main.basic.host': 'Service listening address', - 'tutorial.main.basic.provider': 'Default model provider', - 'tutorial.main.prompt.title': 'System Prompt Configuration', - 'tutorial.main.prompt.file': 'System prompt file path', - 'tutorial.main.prompt.mode': 'System prompt mode: overwrite or append', - 'tutorial.main.retry.title': 'Retry Configuration', - 'tutorial.main.retry.max': 'Provider max retry count', - 'tutorial.main.retry.delay': 'Base retry delay (milliseconds)', - 'tutorial.main.retry.credentialSwitch': 'Max retries for switching bad credentials', - 'tutorial.main.retry.error': 'Max provider error count before marking unhealthy', - 'tutorial.main.governance.warmup': 'Number of nodes to auto-refresh on startup', - 'tutorial.main.governance.concurrency': 'Maximum parallel refresh tasks per provider', - 'tutorial.main.governance.poolLimit': 'Maximum number of healthy credentials per provider type for rotation', - 'tutorial.main.example.title': 'Configuration Example', - 'tutorial.pool.title': 'Provider Pool Config (provider_pools.json)', - 'tutorial.pool.desc': 'Provider pool configures multiple accounts for load balancing and failover. Each provider type can have multiple account nodes.', - 'tutorial.pool.node.title': 'Node Configuration Parameters', - 'tutorial.pool.node.uuid': 'Unique node identifier, auto-generated', - 'tutorial.pool.node.name': 'Custom node name', - 'tutorial.pool.node.oauth': 'OAuth credentials file path', - 'tutorial.pool.node.health': 'Enable health check', - 'tutorial.pool.node.model': 'Model used for health check', - 'tutorial.pool.node.unsupported': 'List of unsupported models for this node', - 'tutorial.pool.node.disabled': 'Whether to disable this node', - 'tutorial.pool.example.title': 'Configuration Example', - 'tutorial.fallback.title': 'Fallback Configuration', - 'tutorial.fallback.desc': 'When all accounts of a provider type are unavailable, automatically switch to configured backup providers.', - 'tutorial.fallback.chain.title': 'Cross-Type Fallback Chain', - 'tutorial.fallback.chain.desc': 'Configure providerFallbackChain in config.json to specify backup types for each provider:', - 'tutorial.fallback.model.title': 'Cross-Protocol Model Mapping', - 'tutorial.fallback.model.desc': 'When primary provider is unavailable, map specific models to other protocol providers:', - 'tutorial.proxy.title': 'Proxy Configuration', - 'tutorial.proxy.desc': 'Support proxy configuration for specific providers in restricted network environments.', - 'tutorial.proxy.config.title': 'Proxy Configuration Parameters', - 'tutorial.proxy.url': 'Proxy URL, supports HTTP, HTTPS, SOCKS5', - 'tutorial.proxy.providers': 'List of providers using proxy', - 'tutorial.proxy.example.title': 'Configuration Example', - 'tutorial.proxy.note': 'Supported proxy types: HTTP (http://), HTTPS (https://), SOCKS5 (socks5://)', - 'tutorial.oauth.title': 'OAuth Configuration', - 'tutorial.oauth.desc': 'Default storage locations for OAuth credentials of each provider:', - 'tutorial.oauth.note': 'Recommended: Use the "Generate Auth" button in Provider Pool Management page for visual authorization. Credentials will be saved automatically.', - 'tutorial.log.title': 'Log Configuration', - 'tutorial.log.prompt.title': 'Prompt Log Configuration', - 'tutorial.log.mode': 'Log mode: none, console, or file', - 'tutorial.log.basename': 'Log file base name', - 'tutorial.log.example.title': 'Configuration Example', - - // Common - 'common.togglePassword': 'Show/Hide Password', - 'common.confirm': 'Confirm', - 'common.cancel': 'Cancel', - 'common.success': 'Success', - 'common.enabled': 'Enabled', - 'common.disabled': 'Disabled', - 'common.error': 'Error', - 'common.warning': 'Warning', - 'common.info': 'Info', - 'common.loading': 'Loading...', - 'common.upload': 'Upload', - 'common.generate': 'Generate', - 'common.optional': 'Optional', - 'common.found': 'Found', - 'common.missing': 'Missing', - 'common.search': 'Search', - 'common.welcome': 'Welcome to AIClient2API Management Console!', - 'common.fileType': 'Unsupported file type. Please select JSON, TXT, KEY, PEM, P12, or PFX.', - 'common.fileSize': 'File size cannot exceed 5MB.', - 'common.uploadSuccess': 'File uploaded successfully', - 'common.uploadFailed': 'File upload failed', - 'common.passwordUpdated': 'Admin password updated, takes effect next login', - 'common.configSaved': 'Configuration saved', - 'common.providerPoolRefreshed': 'Provider pool data refreshed', - 'common.copy.success': 'Content copied to clipboard', - 'common.copy.failed': 'Copy failed, please copy manually', - 'common.refresh.success': 'Refresh successful', - 'common.refresh.failed': 'Refresh failed', - - // Login - 'login.title': 'Login - AIClient2API', - 'login.heading': 'Please login to continue', - 'login.password': 'Password', - 'login.passwordPlaceholder': 'Please enter password', - 'login.error.empty': 'Please enter password', - 'login.error.incorrect': 'Incorrect password, please try again', - 'login.error.incorrectWithLock': 'Incorrect password. Account locked for {time} minutes.', - 'login.error.incorrectWithRemaining': 'Incorrect password. {count} attempts remaining.', - 'login.error.locked': 'Account temporarily locked due to too many failed attempts. Please try again in {time} seconds.', - 'login.error.tooFrequent': 'Too many requests, please slow down.', - 'login.error.postOnly': 'Only POST requests are supported', - 'login.error.invalidJson': 'Invalid request format (JSON)', - 'login.error.failed': 'Login failed, please check your network connection', - 'login.button': 'Login', - 'login.loggingIn': 'Logging in...', - } -}; - -// 当前语言 -let currentLanguage = localStorage.getItem('language') || 'zh-CN'; - -// 获取翻译文本 -export function t(key, params = {}) { - let text = translations[currentLanguage]?.[key] || translations['zh-CN']?.[key] || key; - - // 替换参数 - Object.keys(params).forEach(param => { - text = text.replace(`{${param}}`, params[param]); - }); - - return text; -} - -// 切换语言 -export function setLanguage(lang) { - if (translations[lang]) { - currentLanguage = lang; - localStorage.setItem('language', lang); - updatePageLanguage(); - // 更新图片 - updateDashboardImages(lang); - // 触发语言切换事件 - window.dispatchEvent(new CustomEvent('languageChanged', { detail: { language: lang } })); - } -} - -// 更新仪表盘图片 -function updateDashboardImages(lang) { - const sponsorImg = document.getElementById('sponsor-img'); - const sponsorTitle = document.getElementById('sponsor-title'); - const sponsorDesc = document.getElementById('sponsor-desc'); - - const wechatImg = document.getElementById('wechat-img'); - const wechatIcon = document.getElementById('wechat-icon'); - const wechatTitle = document.getElementById('wechat-title'); - const wechatDesc = document.getElementById('wechat-desc'); - - if (lang === 'en-US') { - // 更新赞助图片 - if (sponsorImg) { - sponsorImg.src = 'static/coffee.png'; - sponsorImg.alt = 'Buy me a coffee'; - if (sponsorTitle) { - sponsorTitle.setAttribute('data-i18n', 'dashboard.contact.coffee'); - sponsorTitle.textContent = translations['en-US']['dashboard.contact.coffee']; - } - if (sponsorDesc) { - sponsorDesc.setAttribute('data-i18n', 'dashboard.contact.coffeeDesc'); - sponsorDesc.textContent = translations['en-US']['dashboard.contact.coffeeDesc']; - } - } - - // 更新联系方式图片 (WeChat -> X.com) - if (wechatImg) { - wechatImg.src = 'static/x.com.png'; - wechatImg.alt = 'X.com'; - if (wechatIcon) { - wechatIcon.className = 'fab fa-x-twitter'; - } - if (wechatTitle) { - wechatTitle.setAttribute('data-i18n', 'dashboard.contact.x'); - wechatTitle.textContent = translations['en-US']['dashboard.contact.x'] || 'Follow on X.com'; - } - if (wechatDesc) { - wechatDesc.setAttribute('data-i18n', 'dashboard.contact.xDesc'); - wechatDesc.textContent = translations['en-US']['dashboard.contact.xDesc'] || 'Follow us on X for latest updates'; - } - } - } else { - // 更新赞助图片 - if (sponsorImg) { - sponsorImg.src = 'static/sponsor.png'; - sponsorImg.alt = '赞助二维码'; - if (sponsorTitle) { - sponsorTitle.setAttribute('data-i18n', 'dashboard.contact.sponsor'); - sponsorTitle.textContent = translations['zh-CN']['dashboard.contact.sponsor']; - } - if (sponsorDesc) { - sponsorDesc.setAttribute('data-i18n', 'dashboard.contact.sponsorDesc'); - sponsorDesc.textContent = translations['zh-CN']['dashboard.contact.sponsorDesc']; - } - } - - // 更新联系方式图片 (X.com -> WeChat) - if (wechatImg) { - wechatImg.src = 'static/wechat.png'; - wechatImg.alt = '微信二维码'; - if (wechatIcon) { - wechatIcon.className = 'fab fa-weixin'; - } - if (wechatTitle) { - wechatTitle.setAttribute('data-i18n', 'dashboard.contact.wechat'); - wechatTitle.textContent = translations['zh-CN']['dashboard.contact.wechat']; - } - if (wechatDesc) { - wechatDesc.setAttribute('data-i18n', 'dashboard.contact.wechatDesc'); - wechatDesc.textContent = translations['zh-CN']['dashboard.contact.wechatDesc']; - } - } - } -} - -// 获取当前语言 -export function getCurrentLanguage() { - return currentLanguage; -} - -// 更新页面语言 -function updatePageLanguage() { - // 更新 HTML lang 属性 - document.documentElement.lang = currentLanguage; - - // 更新所有带 data-i18n 或 data-i18n-xxx 属性的元素 - document.querySelectorAll('[data-i18n], [data-i18n-placeholder], [data-i18n-title], [data-i18n-aria-label]').forEach(element => { - // 1. 处理属性翻译 (placeholder, title, aria-label) - const attributes = ['placeholder', 'title', 'aria-label']; - attributes.forEach(attr => { - const attrKey = element.getAttribute(`data-i18n-${attr}`); - if (attrKey) { - const params = element.getAttribute(`data-i18n-${attr}-params`); - const parsedParams = params ? JSON.parse(params) : {}; - if (attr === 'aria-label') { - element.setAttribute('aria-label', t(attrKey, parsedParams)); - } else { - element[attr] = t(attrKey, parsedParams); - } - } - }); - - // 2. 处理主文本翻译 (data-i18n) - const key = element.getAttribute('data-i18n'); - if (key) { - const params = element.getAttribute('data-i18n-params'); - const parsedParams = params ? JSON.parse(params) : {}; - - if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA') { - // 如果没有显式的 data-i18n-placeholder,则 data-i18n 作用于 placeholder - if (!element.hasAttribute('data-i18n-placeholder')) { - element.placeholder = t(key, parsedParams); - } - } else { - element.textContent = t(key, parsedParams); - } - } - }); - - // 更新所有带 data-i18n-html 属性的元素(支持 HTML 内容) - document.querySelectorAll('[data-i18n-html]').forEach(element => { - const key = element.getAttribute('data-i18n-html'); - const params = element.getAttribute('data-i18n-params'); - const parsedParams = params ? JSON.parse(params) : {}; - element.innerHTML = t(key, parsedParams); - }); -} - -// 初始化多语言 -export function initI18n() { - // 设置初始语言 - updatePageLanguage(); - // 设置初始图片 - updateDashboardImages(currentLanguage); - - // 监听 DOM 变化,自动翻译新添加的元素 - const observer = new MutationObserver((mutations) => { - mutations.forEach((mutation) => { - mutation.addedNodes.forEach((node) => { - if (node.nodeType === 1) { // 元素节点 - // 翻译新添加的元素 - if (node.hasAttribute('data-i18n')) { - const key = node.getAttribute('data-i18n'); - const params = node.getAttribute('data-i18n-params'); - const parsedParams = params ? JSON.parse(params) : {}; - - if (node.tagName === 'INPUT' || node.tagName === 'TEXTAREA') { - if (node.placeholder !== undefined) { - node.placeholder = t(key, parsedParams); - } - } else { - node.textContent = t(key, parsedParams); - } - } - - // 翻译子元素 - node.querySelectorAll('[data-i18n]').forEach(element => { - const key = element.getAttribute('data-i18n'); - const params = element.getAttribute('data-i18n-params'); - const parsedParams = params ? JSON.parse(params) : {}; - - if (element.tagName === 'INPUT' || element.tagName === 'TEXTAREA') { - if (element.placeholder !== undefined) { - element.placeholder = t(key, parsedParams); - } - } else { - element.textContent = t(key, parsedParams); - } - }); - } - }); - }); - }); - - observer.observe(document.body, { - childList: true, - subtree: true - }); -} - -// 导出所有函数 -export default { - t, - setLanguage, - getCurrentLanguage, - initI18n -}; diff --git a/static/app/image-zoom.js b/static/app/image-zoom.js deleted file mode 100644 index f975b8ff1bb5ec69fe9aefcbbcc295f08670cab8..0000000000000000000000000000000000000000 --- a/static/app/image-zoom.js +++ /dev/null @@ -1,45 +0,0 @@ -/** - * 图像点击放大功能模块 - */ - -export function initImageZoom() { - // 创建放大图层 - const overlay = document.createElement('div'); - overlay.className = 'image-zoom-overlay'; - overlay.innerHTML = 'Zoomed Image'; - document.body.appendChild(overlay); - - const zoomedImg = overlay.querySelector('img'); - - // 监听点击事件 - document.addEventListener('click', (e) => { - const target = e.target; - - // 如果点击的是可放大的二维码 - if (target.classList.contains('clickable-qr')) { - zoomedImg.src = target.src; - overlay.style.display = 'flex'; - setTimeout(() => { - overlay.classList.add('show'); - }, 10); - } - - // 如果点击的是放大图层(或者其中的图片),则关闭 - if (overlay.classList.contains('show') && (target === overlay || target === zoomedImg)) { - overlay.classList.remove('show'); - setTimeout(() => { - overlay.style.display = 'none'; - }, 300); - } - }); - - // ESC 键关闭 - document.addEventListener('keydown', (e) => { - if (e.key === 'Escape' && overlay.classList.contains('show')) { - overlay.classList.remove('show'); - setTimeout(() => { - overlay.style.display = 'none'; - }, 300); - } - }); -} diff --git a/static/app/language-switcher.js b/static/app/language-switcher.js deleted file mode 100644 index ec880595bbaf1fe67b7573654a5cd91b6779e409..0000000000000000000000000000000000000000 --- a/static/app/language-switcher.js +++ /dev/null @@ -1,105 +0,0 @@ -// 语言切换器组件 -import { setLanguage, getCurrentLanguage, t } from './i18n.js'; - -// 创建语言切换器 HTML -export function createLanguageSwitcher() { - const currentLang = getCurrentLanguage(); - - const switcher = document.createElement('div'); - switcher.className = 'language-switcher'; - switcher.innerHTML = ` - -
- - -
- `; - - return switcher; -} - -// 初始化语言切换器 -export function initLanguageSwitcher() { - // 创建并添加语言切换器到 header - const headerControls = document.querySelector('.header-controls'); - if (headerControls) { - const switcher = createLanguageSwitcher(); - // 追加到最后位置(最右边) - headerControls.appendChild(switcher); - - // 绑定事件 - bindLanguageSwitcherEvents(); - } -} - -// 绑定语言切换器事件 -function bindLanguageSwitcherEvents() { - const languageBtn = document.getElementById('languageBtn'); - const languageDropdown = document.getElementById('languageDropdown'); - const languageOptions = document.querySelectorAll('.language-option'); - - if (!languageBtn || !languageDropdown) return; - - // 切换下拉菜单显示/隐藏 - languageBtn.addEventListener('click', (e) => { - e.stopPropagation(); - languageDropdown.classList.toggle('show'); - }); - - // 点击语言选项 - languageOptions.forEach(option => { - option.addEventListener('click', (e) => { - e.stopPropagation(); - const lang = option.getAttribute('data-lang'); - - // 切换语言 - setLanguage(lang); - - // 更新按钮文本 - const currentLangSpan = languageBtn.querySelector('.current-lang'); - if (currentLangSpan) { - currentLangSpan.textContent = lang === 'zh-CN' ? '中文' : 'EN'; - } - - // 更新选中状态 - languageOptions.forEach(opt => opt.classList.remove('active')); - option.classList.add('active'); - - // 隐藏下拉菜单 - languageDropdown.classList.remove('show'); - - // 显示提示 - showToast(t('common.success'), lang === 'zh-CN' ? '已切换到简体中文' : 'Switched to English', 'success'); - }); - }); - - // 点击页面其他地方关闭下拉菜单 - document.addEventListener('click', () => { - languageDropdown.classList.remove('show'); - }); -} - -// 显示提示消息(使用现有的 toast 系统) -function showToast(title, message, type = 'info') { - // 检查是否有 showToast 函数 - if (typeof window.showToast === 'function') { - window.showToast(title, message, type); - } else { - // 如果没有,使用简单的 alert - console.log(`${title}: ${message}`); - } -} - -export default { - createLanguageSwitcher, - initLanguageSwitcher -}; \ No newline at end of file diff --git a/static/app/mobile.css b/static/app/mobile.css deleted file mode 100644 index 48b718c19fe09b65543dcf1132f5e8aa6d5d0601..0000000000000000000000000000000000000000 --- a/static/app/mobile.css +++ /dev/null @@ -1,1786 +0,0 @@ -/* ======================================== - 移动端优化样式 - ======================================== */ - -/* 基础响应式文本隐藏 */ -@media (max-width: 480px) { - .header-title { - display: inline; - } - - .btn-text { - display: none; - } - - .status-text { - display: none; - } -} - -@media (min-width: 481px) { - .header-title, - .btn-text, - .status-text { - display: inline; - } -} - -/* 移动端汉堡菜单按钮 */ -.mobile-menu-toggle { - display: none; - background: var(--bg-tertiary); - border: 1px solid var(--border-color); - border-radius: var(--radius-lg); - font-size: 1.25rem; - color: var(--text-primary); - cursor: pointer; - padding: 0.5rem; - transition: var(--transition); - width: 40px; - height: 40px; - align-items: center; - justify-content: center; -} - -.mobile-menu-toggle:hover { - background: var(--bg-secondary); - border-color: var(--primary-color); - color: var(--primary-color); -} - -/* 移动端覆盖层 */ -.mobile-overlay { - display: none; - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.5); - z-index: 99; - opacity: 0; - transition: opacity 0.3s ease; -} - -.mobile-overlay.active { - display: block; - opacity: 1; -} - -@keyframes slideDown { - from { - opacity: 0; - transform: translateY(-10px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -/* ======================================== - 平板设备适配 (768px - 1024px) - ======================================== */ -@media (max-width: 1024px) { - .header-content { - padding: 0.75rem 1.5rem; - } - - .content { - padding: 1.5rem; - } - - .stats-grid { - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 1rem; - } - - .config-form { - max-width: 100%; - } -} - -/* ======================================== - 桌面端确保header-controls显示 (最小宽度 769px) - ======================================== */ -@media (min-width: 769px) { - .header .header-controls { - display: flex !important; - } -} - -/* ======================================== - 移动端适配 (最大宽度 768px) - ======================================== */ -@media (max-width: 768px) { - /* Header 重新设计 - 两行布局 */ - .header-content { - padding: 0.625rem 1rem; - flex-wrap: wrap; - gap: 0.5rem; - justify-content: space-between; - align-items: center; - } - - .header h1 { - font-size: 1rem; - flex: 1 1 auto; - min-width: 0; - text-align: left; - order: 1; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: calc(100% - 50px); - } - - .header h1 i { - margin-right: 0.375rem; - font-size: 0.875rem; - flex-shrink: 0; - } - - .header-title { - display: inline-block; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - max-width: 100%; - } - - /* 显示汉堡菜单按钮 */ - .mobile-menu-toggle { - display: inline-flex !important; - order: 2; - z-index: 101; - position: relative; - } - - /* 默认隐藏header-controls */ - .header .header-controls { - display: none !important; - gap: 0.5rem; - flex-wrap: wrap; - justify-content: flex-end; - flex: 0 0 auto; - order: 3; - width: 100%; - z-index: 100; - position: relative; - } - - /* 当通过JS设置display: flex时显示 */ - .header .header-controls[style*="display: flex"] { - display: flex !important; - animation: slideDown 0.3s ease; - background: var(--bg-glass); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); - padding: 0.75rem; - border-radius: var(--radius-lg); - box-shadow: var(--shadow-lg); - border: 1px solid var(--border-color); - margin-top: 0.5rem; - gap: 0.5rem; - justify-content: center; - align-items: center; - } - - /* 统一header-controls内所有元素的大小 */ - .header .header-controls[style*="display: flex"] > * { - flex: 0 0 auto; - } - - /* 状态徽章 */ - .header .header-controls[style*="display: flex"] .status-badge { - padding: 0.5rem 0.75rem; - font-size: 0.75rem; - border-radius: var(--radius-full); - display: inline-flex; - align-items: center; - gap: 0.375rem; - } - - /* GitHub链接 */ - .header .header-controls[style*="display: flex"] .github-link { - width: 40px; - height: 40px; - border-radius: 50%; - display: inline-flex; - align-items: center; - justify-content: center; - } - - /* 主题切换按钮 */ - .header .header-controls[style*="display: flex"] .theme-toggle { - width: 40px; - height: 40px; - border-radius: 50%; - display: inline-flex; - align-items: center; - justify-content: center; - } - - /* 登出按钮 - 与语言切换按钮样式一致 */ - .header .header-controls[style*="display: flex"] .logout-btn { - padding: 0.5rem 1rem; - background: transparent; - color: var(--text-secondary); - border: 1px solid var(--border-color); - border-radius: 0.375rem; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 0.5rem; - font-size: 0.875rem; - font-weight: 500; - cursor: pointer; - transition: var(--transition); - } - - .header .header-controls[style*="display: flex"] .logout-btn:hover { - background: var(--bg-tertiary); - color: var(--primary-color); - border-color: var(--primary-color); - transform: translateY(-1px); - } - - .header .header-controls[style*="display: flex"] .logout-btn span { - display: inline; - } - - /* 重启按钮 - 与语言切换按钮样式一致 */ - .header .header-controls[style*="display: flex"] #restartBtn { - padding: 0.5rem 1rem; - background: transparent; - color: var(--text-secondary); - border: 1px solid var(--border-color); - border-radius: 0.375rem; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 0.5rem; - font-size: 0.875rem; - font-weight: 500; - cursor: pointer; - transition: var(--transition); - } - - .header .header-controls[style*="display: flex"] #restartBtn:hover { - background: var(--bg-tertiary); - color: var(--primary-color); - border-color: var(--primary-color); - transform: translateY(-1px); - } - - .header .header-controls[style*="display: flex"] #restartBtn .btn-text { - display: inline; - } - - /* 隐藏次要元素 */ - .kiro-buy-link { - display: none; - } - - /* 状态徽章 */ - .status-badge { - padding: 0.375rem 0.625rem; - font-size: 0.6875rem; - gap: 0.375rem; - } - - .status-badge i { - font-size: 0.5rem; - } - - /* 主内容区域 */ - .main-content { - flex-direction: column; - gap: 1rem; - padding: 1rem; - } - - /* 侧边栏移动端样式 - 底部导航栏 */ - .sidebar { - width: 100%; - border: none; - border-radius: var(--radius-xl); - padding: 0.5rem; - position: sticky; - top: 0; - z-index: 90; - background: var(--bg-glass); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); - box-shadow: var(--shadow-md); - margin-bottom: 0.5rem; - } - - .sidebar-nav { - flex-direction: row; - overflow-x: auto; - overflow-y: hidden; - padding: 0.25rem; - gap: 0.375rem; - -webkit-overflow-scrolling: touch; - scrollbar-width: none; - justify-content: flex-start; - } - - .sidebar-nav::-webkit-scrollbar { - display: none; - } - - .nav-item { - padding: 0.5rem 0.75rem; - white-space: nowrap; - border-radius: var(--radius-md); - font-size: 0.75rem; - cursor: pointer; - user-select: none; - -webkit-user-select: none; - -webkit-tap-highlight-color: rgba(0, 0, 0, 0.1); - touch-action: manipulation; - gap: 0.375rem; - min-width: fit-content; - flex-shrink: 0; - } - - .nav-item i { - width: 14px; - font-size: 0.875rem; - pointer-events: none; - } - - .nav-item span { - pointer-events: none; - font-weight: 500; - } - - .nav-item.active { - background: var(--primary-color) !important; - color: white !important; - box-shadow: 0 2px 8px var(--primary-30); - } - - .nav-item:active { - opacity: 0.8; - transform: scale(0.96); - } - - /* 内容区域 */ - .content { - padding: 0; - } - - .section h2 { - font-size: 1.25rem; - margin-bottom: 1rem; - padding: 0 0.25rem; - } - - /* 统计卡片 */ - .stats-grid { - grid-template-columns: repeat(2, 1fr); - gap: 0.75rem; - } - - .stat-card { - padding: 0.875rem; - border-radius: var(--radius-lg); - } - - .stat-icon { - width: 40px; - height: 40px; - font-size: 1.125rem; - } - - .stat-info h3 { - font-size: 1.25rem; - } - - .stat-info p { - font-size: 0.75rem; - } - - /* 表单优化 */ - .config-panel { - padding: 1rem; - border-radius: var(--radius-lg); - } - - .form-row, - .config-row { - grid-template-columns: 1fr; - gap: 0.875rem; - } - - .form-group { - margin-bottom: 0.875rem; - } - - .form-group label { - font-size: 0.8125rem; - margin-bottom: 0.375rem; - font-weight: 500; - } - - .form-control { - padding: 0.625rem 0.875rem; - font-size: 0.875rem; - border-radius: var(--radius-md); - } - - textarea.form-control { - min-height: 100px; - } - - /* 按钮优化 */ - .btn { - padding: 0.625rem 1rem; - font-size: 0.8125rem; - border-radius: var(--radius-md); - } - - .form-actions { - display: flex; - flex-direction: column; - gap: 0.625rem; - } - - .form-actions .btn { - width: 100%; - } - - /* 单选按钮组 */ - .radio-group { - flex-direction: column; - gap: 0.625rem; - } - - /* 高级配置区域 */ - .advanced-config-section { - padding: 1rem; - margin-top: 1rem; - border-radius: var(--radius-lg); - } - - .advanced-config-section h3 { - font-size: 1rem; - margin-bottom: 0.875rem; - } - - /* 系统信息面板 */ - .system-info-panel { - padding: 1rem; - border-radius: var(--radius-lg); - } - - .system-info-panel h3 { - font-size: 1rem; - margin-bottom: 0.875rem; - } - - .system-info-header { - flex-direction: column; - align-items: flex-start; - gap: 0.75rem; - } - - .update-controls { - width: 100%; - justify-content: flex-start; - } - - .info-grid { - grid-template-columns: repeat(2, 1fr) !important; - gap: 0.75rem; - } - - .info-item { - padding: 0.75rem; - flex-direction: column; - align-items: flex-start; - gap: 0.5rem; - border-radius: var(--radius-md); - background: var(--bg-secondary); - } - - .info-label { - font-size: 0.75rem; - font-weight: 500; - color: var(--text-secondary); - } - - .info-label i { - font-size: 0.75rem; - width: 14px; - } - - .info-value { - font-size: 0.875rem; - font-weight: 600; - color: var(--text-primary); - } - - .version-display-wrapper { - padding-left: 0; - width: 100%; - } - - .update-badge { - font-size: 0.625rem; - padding: 0.125rem 0.5rem; - margin-left: 0.5rem; - } - - /* 提供商列表 */ - .providers-container { - padding: 0; - } - - .providers-list { - gap: 0.75rem; - } - - .provider-card { - padding: 0.875rem; - border-radius: var(--radius-lg); - } - - /* Provider Item Header 移动端优化 */ - .provider-item-header { - padding: 0.875rem; - flex-direction: column; - align-items: flex-start; - gap: 0.75rem; - } - - .provider-item-header .provider-info { - width: 100%; - } - - .provider-item-header .provider-name { - font-size: 1rem; - margin-bottom: 0.5rem; - } - - .provider-item-header .provider-meta { - font-size: 0.75rem; - line-height: 1.3; - } - - .provider-item-header .provider-health-meta { - font-size: 0.6875rem; - margin-top: 0.375rem; - } - - .provider-item-header .provider-actions-group { - width: 100%; - justify-content: flex-start; - flex-wrap: wrap; - gap: 0.5rem; - } - - .provider-item-header .provider-actions-group .btn { - flex: 1; - min-width: 80px; - font-size: 0.75rem; - padding: 0.5rem 0.75rem; - } - - .provider-item-header .health-status { - font-size: 0.6875rem; - padding: 0.1875rem 0.5rem; - } - - .provider-item-header .disabled-status { - font-size: 0.6875rem; - padding: 0.1875rem 0.5rem; - } - - .provider-item-header .provider-error-info { - padding: 0.5rem 0.625rem; - font-size: 0.6875rem; - margin-top: 0.5rem; - } - - .provider-item-header .provider-error-info i { - font-size: 0.75rem; - } - - .provider-item-header .provider-error-info .error-label { - font-size: 0.6875rem; - } - - .provider-item-header .provider-error-info .error-message { - font-size: 0.6875rem; - } - - .provider-header { - flex-direction: column; - align-items: flex-start; - gap: 0.625rem; - } - - .provider-header-left { - width: 100%; - margin-bottom: 0.25rem; - } - - .provider-name { - font-size: 1rem; - font-weight: 600; - margin-bottom: 0.25rem; - } - - .provider-status { - font-size: 0.75rem; - padding: 0.25rem 0.5rem; - } - - .provider-header-right { - width: 100%; - justify-content: flex-start; - gap: 0.5rem; - } - - .provider-actions { - width: 100%; - justify-content: flex-start; - gap: 0.5rem; - margin-bottom: 0.5rem; - } - - .provider-actions-group { - flex-wrap: wrap; - gap: 0.5rem; - width: 100%; - } - - .provider-actions .btn { - flex: 1; - min-width: 0; - font-size: 0.8125rem; - padding: 0.5rem 0.75rem; - } - - .provider-summary { - padding: 0.75rem; - margin-top: 0.5rem; - background: var(--bg-secondary); - border-radius: var(--radius-md); - } - - .provider-summary h4 { - font-size: 0.875rem; - margin-bottom: 0.5rem; - } - - .provider-summary p { - font-size: 0.75rem; - line-height: 1.4; - } - - .provider-models { - font-size: 0.75rem; - margin-top: 0.5rem; - } - - .provider-models span { - display: inline-block; - padding: 0.125rem 0.375rem; - margin: 0.125rem; - font-size: 0.6875rem; - } - - /* 提供商统计信息 */ - .provider-stats { - grid-template-columns: repeat(2, 1fr) !important; - gap: 0.75rem; - margin-top: 0.75rem; - } - - .provider-stat { - padding: 0.625rem; - background: var(--bg-secondary); - border-radius: var(--radius-md); - } - - .provider-stat-label { - font-size: 0.6875rem; - margin-bottom: 0.375rem; - } - - .provider-stat-value { - font-size: 1rem; - } - - /* 提供商详情 */ - .provider-summary { - flex-direction: column; - align-items: flex-start; - gap: 0.875rem; - } - - .provider-summary-actions { - margin-left: 0; - width: 100%; - } - - .provider-summary-actions .btn { - width: 100%; - } - - /* 模态框优化 */ - .provider-modal-content { - width: 95%; - max-width: 95%; - max-height: 90vh; - margin: 5vh auto; - padding: 1rem; - border-radius: var(--radius-xl); - } - - .provider-modal-header { - padding: 1rem; - margin: -1rem -1rem 1rem -1rem; - border-bottom: 1px solid var(--border-color); - flex-direction: column; - align-items: flex-start; - gap: 0.75rem; - } - - .provider-modal-header h2 { - font-size: 1.125rem; - flex: 1; - } - - .provider-modal-header .modal-close { - position: absolute; - top: 1rem; - right: 1rem; - width: 32px; - height: 32px; - font-size: 1.25rem; - } - - .provider-modal-body { - padding: 0; - max-height: calc(90vh - 140px); - } - - .provider-summary { - padding: 0.875rem; - margin-bottom: 0.875rem; - flex-direction: column; - align-items: flex-start; - gap: 0.75rem; - } - - .provider-summary-item { - padding: 0.5rem; - width: 100%; - } - - .provider-summary-item .label { - font-size: 0.75rem; - margin-bottom: 0.375rem; - } - - .provider-summary-item .value { - font-size: 1.125rem; - } - - .provider-summary-actions { - width: 100%; - margin-left: 0; - flex-wrap: wrap; - gap: 0.5rem; - } - - .provider-summary-actions .btn { - flex: 1; - min-width: 120px; - } - - .config-section { - padding: 0.875rem; - margin-bottom: 0.875rem; - border-radius: var(--radius-md); - } - - .config-section h3 { - font-size: 1rem; - margin-bottom: 0.75rem; - } - - .config-item { - padding: 0.625rem; - gap: 0.5rem; - flex-direction: column; - align-items: flex-start; - } - - .config-item label { - font-size: 0.8125rem; - margin-bottom: 0.375rem; - } - - .config-item input, - .config-item textarea, - .config-item select { - padding: 0.625rem; - font-size: 0.875rem; - } - - .config-label { - font-size: 0.75rem; - min-width: 70px; - font-weight: 500; - } - - .config-value { - font-size: 0.8125rem; - } - - .form-grid { - grid-template-columns: 1fr; - gap: 0.75rem; - } - - .form-group { - margin-bottom: 0.75rem; - } - - .form-group label { - font-size: 0.8125rem; - margin-bottom: 0.375rem; - } - - .form-actions { - flex-direction: column; - gap: 0.625rem; - } - - .form-actions .btn { - width: 100%; - } - - /* 日志容器 */ - .logs-container { - height: calc(100vh - 280px); - min-height: 300px; - font-size: 0.6875rem; - padding: 0.625rem; - border-radius: var(--radius-lg); - } - - .logs-controls { - flex-wrap: wrap; - gap: 0.5rem; - margin-bottom: 0.75rem; - } - - .logs-controls .btn { - flex: 1; - min-width: 100px; - font-size: 0.75rem; - } - - .log-entry { - padding: 0.375rem; - margin-bottom: 0.25rem; - font-size: 0.6875rem; - line-height: 1.4; - border-radius: var(--radius-sm); - } - - .log-timestamp { - font-size: 0.625rem; - } - - /* Toast 通知 */ - .toast-container { - left: 1rem; - right: 1rem; - bottom: 1rem; - pointer-events: none; - } - - .toast { - padding: 0.75rem 1rem; - font-size: 0.8125rem; - border-radius: var(--radius-md); - pointer-events: auto; - } - - /* 密码输入框 */ - .password-input-wrapper .form-control { - padding-right: 2.5rem; - } - - .password-toggle { - right: 0.5rem; - padding: 0.375rem; - } - - /* 切换开关 */ - .toggle-switch { - transform: scale(0.85); - } - - /* OAuth刷新切换 */ - .oauth-refresh-toggle { - flex-direction: column; - align-items: flex-start; - gap: 0.5rem; - } - - /* 表单网格 */ - .form-grid { - grid-template-columns: 1fr; - } - - /* 路由路由示例移动端优化 */ - .routing-examples-panel { - padding: 1rem; - margin-bottom: 1rem; - border-radius: var(--radius-lg); - } - - .routing-examples-panel h3 { - font-size: 1rem; - } - - .routing-description { - font-size: 0.8125rem; - } - - .routing-examples-grid { - grid-template-columns: 1fr; - gap: 0.875rem; - } - - .routing-example-card { - border-radius: var(--radius-lg); - } - - .routing-card-header { - padding: 0.75rem 1rem; - flex-wrap: wrap; - gap: 0.5rem; - } - - .routing-card-header h4 { - font-size: 0.875rem; - flex-basis: 100%; - margin-bottom: 0.25rem; - } - - .routing-card-header i { - font-size: 0.875rem; - } - - .provider-badge { - font-size: 0.625rem; - padding: 0.1875rem 0.375rem; - } - - .routing-card-content { - padding: 0.875rem; - } - - .endpoint-path { - font-size: 0.6875rem; - padding: 0.375rem 0.5rem; - padding-right: 2rem; - word-break: break-all; - border-radius: var(--radius-sm); - } - - .copy-btn { - right: 0.375rem; - padding: 0.1875rem; - min-width: 28px; - min-height: 28px; - } - - .usage-example pre { - font-size: 0.6875rem; - padding: 0.625rem; - overflow-x: auto; - white-space: pre-wrap; - border-radius: var(--radius-md); - } - - .routing-tips { - padding: 0.875rem; - border-radius: var(--radius-md); - } - - .routing-tips h4 { - font-size: 0.875rem; - } - - .routing-tips ul { - padding-left: 1.125rem; - } - - .routing-tips li { - font-size: 0.75rem; - margin-bottom: 0.5rem; - } - - .routing-tips code { - font-size: 0.6875rem; - padding: 0.125rem 0.25rem; - } - - /* 协议标签移动端优化 */ - .protocol-tabs { - margin-bottom: 0.75rem; - gap: 0.375rem; - } - - .protocol-tab { - padding: 0.375rem 0.625rem; - font-size: 0.75rem; - border-radius: var(--radius-md); - } - - /* 池配置部分 */ - .pool-section { - margin-top: 0.875rem; - } - - .pool-section small { - font-size: 0.6875rem; - } - - /* 系统提示部分 */ - .system-prompt-section { - margin-top: 0.875rem; - } - - .system-prompt-section textarea { - min-height: 120px; - } - - /* Contact Grid 移动端优化 */ - .contact-grid { - grid-template-columns: 1fr !important; - gap: 1rem; - margin-top: 1rem; - } - - .contact-card { - padding: 1rem; - border-radius: var(--radius-lg); - } - - .contact-card h3 { - font-size: 1rem; - margin-bottom: 0.75rem; - gap: 0.375rem; - } - - .contact-card h3 i { - font-size: 0.875rem; - } - - .qr-container { - margin: 1rem 0; - } - - .qr-code { - width: 150px; - height: 150px; - border-width: 2px; - padding: 0.375rem; - } - - .qr-description { - font-size: 0.75rem; - line-height: 1.4; - } - - /* Config Stats 移动端优化 */ - .config-stats { - flex-wrap: wrap; - gap: 0.5rem; - justify-content: flex-start; - } - - .config-stats span { - font-size: 0.75rem; - padding: 0.1875rem 0.5rem; - border-radius: 0.375rem; - } - - /* Dashboard Top Row 移动端优化 */ - .dashboard-top-row { - flex-direction: column; - gap: 1rem; - margin-bottom: 1rem; - } - - .dashboard-top-row .stats-grid { - margin-bottom: 0; - } - - .dashboard-top-row .dashboard-contact { - margin-top: 0; - padding-top: 0; - border-top: none; - } - - .dashboard-top-row .dashboard-contact .contact-grid { - grid-template-columns: 1fr !important; - gap: 0.75rem; - } - - .dashboard-top-row .dashboard-contact .contact-card { - padding: 0.875rem; - } - - .dashboard-top-row .dashboard-contact .qr-container { - margin: 0.5rem 0; - } - - .dashboard-top-row .dashboard-contact .qr-code { - width: 120px; - height: 120px; - } - - .dashboard-top-row .dashboard-contact .contact-card h3 { - font-size: 0.875rem; - } - - .dashboard-top-row .dashboard-contact .qr-description { - font-size: 0.6875rem; - } -} - -/* ======================================== - 小屏幕移动设备 (最大宽度 480px) - ======================================== */ -@media (max-width: 480px) { - /* Header 进一步优化 */ - .header-content { - padding: 0.5rem 0.75rem; - gap: 0.5rem; - } - - .header h1 { - font-size: 0.875rem; - max-width: calc(100% - 45px); - } - - .header h1 i { - font-size: 0.75rem; - margin-right: 0.25rem; - flex-shrink: 0; - } - - .header-controls { - gap: 0.375rem; - } - - .status-badge { - padding: 0.3125rem 0.5rem; - font-size: 0.625rem; - } - - /* 按钮进一步缩小 */ - .github-link, - .theme-toggle { - width: 32px; - height: 32px; - font-size: 0.75rem; - } - - .github-link i, - .theme-toggle i { - font-size: 0.75rem; - } - - /* 侧边栏导航 */ - .sidebar { - padding: 0.375rem; - margin-bottom: 0.375rem; - } - - .sidebar-nav { - gap: 0.25rem; - padding: 0.125rem; - } - - .nav-item { - padding: 0.375rem 0.625rem; - font-size: 0.6875rem; - gap: 0.25rem; - } - - .nav-item i { - width: 12px; - font-size: 0.75rem; - } - - /* 统计卡片 */ - .stats-grid { - grid-template-columns: 1fr; - gap: 0.625rem; - } - - .stat-icon { - width: 36px; - height: 36px; - font-size: 1rem; - } - - .stat-info h3 { - font-size: 1.125rem; - } - - /* 表单控件 */ - .form-control { - font-size: 16px; /* 防止iOS自动缩放 */ - } - - select.form-control { - font-size: 16px; - } - - /* 配置面板 */ - .config-panel { - padding: 0.75rem; - } - - .advanced-config-section { - padding: 0.75rem; - } - - /* 提供商卡片 */ - .provider-card { - padding: 0.625rem; - } - - .provider-name { - font-size: 0.875rem; - } - - /* 提供商卡片进一步优化 */ - .provider-header { - gap: 0.5rem; - } - - .provider-status { - font-size: 0.6875rem; - padding: 0.1875rem 0.375rem; - } - - .provider-header-right { - gap: 0.375rem; - } - - .provider-actions { - gap: 0.375rem; - margin-bottom: 0.375rem; - } - - .provider-actions-group { - gap: 0.375rem; - } - - .provider-actions .btn { - font-size: 0.75rem; - padding: 0.4375rem 0.625rem; - } - - /* Provider Item Header 进一步优化 */ - .provider-item-header { - padding: 0.625rem; - gap: 0.5rem; - } - - .provider-item-header .provider-name { - font-size: 0.875rem; - margin-bottom: 0.375rem; - } - - .provider-item-header .provider-meta { - font-size: 0.6875rem; - } - - .provider-item-header .provider-health-meta { - font-size: 0.625rem; - margin-top: 0.25rem; - } - - .provider-item-header .provider-actions-group { - gap: 0.375rem; - } - - .provider-item-header .provider-actions-group .btn { - min-width: 70px; - font-size: 0.6875rem; - padding: 0.375rem 0.625rem; - } - - .provider-item-header .health-status { - font-size: 0.625rem; - padding: 0.125rem 0.375rem; - } - - .provider-item-header .disabled-status { - font-size: 0.625rem; - padding: 0.125rem 0.375rem; - } - - .provider-item-header .provider-error-info { - padding: 0.375rem 0.5rem; - font-size: 0.625rem; - margin-top: 0.375rem; - } - - .provider-item-header .provider-error-info i { - font-size: 0.6875rem; - } - - .provider-item-header .provider-error-info .error-label { - font-size: 0.625rem; - } - - .provider-item-header .provider-error-info .error-message { - font-size: 0.625rem; - } - - .provider-summary { - padding: 0.5rem; - margin-top: 0.375rem; - } - - .provider-summary h4 { - font-size: 0.8125rem; - margin-bottom: 0.375rem; - } - - .provider-summary p { - font-size: 0.6875rem; - } - - .provider-models { - font-size: 0.6875rem; - margin-top: 0.375rem; - } - - .provider-models span { - padding: 0.125rem 0.25rem; - margin: 0.0625rem; - font-size: 0.625rem; - } - - /* Config Stats 进一步优化 */ - .config-stats { - gap: 0.375rem; - } - - .config-stats span { - font-size: 0.6875rem; - padding: 0.125rem 0.375rem; - } - - /* 模态框进一步优化 */ - .provider-modal-content { - width: 98%; - max-width: 98%; - padding: 0.75rem; - } - - .provider-modal-header { - padding: 0.75rem; - margin: -0.75rem -0.75rem 0.75rem -0.75rem; - gap: 0.5rem; - } - - .provider-modal-header h2 { - font-size: 1rem; - } - - .provider-modal-header .modal-close { - width: 28px; - height: 28px; - font-size: 1rem; - top: 0.75rem; - right: 0.75rem; - } - - .provider-summary { - padding: 0.625rem; - margin-bottom: 0.625rem; - gap: 0.5rem; - } - - .provider-summary-item { - padding: 0.375rem; - } - - .provider-summary-item .label { - font-size: 0.6875rem; - margin-bottom: 0.25rem; - } - - .provider-summary-item .value { - font-size: 1rem; - } - - .provider-summary-actions { - gap: 0.375rem; - } - - .provider-summary-actions .btn { - min-width: 100px; - font-size: 0.75rem; - padding: 0.5rem 0.75rem; - } - - .config-section { - padding: 0.625rem; - margin-bottom: 0.625rem; - } - - .config-section h3 { - font-size: 0.875rem; - margin-bottom: 0.625rem; - } - - .config-item { - padding: 0.5rem; - gap: 0.375rem; - } - - .config-item label { - font-size: 0.75rem; - margin-bottom: 0.25rem; - } - - .config-item input, - .config-item textarea, - .config-item select { - padding: 0.5rem; - font-size: 0.8125rem; - } - - .form-grid { - gap: 0.625rem; - } - - .form-group { - margin-bottom: 0.625rem; - } - - .form-group label { - font-size: 0.75rem; - margin-bottom: 0.25rem; - } - - .form-actions { - gap: 0.5rem; - } - - .form-actions .btn { - font-size: 0.75rem; - padding: 0.5rem 0.75rem; - } - - /* 日志 */ - .logs-container { - font-size: 0.625rem; - padding: 0.5rem; - height: calc(100vh - 260px); - } - - .log-entry { - padding: 0.3125rem; - font-size: 0.625rem; - } - - /* Toast */ - .toast { - font-size: 0.75rem; - padding: 0.625rem 0.75rem; - } - - /* Section标题 */ - .section h2 { - font-size: 1.125rem; - } - - /* System Info Panel 进一步优化 */ - .system-info-panel { - padding: 0.875rem; - } - - .system-info-panel h3 { - font-size: 0.875rem; - margin-bottom: 0.75rem; - } - - .system-info-header { - gap: 0.625rem; - } - - .info-grid { - grid-template-columns: 1fr !important; - gap: 0.625rem; - } - - .info-item { - padding: 0.625rem; - gap: 0.375rem; - } - - .info-label { - font-size: 0.6875rem; - } - - .info-label i { - font-size: 0.6875rem; - width: 12px; - } - - .info-value { - font-size: 0.8125rem; - } - - .update-badge { - font-size: 0.5625rem; - padding: 0.125rem 0.375rem; - } - - /* Contact Grid 进一步优化 */ - .contact-grid { - gap: 0.75rem; - } - - .contact-card { - padding: 0.875rem; - } - - .contact-card h3 { - font-size: 0.875rem; - margin-bottom: 0.625rem; - } - - .contact-card h3 i { - font-size: 0.75rem; - } - - .qr-container { - margin: 0.75rem 0; - } - - .qr-code { - width: 120px; - height: 120px; - border-width: 2px; - padding: 0.25rem; - } - - .qr-description { - font-size: 0.6875rem; - line-height: 1.3; - } - - /* Dashboard Top Row 进一步优化 */ - .dashboard-top-row .dashboard-contact .contact-card { - padding: 0.75rem; - } - - .dashboard-top-row .dashboard-contact .qr-container { - margin: 0.375rem 0; - } - - .dashboard-top-row .dashboard-contact .qr-code { - width: 100px; - height: 100px; - } - - .dashboard-top-row .dashboard-contact .contact-card h3 { - font-size: 0.8125rem; - } - - .dashboard-top-row .dashboard-contact .qr-description { - font-size: 0.625rem; - } - - /* Provider Stats 进一步优化 */ - .provider-stats { - grid-template-columns: repeat(2, 1fr) !important; - gap: 0.625rem; - margin-top: 0.625rem; - } - - .provider-stat { - padding: 0.5rem; - } - - .provider-stat-label { - font-size: 0.625rem; - margin-bottom: 0.25rem; - } - - .provider-stat-value { - font-size: 0.875rem; - } -} - -/* ======================================== - 横屏模式优化 - ======================================== */ -@media (max-width: 768px) and (orientation: landscape) { - .sidebar { - position: relative; - top: 0; - margin-bottom: 0.5rem; - } - - .sidebar-nav { - padding: 0.25rem; - } - - .nav-item { - padding: 0.375rem 0.625rem; - } - - .logs-container { - height: calc(100vh - 200px); - min-height: 200px; - } - - .provider-modal-content { - max-height: 85vh; - } - - .provider-modal-body { - max-height: calc(85vh - 120px); - } -} - -/* ======================================== - 触摸优化 - ======================================== */ -@media (hover: none) and (pointer: coarse) { - /* 增加可点击区域 */ - .btn { - min-height: 44px; - padding: 0.625rem 1rem; - } - - .nav-item { - min-height: 44px; - padding: 0.625rem 0.875rem; - } - - .password-toggle { - min-width: 44px; - min-height: 44px; - } - - .github-link, - .theme-toggle, - .logout-btn, - .mobile-menu-toggle { - min-width: 44px; - min-height: 44px; - } - - /* 移除悬停效果 */ - .nav-item:hover { - background: transparent; - } - - .nav-item:active { - background: var(--bg-tertiary); - } - - .btn:hover { - transform: none; - } - - .btn:active { - transform: scale(0.96); - } - - /* 优化滚动 */ - .sidebar-nav, - .logs-container, - .provider-modal-body { - -webkit-overflow-scrolling: touch; - } -} - -/* ======================================== - 暗色模式支持(可选) - ======================================== */ -@media (prefers-color-scheme: dark) { - /* 如果需要暗色模式,可以在这里添加样式 */ -} - -/* ======================================== - 打印样式 - ======================================== */ -@media print { - .header-controls, - .sidebar, - .form-actions, - .logs-controls, - .provider-actions, - .mobile-menu-toggle, - .mobile-overlay { - display: none !important; - } - - .main-content { - flex-direction: column; - } - - .content { - padding: 0; - } - - .section { - display: block !important; - page-break-after: always; - } -} - -/* ======================================== - 辅助功能优化 - ======================================== */ -@media (prefers-reduced-motion: reduce) { - *, - *::before, - *::after { - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; - transition-duration: 0.01ms !important; - } -} - -/* 高对比度模式 */ -@media (prefers-contrast: high) { - .btn { - border: 2px solid currentColor; - } - - .form-control { - border: 2px solid var(--border-color); - } - - .nav-item.active { - border: 2px solid var(--primary-color); - } -} \ No newline at end of file diff --git a/static/app/modal.js b/static/app/modal.js deleted file mode 100644 index f906030794495a7f07bc897e50eeefe0c6a6349c..0000000000000000000000000000000000000000 --- a/static/app/modal.js +++ /dev/null @@ -1,1641 +0,0 @@ -// 模态框管理模块 - -import { showToast, getFieldLabel, getProviderTypeFields } from './utils.js'; -import { handleProviderPasswordToggle } from './event-handlers.js'; -import { t } from './i18n.js'; - -// 分页配置 -const PROVIDERS_PER_PAGE = 5; -let currentPage = 1; -let currentProviders = []; -let currentProviderType = ''; -let cachedModels = []; // 缓存模型列表 - -/** - * 显示提供商管理模态框 - * @param {Object} data - 提供商数据 - */ -function showProviderManagerModal(data) { - const { providerType, providers, totalCount, healthyCount } = data; - - // 保存当前数据用于分页 - currentProviders = providers; - currentProviderType = providerType; - currentPage = 1; - cachedModels = []; - - // 移除已存在的模态框 - const existingModal = document.querySelector('.provider-modal'); - if (existingModal) { - // 清理事件监听器 - if (existingModal.cleanup) { - existingModal.cleanup(); - } - existingModal.remove(); - } - - const totalPages = Math.ceil(providers.length / PROVIDERS_PER_PAGE); - - // 创建模态框 - const modal = document.createElement('div'); - modal.className = 'provider-modal'; - modal.setAttribute('data-provider-type', providerType); - modal.innerHTML = ` -
-
-

管理 ${providerType} 提供商配置

- -
-
-
-
- 总账户数: - ${totalCount} -
-
- 健康账户: - ${healthyCount} -
-
- - - - - -
-
- - ${totalPages > 1 ? renderPagination(1, totalPages, providers.length) : ''} - -
- ${renderProviderListPaginated(providers, 1)} -
- - ${totalPages > 1 ? renderPagination(1, totalPages, providers.length, 'bottom') : ''} -
-
- `; - - // 添加到页面 - document.body.appendChild(modal); - - // 添加模态框事件监听 - addModalEventListeners(modal); - - // 先获取该提供商类型的模型列表(只调用一次API) - const pageProviders = providers.slice(0, PROVIDERS_PER_PAGE); - loadModelsForProviderType(providerType, pageProviders); -} - -/** - * 渲染分页控件 - * @param {number} currentPage - 当前页码 - * @param {number} totalPages - 总页数 - * @param {number} totalItems - 总条目数 - * @param {string} position - 位置标识 (top/bottom) - * @returns {string} HTML字符串 - */ -function renderPagination(page, totalPages, totalItems, position = 'top') { - const startItem = (page - 1) * PROVIDERS_PER_PAGE + 1; - const endItem = Math.min(page * PROVIDERS_PER_PAGE, totalItems); - - // 生成页码按钮 - let pageButtons = ''; - const maxVisiblePages = 5; - let startPage = Math.max(1, page - Math.floor(maxVisiblePages / 2)); - let endPage = Math.min(totalPages, startPage + maxVisiblePages - 1); - - if (endPage - startPage < maxVisiblePages - 1) { - startPage = Math.max(1, endPage - maxVisiblePages + 1); - } - - if (startPage > 1) { - pageButtons += ``; - if (startPage > 2) { - pageButtons += `...`; - } - } - - for (let i = startPage; i <= endPage; i++) { - pageButtons += ``; - } - - if (endPage < totalPages) { - if (endPage < totalPages - 1) { - pageButtons += `...`; - } - pageButtons += ``; - } - - return ` -
-
- 显示 ${startItem}-${endItem} / 共 ${totalItems} 条 -
-
- - ${pageButtons} - -
-
- 跳转到 - - -
-
- `; -} - -/** - * 跳转到指定页 - * @param {number} page - 目标页码 - */ -function goToProviderPage(page) { - const totalPages = Math.ceil(currentProviders.length / PROVIDERS_PER_PAGE); - - // 验证页码范围 - if (page < 1) page = 1; - if (page > totalPages) page = totalPages; - - currentPage = page; - - // 更新提供商列表 - const providerList = document.getElementById('providerList'); - if (providerList) { - providerList.innerHTML = renderProviderListPaginated(currentProviders, page); - } - - // 更新分页控件 - const paginationContainers = document.querySelectorAll('.pagination-container'); - paginationContainers.forEach(container => { - const position = container.getAttribute('data-position'); - container.outerHTML = renderPagination(page, totalPages, currentProviders.length, position); - }); - - // 滚动到顶部 - const modalBody = document.querySelector('.provider-modal-body'); - if (modalBody) { - modalBody.scrollTop = 0; - } - - // 为当前页的提供商加载模型列表 - const startIndex = (page - 1) * PROVIDERS_PER_PAGE; - const endIndex = Math.min(startIndex + PROVIDERS_PER_PAGE, currentProviders.length); - const pageProviders = currentProviders.slice(startIndex, endIndex); - - // 如果已缓存模型列表,直接使用 - if (cachedModels.length > 0) { - pageProviders.forEach(provider => { - renderNotSupportedModelsSelector(provider.uuid, cachedModels, provider.notSupportedModels || []); - }); - } else { - loadModelsForProviderType(currentProviderType, pageProviders); - } -} - -/** - * 渲染分页后的提供商列表 - * @param {Array} providers - 提供商数组 - * @param {number} page - 当前页码 - * @returns {string} HTML字符串 - */ -function renderProviderListPaginated(providers, page) { - const startIndex = (page - 1) * PROVIDERS_PER_PAGE; - const endIndex = Math.min(startIndex + PROVIDERS_PER_PAGE, providers.length); - const pageProviders = providers.slice(startIndex, endIndex); - - return renderProviderList(pageProviders); -} - -/** - * 为提供商类型加载模型列表(优化:只调用一次API,并缓存结果) - * @param {string} providerType - 提供商类型 - * @param {Array} providers - 提供商列表 - */ -async function loadModelsForProviderType(providerType, providers) { - try { - // 如果已有缓存,直接使用 - if (cachedModels.length > 0) { - providers.forEach(provider => { - renderNotSupportedModelsSelector(provider.uuid, cachedModels, provider.notSupportedModels || []); - }); - return; - } - - // 只调用一次API获取模型列表 - const response = await window.apiClient.get(`/provider-models/${encodeURIComponent(providerType)}`); - const models = response.models || []; - - // 缓存模型列表 - cachedModels = models; - - // 为每个提供商渲染模型选择器 - providers.forEach(provider => { - renderNotSupportedModelsSelector(provider.uuid, models, provider.notSupportedModels || []); - }); - } catch (error) { - console.error('Failed to load models for provider type:', error); - // 如果加载失败,为每个提供商显示错误信息 - providers.forEach(provider => { - const container = document.querySelector(`.not-supported-models-container[data-uuid="${provider.uuid}"]`); - if (container) { - container.innerHTML = `
${t('common.error')}: 加载模型列表失败
`; - } - }); - } -} - -/** - * 为模态框添加事件监听器 - * @param {HTMLElement} modal - 模态框元素 - */ -function addModalEventListeners(modal) { - // ESC键关闭模态框 - const handleEscKey = (event) => { - if (event.key === 'Escape') { - modal.remove(); - document.removeEventListener('keydown', handleEscKey); - } - }; - - // 点击背景关闭模态框 - const handleBackgroundClick = (event) => { - if (event.target === modal) { - modal.remove(); - document.removeEventListener('keydown', handleEscKey); - } - }; - - // 防止模态框内容区域点击时关闭模态框 - const modalContent = modal.querySelector('.provider-modal-content'); - const handleContentClick = (event) => { - event.stopPropagation(); - }; - - // 密码切换按钮事件处理 - const handlePasswordToggleClick = (event) => { - const button = event.target.closest('.password-toggle'); - if (button) { - event.preventDefault(); - event.stopPropagation(); - handleProviderPasswordToggle(button); - } - }; - - // 上传按钮事件处理 - const handleUploadButtonClick = (event) => { - const button = event.target.closest('.upload-btn'); - if (button) { - event.preventDefault(); - event.stopPropagation(); - const targetInputId = button.getAttribute('data-target'); - const providerType = modal.getAttribute('data-provider-type'); - if (targetInputId && window.fileUploadHandler) { - window.fileUploadHandler.handleFileUpload(button, targetInputId, providerType); - } - } - }; - - // 添加事件监听器 - document.addEventListener('keydown', handleEscKey); - modal.addEventListener('click', handleBackgroundClick); - if (modalContent) { - modalContent.addEventListener('click', handleContentClick); - modalContent.addEventListener('click', handlePasswordToggleClick); - modalContent.addEventListener('click', handleUploadButtonClick); - } - - // 清理函数,在模态框关闭时调用 - modal.cleanup = () => { - document.removeEventListener('keydown', handleEscKey); - modal.removeEventListener('click', handleBackgroundClick); - if (modalContent) { - modalContent.removeEventListener('click', handleContentClick); - modalContent.removeEventListener('click', handlePasswordToggleClick); - modalContent.removeEventListener('click', handleUploadButtonClick); - } - }; -} - -/** - * 关闭模态框并清理事件监听器 - * @param {HTMLElement} button - 关闭按钮 - */ -function closeProviderModal(button) { - const modal = button.closest('.provider-modal'); - if (modal) { - if (modal.cleanup) { - modal.cleanup(); - } - modal.remove(); - } -} - -/** - * 渲染提供商列表 - * @param {Array} providers - 提供商数组 - * @returns {string} HTML字符串 - */ -function renderProviderList(providers) { - return providers.map(provider => { - const isHealthy = provider.isHealthy; - const isDisabled = provider.isDisabled || false; - const lastUsed = provider.lastUsed ? new Date(provider.lastUsed).toLocaleString() : t('modal.provider.neverUsed'); - const lastHealthCheckTime = provider.lastHealthCheckTime ? new Date(provider.lastHealthCheckTime).toLocaleString() : t('modal.provider.neverChecked'); - const lastHealthCheckModel = provider.lastHealthCheckModel || '-'; - const healthClass = isHealthy ? 'healthy' : 'unhealthy'; - const disabledClass = isDisabled ? 'disabled' : ''; - const healthIcon = isHealthy ? 'fas fa-check-circle text-success' : 'fas fa-exclamation-triangle text-warning'; - const healthText = isHealthy ? t('modal.provider.status.healthy') : t('modal.provider.status.unhealthy'); - const disabledText = isDisabled ? t('modal.provider.status.disabled') : t('modal.provider.status.enabled'); - const disabledIcon = isDisabled ? 'fas fa-ban text-muted' : 'fas fa-play text-success'; - const toggleButtonText = isDisabled ? t('modal.provider.enabled') : t('modal.provider.disabled'); - const toggleButtonIcon = isDisabled ? 'fas fa-play' : 'fas fa-ban'; - const toggleButtonClass = isDisabled ? 'btn-success' : 'btn-warning'; - - // 构建错误信息显示 - let errorInfoHtml = ''; - if (!isHealthy && provider.lastErrorMessage) { - const escapedErrorMsg = provider.lastErrorMessage.replace(//g, '>'); - errorInfoHtml = ` -
- - 最后错误: - ${escapedErrorMsg} -
- `; - } - - return ` -
-
-
-
${provider.customName || provider.uuid}
-
- - - 健康状态: ${healthText} - | - - - 状态: ${disabledText} - | - 使用次数: ${provider.usageCount || 0} | - 失败次数: ${provider.errorCount || 0} | - 最后使用: ${lastUsed} -
-
- - - 最后检测: ${lastHealthCheckTime} - | - - - 检测模型: ${lastHealthCheckModel} - -
- ${errorInfoHtml} -
-
- - - - -
-
-
-
- ${renderProviderConfig(provider)} -
-
-
- `; - }).join(''); -} - -/** - * 渲染提供商配置 - * @param {Object} provider - 提供商对象 - * @returns {string} HTML字符串 - */ -function renderProviderConfig(provider) { - // 获取该提供商类型的所有字段定义(从 utils.js) - const fieldConfigs = getProviderTypeFields(currentProviderType); - - // 获取字段显示顺序 - const fieldOrder = getFieldOrder(provider); - - // 先渲染基础配置字段(customName、checkModelName 和 checkHealth) - let html = '
'; - const baseFields = ['customName', 'checkModelName', 'checkHealth', 'concurrencyLimit', 'queueLimit']; - - baseFields.forEach(fieldKey => { - const displayLabel = getFieldLabel(fieldKey); - const value = provider[fieldKey]; - const displayValue = (value !== undefined && value !== null) ? value : ''; - - // 查找字段定义以获取 placeholder - const fieldDef = fieldConfigs.find(f => f.id === fieldKey) || fieldConfigs.find(f => f.id.toUpperCase() === fieldKey.toUpperCase()) || {}; - const placeholder = fieldDef.placeholder || (fieldKey === 'customName' ? '节点自定义名称' : (fieldKey === 'checkModelName' ? '例如: gpt-3.5-turbo' : (fieldKey === 'concurrencyLimit' ? '最大并发, 默认0不限制' : (fieldKey === 'queueLimit' ? '最大队列, 默认0不限制' : '')))); - - // 如果是 customName 字段,使用普通文本输入框 - if (fieldKey === 'customName') { - html += ` -
- - -
- `; - } else if (fieldKey === 'checkHealth') { - // 如果没有值,默认为 false - const actualValue = value !== undefined ? value : false; - const isEnabled = actualValue === true || actualValue === 'true'; - html += ` -
- - -
- `; - } else { - // checkModelName 字段始终显示 - html += ` -
- - -
- `; - } - }); - html += '
'; - - // 渲染其他配置字段,每行2列 - const otherFields = fieldOrder.filter(key => !baseFields.includes(key)); - - for (let i = 0; i < otherFields.length; i += 2) { - html += '
'; - - const field1Key = otherFields[i]; - const field1Label = getFieldLabel(field1Key); - const field1Value = provider[field1Key]; - const field1IsPassword = field1Key.toLowerCase().includes('key') || field1Key.toLowerCase().includes('password'); - const field1IsOAuthFilePath = field1Key.includes('OAUTH_CREDS_FILE_PATH'); - const field1DisplayValue = field1IsPassword && field1Value ? '••••••••' : ((field1Value !== undefined && field1Value !== null) ? field1Value : ''); - const field1Def = fieldConfigs.find(f => f.id === field1Key) || fieldConfigs.find(f => f.id.toUpperCase() === field1Key.toUpperCase()) || {}; - - if (field1IsPassword) { - html += ` -
- -
- - -
-
- `; - } else if (field1IsOAuthFilePath) { - // OAuth凭据文件路径字段,添加上传按钮 - const field1IsKiro = field1Key.includes('KIRO'); - html += ` -
- -
- - -
- ${field1IsKiro ? ' ' + t('modal.provider.kiroAuthHint') + '' : ''} -
- `; - } else { - html += ` -
- - -
- `; - } - - // 如果有第二个字段 - if (i + 1 < otherFields.length) { - const field2Key = otherFields[i + 1]; - const field2Label = getFieldLabel(field2Key); - const field2Value = provider[field2Key]; - const field2IsPassword = field2Key.toLowerCase().includes('key') || field2Key.toLowerCase().includes('password'); - const field2IsOAuthFilePath = field2Key.includes('OAUTH_CREDS_FILE_PATH'); - const field2DisplayValue = field2IsPassword && field2Value ? '••••••••' : ((field2Value !== undefined && field2Value !== null) ? field2Value : ''); - const field2Def = fieldConfigs.find(f => f.id === field2Key) || fieldConfigs.find(f => f.id.toUpperCase() === field2Key.toUpperCase()) || {}; - - if (field2IsPassword) { - html += ` -
- -
- - -
-
- `; - } else if (field2IsOAuthFilePath) { - // OAuth凭据文件路径字段,添加上传按钮 - const field2IsKiro = field2Key.includes('KIRO'); - html += ` -
- -
- - -
- ${field2IsKiro ? ' ' + t('modal.provider.kiroAuthHint') + '' : ''} -
- `; - } else { - html += ` -
- - -
- `; - } - } - - html += '
'; - } - - // 添加 notSupportedModels 配置区域 - html += '
'; - html += ` -
- -
-
- 加载模型列表... -
-
-
- `; - html += '
'; - - return html; -} - -/** - * 获取字段显示顺序 - * @param {Object} provider - 提供商对象 - * @returns {Array} 字段键数组 - */ -function getFieldOrder(provider) { - const orderedFields = ['customName', 'checkModelName', 'checkHealth']; - - // 需要排除的内部状态字段 - const excludedFields = [ - 'isHealthy', 'lastUsed', 'usageCount', 'errorCount', 'lastErrorTime', - 'uuid', 'isDisabled', 'lastHealthCheckTime', 'lastHealthCheckModel', 'lastErrorMessage', - 'notSupportedModels', 'refreshCount', 'needsRefresh', '_lastSelectionSeq' - ]; - - // 从 getProviderTypeFields 获取字段顺序映射 - const fieldOrderMap = { - 'openai-custom': ['OPENAI_API_KEY', 'OPENAI_BASE_URL'], - 'openaiResponses-custom': ['OPENAI_API_KEY', 'OPENAI_BASE_URL'], - 'claude-custom': ['CLAUDE_API_KEY', 'CLAUDE_BASE_URL'], - 'gemini-cli-oauth': ['PROJECT_ID', 'GEMINI_OAUTH_CREDS_FILE_PATH', 'GEMINI_BASE_URL'], - 'claude-kiro-oauth': ['KIRO_OAUTH_CREDS_FILE_PATH', 'KIRO_BASE_URL', 'KIRO_REFRESH_URL', 'KIRO_REFRESH_IDC_URL'], - 'openai-qwen-oauth': ['QWEN_OAUTH_CREDS_FILE_PATH', 'QWEN_BASE_URL', 'QWEN_OAUTH_BASE_URL'], - 'gemini-antigravity': ['PROJECT_ID', 'ANTIGRAVITY_OAUTH_CREDS_FILE_PATH', 'ANTIGRAVITY_BASE_URL_DAILY', 'ANTIGRAVITY_BASE_URL_AUTOPUSH'], - 'openai-iflow': ['IFLOW_OAUTH_CREDS_FILE_PATH', 'IFLOW_BASE_URL'], - 'openai-codex-oauth': ['CODEX_OAUTH_CREDS_FILE_PATH', 'CODEX_EMAIL', 'CODEX_BASE_URL'], - 'grok-custom': ['GROK_COOKIE_TOKEN', 'GROK_CF_CLEARANCE', 'GROK_USER_AGENT', 'GROK_BASE_URL'], - 'forward-api': ['FORWARD_API_KEY', 'FORWARD_BASE_URL', 'FORWARD_HEADER_NAME', 'FORWARD_HEADER_VALUE_PREFIX'] - }; - - // 尝试从全局或当前模态框上下文中推断提供商类型 - let providerType = currentProviderType; - if (!providerType) { - if (provider.OPENAI_API_KEY && provider.OPENAI_BASE_URL) { - providerType = 'openai-custom'; - } else if (provider.CLAUDE_API_KEY && provider.CLAUDE_BASE_URL) { - providerType = 'claude-custom'; - } else if (provider.GEMINI_OAUTH_CREDS_FILE_PATH) { - providerType = 'gemini-cli-oauth'; - } else if (provider.KIRO_OAUTH_CREDS_FILE_PATH) { - providerType = 'claude-kiro-oauth'; - } else if (provider.QWEN_OAUTH_CREDS_FILE_PATH) { - providerType = 'openai-qwen-oauth'; - } else if (provider.ANTIGRAVITY_OAUTH_CREDS_FILE_PATH) { - providerType = 'gemini-antigravity'; - } else if (provider.IFLOW_OAUTH_CREDS_FILE_PATH) { - providerType = 'openai-iflow'; - } else if (provider.CODEX_OAUTH_CREDS_FILE_PATH) { - providerType = 'openai-codex-oauth'; - } else if (provider.GROK_COOKIE_TOKEN) { - providerType = 'grok-custom'; - } else if (provider.FORWARD_API_KEY) { - providerType = 'forward-api'; - } - } - - // 获取该类型应该具有的所有字段(预定义顺序) - const predefinedOrder = providerType ? (fieldOrderMap[providerType] || []) : []; - - // 获取当前对象中存在且不在预定义列表中的其他字段 - const otherFields = Object.keys(provider).filter(key => - !excludedFields.includes(key) && - !orderedFields.includes(key) && - !predefinedOrder.includes(key) - ); - otherFields.sort(); - - // 合并所有要显示的字段 - const allExpectedFields = [...orderedFields, ...predefinedOrder, ...otherFields]; - - // 只有在字段确实存在于 provider 中,或者它是该提供商类型的预定义字段时才显示 - return allExpectedFields.filter(key => - provider.hasOwnProperty(key) || predefinedOrder.includes(key) - ); - - // 如果无法识别提供商类型,按字母顺序排序 - otherFields.sort(); - return [...orderedFields, ...otherFields].filter(key => provider.hasOwnProperty(key)); -} - -/** - * 切换提供商详情显示 - * @param {string} uuid - 提供商UUID - */ -function toggleProviderDetails(uuid) { - const content = document.getElementById(`content-${uuid}`); - if (content) { - content.classList.toggle('expanded'); - } -} - -/** - * 编辑提供商 - * @param {string} uuid - 提供商UUID - * @param {Event} event - 事件对象 - */ -function editProvider(uuid, event) { - event.stopPropagation(); - - const providerDetail = event.target.closest('.provider-item-detail'); - const configInputs = providerDetail.querySelectorAll('input[data-config-key]'); - const configSelects = providerDetail.querySelectorAll('select[data-config-key]'); - const content = providerDetail.querySelector(`#content-${uuid}`); - - // 如果还没有展开,则自动展开编辑框 - if (content && !content.classList.contains('expanded')) { - toggleProviderDetails(uuid); - } - - // 等待一小段时间让展开动画完成,然后切换输入框为可编辑状态 - setTimeout(() => { - // 切换输入框为可编辑状态 - configInputs.forEach(input => { - input.readOnly = false; - if (input.type === 'password') { - const actualValue = input.dataset.configValue; - input.value = actualValue; - } - }); - - // 启用文件上传按钮 - const uploadButtons = providerDetail.querySelectorAll('.upload-btn'); - uploadButtons.forEach(button => { - button.disabled = false; - }); - - // 启用下拉选择框 - configSelects.forEach(select => { - select.disabled = false; - }); - - // 启用模型复选框 - const modelCheckboxes = providerDetail.querySelectorAll('.model-checkbox'); - modelCheckboxes.forEach(checkbox => { - checkbox.disabled = false; - }); - - // 添加编辑状态类 - providerDetail.classList.add('editing'); - - // 替换编辑按钮为保存和取消按钮,不显示禁用/启用按钮 - const actionsGroup = providerDetail.querySelector('.provider-actions-group'); - - actionsGroup.innerHTML = ` - - - `; - }, 100); -} - -/** - * 取消编辑 - * @param {string} uuid - 提供商UUID - * @param {Event} event - 事件对象 - */ -function cancelEdit(uuid, event) { - event.stopPropagation(); - - const providerDetail = event.target.closest('.provider-item-detail'); - const configInputs = providerDetail.querySelectorAll('input[data-config-key]'); - const configSelects = providerDetail.querySelectorAll('select[data-config-key]'); - - // 恢复输入框为只读状态 - configInputs.forEach(input => { - input.readOnly = true; - const originalValue = input.dataset.configValue; - // 恢复原始值 - if (input.type === 'password') { - input.value = originalValue ? '••••••••' : ''; - } else { - input.value = originalValue || ''; - } - }); - - // 禁用模型复选框 - const modelCheckboxes = providerDetail.querySelectorAll('.model-checkbox'); - modelCheckboxes.forEach(checkbox => { - checkbox.disabled = true; - }); - - // 移除编辑状态类 - providerDetail.classList.remove('editing'); - - // 禁用文件上传按钮 - const uploadButtons = providerDetail.querySelectorAll('.upload-btn'); - uploadButtons.forEach(button => { - button.disabled = true; - }); - - // 禁用下拉选择框 - configSelects.forEach(select => { - select.disabled = true; - // 恢复原始值 - const originalValue = select.dataset.configValue; - select.value = originalValue || ''; - }); - - // 恢复原来的按钮布局 - const actionsGroup = providerDetail.querySelector('.provider-actions-group'); - const currentProvider = providerDetail.closest('.provider-modal').querySelector(`[data-uuid="${uuid}"]`); - const isCurrentlyDisabled = currentProvider.classList.contains('disabled'); - const toggleButtonText = isCurrentlyDisabled ? t('modal.provider.enabled') : t('modal.provider.disabled'); - const toggleButtonIcon = isCurrentlyDisabled ? 'fas fa-play' : 'fas fa-ban'; - const toggleButtonClass = isCurrentlyDisabled ? 'btn-success' : 'btn-warning'; - - actionsGroup.innerHTML = ` - - - - - `; -} - -/** - * 保存提供商 - * @param {string} uuid - 提供商UUID - * @param {Event} event - 事件对象 - */ -async function saveProvider(uuid, event) { - event.stopPropagation(); - - const providerDetail = event.target.closest('.provider-item-detail'); - const providerType = providerDetail.closest('.provider-modal').getAttribute('data-provider-type'); - - const configInputs = providerDetail.querySelectorAll('input[data-config-key]'); - const configSelects = providerDetail.querySelectorAll('select[data-config-key]'); - const providerConfig = {}; - - configInputs.forEach(input => { - const key = input.dataset.configKey; - let value = input.value; - if (key === 'concurrencyLimit' || key === 'queueLimit') { - value = parseInt(value || '0'); - } - providerConfig[key] = value; - }); - - configSelects.forEach(select => { - const key = select.dataset.configKey; - const value = select.value === 'true'; - providerConfig[key] = value; - }); - - // 收集不支持的模型列表 - const modelCheckboxes = providerDetail.querySelectorAll(`.model-checkbox[data-uuid="${uuid}"]:checked`); - const notSupportedModels = Array.from(modelCheckboxes).map(checkbox => checkbox.value); - providerConfig.notSupportedModels = notSupportedModels; - - try { - await window.apiClient.put(`/providers/${encodeURIComponent(providerType)}/${uuid}`, { providerConfig }); - await window.apiClient.post('/reload-config'); - showToast(t('common.success'), t('modal.provider.save.success'), 'success'); - // 重新获取该提供商类型的最新配置 - await refreshProviderConfig(providerType); - } catch (error) { - console.error('Failed to update provider:', error); - showToast(t('common.error'), t('modal.provider.save.failed') + ': ' + error.message, 'error'); - } -} - -/** - * 删除提供商 - * @param {string} uuid - 提供商UUID - * @param {Event} event - 事件对象 - */ -async function deleteProvider(uuid, event) { - event.stopPropagation(); - - if (!confirm(t('modal.provider.deleteConfirm'))) { - return; - } - - const providerDetail = event.target.closest('.provider-item-detail'); - const providerType = providerDetail.closest('.provider-modal').getAttribute('data-provider-type'); - - try { - await window.apiClient.delete(`/providers/${encodeURIComponent(providerType)}/${uuid}`); - await window.apiClient.post('/reload-config'); - showToast(t('common.success'), t('modal.provider.delete.success'), 'success'); - // 重新获取最新配置 - await refreshProviderConfig(providerType); - } catch (error) { - console.error('Failed to delete provider:', error); - showToast(t('common.error'), t('modal.provider.delete.failed') + ': ' + error.message, 'error'); - } -} - -/** - * 重新获取并刷新提供商配置 - * @param {string} providerType - 提供商类型 - */ -async function refreshProviderConfig(providerType) { - try { - // 重新获取该提供商类型的最新数据 - const data = await window.apiClient.get(`/providers/${encodeURIComponent(providerType)}`); - - // 如果当前显示的是该提供商类型的模态框,则更新模态框 - const modal = document.querySelector('.provider-modal'); - if (modal && modal.getAttribute('data-provider-type') === providerType) { - // 更新缓存的提供商数据 - currentProviders = data.providers; - currentProviderType = providerType; - - // 更新统计信息 - const totalCountElement = modal.querySelector('.provider-summary-item .value'); - if (totalCountElement) { - totalCountElement.textContent = data.totalCount; - } - - const healthyCountElement = modal.querySelectorAll('.provider-summary-item .value')[1]; - if (healthyCountElement) { - healthyCountElement.textContent = data.healthyCount; - } - - const totalPages = Math.ceil(data.providers.length / PROVIDERS_PER_PAGE); - - // 确保当前页不超过总页数 - if (currentPage > totalPages) { - currentPage = Math.max(1, totalPages); - } - - // 重新渲染提供商列表(分页) - const providerList = modal.querySelector('.provider-list'); - if (providerList) { - providerList.innerHTML = renderProviderListPaginated(data.providers, currentPage); - } - - // 更新分页控件 - const paginationContainers = modal.querySelectorAll('.pagination-container'); - if (totalPages > 1) { - paginationContainers.forEach(container => { - const position = container.getAttribute('data-position'); - container.outerHTML = renderPagination(currentPage, totalPages, data.providers.length, position); - }); - - // 如果之前没有分页控件,需要添加 - if (paginationContainers.length === 0) { - const modalBody = modal.querySelector('.provider-modal-body'); - const providerListEl = modal.querySelector('.provider-list'); - if (modalBody && providerListEl) { - providerListEl.insertAdjacentHTML('beforebegin', renderPagination(currentPage, totalPages, data.providers.length, 'top')); - providerListEl.insertAdjacentHTML('afterend', renderPagination(currentPage, totalPages, data.providers.length, 'bottom')); - } - } - } else { - // 如果只有一页,移除分页控件 - paginationContainers.forEach(container => container.remove()); - } - - // 重新加载当前页的模型列表 - const startIndex = (currentPage - 1) * PROVIDERS_PER_PAGE; - const endIndex = Math.min(startIndex + PROVIDERS_PER_PAGE, data.providers.length); - const pageProviders = data.providers.slice(startIndex, endIndex); - loadModelsForProviderType(providerType, pageProviders); - } - - // 同时更新主界面的提供商统计数据 - if (typeof window.loadProviders === 'function') { - await window.loadProviders(); - } - - } catch (error) { - console.error('Failed to refresh provider config:', error); - } -} - -/** - * 显示添加提供商表单 - * @param {string} providerType - 提供商类型 - */ -function showAddProviderForm(providerType) { - const modal = document.querySelector('.provider-modal'); - const existingForm = modal.querySelector('.add-provider-form'); - - if (existingForm) { - existingForm.remove(); - return; - } - - // Codex OAuth 只支持授权添加,不支持手动添加 - if (providerType === 'openai-codex-oauth') { - const form = document.createElement('div'); - form.className = 'add-provider-form'; - form.innerHTML = ` -

添加新提供商配置

-
-
- - Codex 仅支持 OAuth 授权添加 -
-

- OpenAI Codex 需要通过 OAuth 授权获取访问令牌,无法手动填写凭据。请点击下方按钮进行授权。 -

- - -
- `; - - const providerList = modal.querySelector('.provider-list'); - providerList.parentNode.insertBefore(form, providerList); - return; - } - - const form = document.createElement('div'); - form.className = 'add-provider-form'; - form.innerHTML = ` -

添加新提供商配置

-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- -
-
- - -
- `; - - // 添加动态配置字段 - addDynamicConfigFields(form, providerType); - - // 为添加表单中的密码切换按钮绑定事件监听器 - bindAddFormPasswordToggleListeners(form); - - // 插入到提供商列表前面 - const providerList = modal.querySelector('.provider-list'); - providerList.parentNode.insertBefore(form, providerList); -} - -/** - * 添加动态配置字段 - * @param {HTMLElement} form - 表单元素 - * @param {string} providerType - 提供商类型 - */ -function addDynamicConfigFields(form, providerType) { - const configFields = form.querySelector('#dynamicConfigFields'); - - // 获取该提供商类型的字段配置(已经在 utils.js 中包含了 URL 字段) - const allFields = getProviderTypeFields(providerType); - - // 过滤掉已经在 form-grid 中硬编码显示的五个基础字段,避免重复 - const baseFields = ['customName', 'checkModelName', 'checkHealth', 'concurrencyLimit', 'queueLimit']; - const filteredFields = allFields.filter(f => !baseFields.some(bf => f.id.toLowerCase().includes(bf.toLowerCase()))); - - let fields = ''; - - if (filteredFields.length > 0) { - // 分组显示,每行两个字段 - for (let i = 0; i < filteredFields.length; i += 2) { - fields += '
'; - - const field1 = filteredFields[i]; - // 检查是否为密码类型字段 - const isPassword1 = field1.type === 'password'; - // 检查是否为OAuth凭据文件路径字段(兼容两种命名方式) - const isOAuthFilePath1 = field1.id.includes('OAUTH_CREDS_FILE_PATH') || field1.id.includes('OauthCredsFilePath'); - - if (isPassword1) { - fields += ` -
- -
- - -
-
- `; - } else if (isOAuthFilePath1) { - // OAuth凭据文件路径字段,添加上传按钮 - const isKiroField = field1.id.includes('KIRO'); - fields += ` -
- -
- - -
- ${isKiroField ? ' ' + t('modal.provider.kiroAuthHint') + '' : ''} -
- `; - } else { - fields += ` -
- - -
- `; - } - - const field2 = filteredFields[i + 1]; - if (field2) { - // 检查是否为密码类型字段 - const isPassword2 = field2.type === 'password'; - // 检查是否为OAuth凭据文件路径字段(兼容两种命名方式) - const isOAuthFilePath2 = field2.id.includes('OAUTH_CREDS_FILE_PATH') || field2.id.includes('OauthCredsFilePath'); - - if (isPassword2) { - fields += ` -
- -
- - -
-
- `; - } else if (isOAuthFilePath2) { - // OAuth凭据文件路径字段,添加上传按钮 - const isKiroField = field2.id.includes('KIRO'); - fields += ` -
- -
- - -
- ${isKiroField ? ' ' + t('modal.provider.kiroAuthHint') + '' : ''} -
- `; - } else { - fields += ` -
- - -
- `; - } - } - - fields += '
'; - } - } else { - fields = `

${t('modal.provider.noProviderType')}

`; - } - - configFields.innerHTML = fields; -} - -/** - * 为添加新提供商表单中的密码切换按钮绑定事件监听器 - * @param {HTMLElement} form - 表单元素 - */ -function bindAddFormPasswordToggleListeners(form) { - const passwordToggles = form.querySelectorAll('.password-toggle'); - passwordToggles.forEach(button => { - button.addEventListener('click', function() { - const targetId = this.getAttribute('data-target'); - const input = document.getElementById(targetId); - const icon = this.querySelector('i'); - - if (!input || !icon) return; - - if (input.type === 'password') { - input.type = 'text'; - icon.className = 'fas fa-eye-slash'; - } else { - input.type = 'password'; - icon.className = 'fas fa-eye'; - } - }); - }); -} - -/** - * 添加新提供商 - * @param {string} providerType - 提供商类型 - */ -async function addProvider(providerType) { - const customName = document.getElementById('newCustomName')?.value; - const checkModelName = document.getElementById('newCheckModelName')?.value; - const checkHealth = document.getElementById('newCheckHealth')?.value === 'true'; - const concurrencyLimit = parseInt(document.getElementById('newConcurrencyLimit')?.value || '0'); - const queueLimit = parseInt(document.getElementById('newQueueLimit')?.value || '0'); - - const providerConfig = { - customName: customName || '', // 允许为空 - checkModelName: checkModelName || '', // 允许为空 - checkHealth, - concurrencyLimit, - queueLimit - }; - - // 根据提供商类型动态收集配置字段(自动匹配 utils.js 中的定义) - const allFields = getProviderTypeFields(providerType); - allFields.forEach(field => { - const element = document.getElementById(`new${field.id}`); - if (element) { - providerConfig[field.id] = element.value || ''; - } - }); - - try { - await window.apiClient.post('/providers', { - providerType, - providerConfig - }); - await window.apiClient.post('/reload-config'); - showToast(t('common.success'), t('modal.provider.add.success'), 'success'); - // 移除添加表单 - const form = document.querySelector('.add-provider-form'); - if (form) { - form.remove(); - } - // 重新获取最新配置数据 - await refreshProviderConfig(providerType); - } catch (error) { - console.error('Failed to add provider:', error); - showToast(t('common.error'), t('modal.provider.add.failed') + ': ' + error.message, 'error'); - } -} - -/** - * 切换提供商禁用/启用状态 - * @param {string} uuid - 提供商UUID - * @param {Event} event - 事件对象 - */ -async function toggleProviderStatus(uuid, event) { - event.stopPropagation(); - - const providerDetail = event.target.closest('.provider-item-detail'); - const providerType = providerDetail.closest('.provider-modal').getAttribute('data-provider-type'); - const currentProvider = providerDetail.closest('.provider-modal').querySelector(`[data-uuid="${uuid}"]`); - - // 获取当前提供商信息 - const isCurrentlyDisabled = currentProvider.classList.contains('disabled'); - const action = isCurrentlyDisabled ? 'enable' : 'disable'; - const confirmMessage = isCurrentlyDisabled ? - t('modal.provider.enableConfirm') : - t('modal.provider.disableConfirm'); - - if (!confirm(confirmMessage)) { - return; - } - - try { - await window.apiClient.post(`/providers/${encodeURIComponent(providerType)}/${uuid}/${action}`, { action }); - await window.apiClient.post('/reload-config'); - showToast(t('common.success'), t('common.success'), 'success'); - // 重新获取该提供商类型的最新配置 - await refreshProviderConfig(providerType); - } catch (error) { - console.error('Failed to toggle provider status:', error); - showToast(t('common.error'), t('common.error') + ': ' + error.message, 'error'); - } -} - -/** - * 重置所有提供商的健康状态 - * @param {string} providerType - 提供商类型 - */ -async function resetAllProvidersHealth(providerType) { - if (!confirm(t('modal.provider.resetHealthConfirm', {type: providerType}))) { - return; - } - - try { - showToast(t('common.info'), t('modal.provider.resetHealth') + '...', 'info'); - - const response = await window.apiClient.post( - `/providers/${encodeURIComponent(providerType)}/reset-health`, - {} - ); - - if (response.success) { - showToast(t('common.success'), t('modal.provider.resetHealth.success', { count: response.resetCount }), 'success'); - - // 重新加载配置 - await window.apiClient.post('/reload-config'); - - // 刷新提供商配置显示 - await refreshProviderConfig(providerType); - } else { - showToast(t('common.error'), t('modal.provider.resetHealth.failed'), 'error'); - } - } catch (error) { - console.error('重置健康状态失败:', error); - showToast(t('common.error'), t('modal.provider.resetHealth.failed') + ': ' + error.message, 'error'); - } -} - -/** - * 执行健康检测 - * @param {string} providerType - 提供商类型 - */ -async function performHealthCheck(providerType) { - if (!confirm(t('modal.provider.healthCheckConfirm', {type: providerType}))) { - return; - } - - try { - showToast(t('common.info'), t('modal.provider.healthCheck') + '...', 'info'); - - const response = await window.apiClient.post( - `/providers/${encodeURIComponent(providerType)}/health-check`, - {} - ); - - if (response.success) { - const { successCount, failCount, totalCount, results } = response; - - // 统计跳过的数量(checkHealth 未启用的) - const skippedCount = results ? results.filter(r => r.success === null).length : 0; - - let message = `${t('modal.provider.healthCheck.complete', { success: successCount })}`; - if (failCount > 0) message += t('modal.provider.healthCheck.abnormal', { fail: failCount }); - if (skippedCount > 0) message += t('modal.provider.healthCheck.skipped', { skipped: skippedCount }); - - showToast(t('common.info'), message, failCount > 0 ? 'warning' : 'success'); - - // 重新加载配置 - await window.apiClient.post('/reload-config'); - - // 刷新提供商配置显示 - await refreshProviderConfig(providerType); - } else { - showToast(t('common.error'), t('modal.provider.healthCheck') + ' ' + t('common.error'), 'error'); - } - } catch (error) { - console.error('健康检测失败:', error); - showToast(t('common.error'), t('modal.provider.healthCheck') + ' ' + t('common.error') + ': ' + error.message, 'error'); - } -} - -/** - * 刷新提供商UUID - * @param {string} uuid - 提供商UUID - * @param {Event} event - 事件对象 - */ -async function refreshProviderUuid(uuid, event) { - event.stopPropagation(); - - if (!confirm(t('modal.provider.refreshUuidConfirm', { oldUuid: uuid }))) { - return; - } - - const providerDetail = event.target.closest('.provider-item-detail'); - const providerType = providerDetail.closest('.provider-modal').getAttribute('data-provider-type'); - - try { - const response = await window.apiClient.post( - `/providers/${encodeURIComponent(providerType)}/${uuid}/refresh-uuid`, - {} - ); - - if (response.success) { - showToast(t('common.success'), t('modal.provider.refreshUuid.success', { oldUuid: response.oldUuid, newUuid: response.newUuid }), 'success'); - - // 重新加载配置 - await window.apiClient.post('/reload-config'); - - // 刷新提供商配置显示 - await refreshProviderConfig(providerType); - } else { - showToast(t('common.error'), t('modal.provider.refreshUuid.failed'), 'error'); - } - } catch (error) { - console.error('刷新uuid失败:', error); - showToast(t('common.error'), t('modal.provider.refreshUuid.failed') + ': ' + error.message, 'error'); - } -} - -/** - * 删除所有不健康的提供商节点 - * @param {string} providerType - 提供商类型 - */ -async function deleteUnhealthyProviders(providerType) { - // 先获取不健康节点数量 - const unhealthyCount = currentProviders.filter(p => !p.isHealthy).length; - - if (unhealthyCount === 0) { - showToast(t('common.info'), t('modal.provider.deleteUnhealthy.noUnhealthy'), 'info'); - return; - } - - if (!confirm(t('modal.provider.deleteUnhealthyConfirm', { type: providerType, count: unhealthyCount }))) { - return; - } - - try { - showToast(t('common.info'), t('modal.provider.deleteUnhealthy.deleting'), 'info'); - - const response = await window.apiClient.delete( - `/providers/${encodeURIComponent(providerType)}/delete-unhealthy` - ); - - if (response.success) { - showToast( - t('common.success'), - t('modal.provider.deleteUnhealthy.success', { count: response.deletedCount }), - 'success' - ); - - // 重新加载配置 - await window.apiClient.post('/reload-config'); - - // 刷新提供商配置显示 - await refreshProviderConfig(providerType); - } else { - showToast(t('common.error'), t('modal.provider.deleteUnhealthy.failed'), 'error'); - } - } catch (error) { - console.error('删除不健康节点失败:', error); - showToast(t('common.error'), t('modal.provider.deleteUnhealthy.failed') + ': ' + error.message, 'error'); - } -} - -/** - * 批量刷新不健康节点的UUID - * @param {string} providerType - 提供商类型 - */ -async function refreshUnhealthyUuids(providerType) { - // 先获取不健康节点数量 - const unhealthyCount = currentProviders.filter(p => !p.isHealthy).length; - - if (unhealthyCount === 0) { - showToast(t('common.info'), t('modal.provider.refreshUnhealthyUuids.noUnhealthy'), 'info'); - return; - } - - if (!confirm(t('modal.provider.refreshUnhealthyUuidsConfirm', { type: providerType, count: unhealthyCount }))) { - return; - } - - try { - showToast(t('common.info'), t('modal.provider.refreshUnhealthyUuids.refreshing'), 'info'); - - const response = await window.apiClient.post( - `/providers/${encodeURIComponent(providerType)}/refresh-unhealthy-uuids` - ); - - if (response.success) { - showToast( - t('common.success'), - t('modal.provider.refreshUnhealthyUuids.success', { count: response.refreshedCount }), - 'success' - ); - - // 重新加载配置 - await window.apiClient.post('/reload-config'); - - // 刷新提供商配置显示 - await refreshProviderConfig(providerType); - } else { - showToast(t('common.error'), t('modal.provider.refreshUnhealthyUuids.failed'), 'error'); - } - } catch (error) { - console.error('刷新不健康节点UUID失败:', error); - showToast(t('common.error'), t('modal.provider.refreshUnhealthyUuids.failed') + ': ' + error.message, 'error'); - } -} - -/** - * 渲染不支持的模型选择器(不调用API,直接使用传入的模型列表) - * @param {string} uuid - 提供商UUID - * @param {Array} models - 模型列表 - * @param {Array} notSupportedModels - 当前不支持的模型列表 - */ -function renderNotSupportedModelsSelector(uuid, models, notSupportedModels = []) { - const container = document.querySelector(`.not-supported-models-container[data-uuid="${uuid}"]`); - if (!container) return; - - if (models.length === 0) { - container.innerHTML = `
${t('modal.provider.noModels')}
`; - return; - } - - // 渲染模型复选框列表 - let html = '
'; - models.forEach(model => { - const isChecked = notSupportedModels.includes(model); - html += ` - - `; - }); - html += '
'; - - container.innerHTML = html; -} - -// 导出所有函数,并挂载到window对象供HTML调用 -export { - showProviderManagerModal, - closeProviderModal, - toggleProviderDetails, - editProvider, - cancelEdit, - saveProvider, - deleteProvider, - refreshProviderConfig, - showAddProviderForm, - addProvider, - toggleProviderStatus, - resetAllProvidersHealth, - performHealthCheck, - deleteUnhealthyProviders, - refreshUnhealthyUuids, - loadModelsForProviderType, - renderNotSupportedModelsSelector, - goToProviderPage, - refreshProviderUuid -}; - -// 将函数挂载到window对象 -window.closeProviderModal = closeProviderModal; -window.toggleProviderDetails = toggleProviderDetails; -window.editProvider = editProvider; -window.cancelEdit = cancelEdit; -window.saveProvider = saveProvider; -window.deleteProvider = deleteProvider; -window.showAddProviderForm = showAddProviderForm; -window.addProvider = addProvider; -window.toggleProviderStatus = toggleProviderStatus; -window.resetAllProvidersHealth = resetAllProvidersHealth; -window.performHealthCheck = performHealthCheck; -window.deleteUnhealthyProviders = deleteUnhealthyProviders; -window.refreshUnhealthyUuids = refreshUnhealthyUuids; -window.goToProviderPage = goToProviderPage; -window.refreshProviderUuid = refreshProviderUuid; \ No newline at end of file diff --git a/static/app/models-manager.js b/static/app/models-manager.js deleted file mode 100644 index 25a50994f6d538791eaf991369371b9b67062406..0000000000000000000000000000000000000000 --- a/static/app/models-manager.js +++ /dev/null @@ -1,328 +0,0 @@ -/** - * Models Manager - 管理可用模型列表的显示和复制功能 - * Models Manager - Manages the display and copy functionality of available models - */ - -import { t } from './i18n.js'; - -// 模型数据缓存 -let modelsCache = null; - -// 提供商配置缓存 -let currentProviderConfigs = null; - -/** - * 更新提供商配置 - * @param {Array} configs - 提供商配置列表 - */ -function updateModelsProviderConfigs(configs) { - currentProviderConfigs = configs; - // 如果已经加载了模型,重新渲染一次以更新显示名称和图标 - if (modelsCache) { - renderModelsList(modelsCache); - } -} - -/** - * 获取所有提供商的可用模型 - * @returns {Promise} 模型数据 - */ -async function fetchProviderModels() { - if (modelsCache) { - return modelsCache; - } - - try { - const response = await fetch('/api/provider-models', { - headers: { - 'Authorization': `Bearer ${localStorage.getItem('authToken') || ''}` - } - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - modelsCache = await response.json(); - return modelsCache; - } catch (error) { - console.error('[Models Manager] Failed to fetch provider models:', error); - throw error; - } -} - -/** - * 复制文本到剪贴板 - * @param {string} text - 要复制的文本 - * @returns {Promise} 是否复制成功 - */ -async function copyToClipboard(text) { - try { - if (navigator.clipboard && navigator.clipboard.writeText) { - await navigator.clipboard.writeText(text); - return true; - } - - // Fallback for older browsers - const textArea = document.createElement('textarea'); - textArea.value = text; - textArea.style.position = 'fixed'; - textArea.style.left = '-9999px'; - textArea.style.top = '-9999px'; - document.body.appendChild(textArea); - textArea.focus(); - textArea.select(); - - const successful = document.execCommand('copy'); - document.body.removeChild(textArea); - return successful; - } catch (error) { - console.error('[Models Manager] Failed to copy to clipboard:', error); - return false; - } -} - -/** - * 显示复制成功的 Toast 提示 - * @param {string} modelName - 模型名称 - */ -function showCopyToast(modelName) { - const toastContainer = document.getElementById('toastContainer'); - if (!toastContainer) return; - - const toast = document.createElement('div'); - toast.className = 'toast toast-success'; - toast.innerHTML = ` - - ${t('models.copied') || '已复制'}: ${modelName} - `; - - toastContainer.appendChild(toast); - - // 自动移除 - setTimeout(() => { - toast.classList.add('toast-fade-out'); - setTimeout(() => { - toast.remove(); - }, 300); - }, 2000); -} - -/** - * 渲染模型列表 - * @param {Object} models - 模型数据 - */ -function renderModelsList(models) { - const container = document.getElementById('modelsList'); - if (!container) return; - - // 检查是否有模型数据 - const providerTypes = Object.keys(models); - if (providerTypes.length === 0) { - container.innerHTML = ` -
- -

${t('models.empty') || '暂无可用模型'}

-
- `; - return; - } - - // 渲染每个提供商的模型组 - let html = ''; - - for (const providerType of providerTypes) { - const modelList = models[providerType]; - if (!modelList || modelList.length === 0) continue; - - // 如果配置了不可见,则跳过 - if (currentProviderConfigs) { - const config = currentProviderConfigs.find(c => c.id === providerType); - if (config && config.visible === false) continue; - } - - const providerDisplayName = getProviderDisplayName(providerType); - const providerIcon = getProviderIcon(providerType); - - html += ` -
-
-
- -

${providerDisplayName}

- ${modelList.length} -
-
- -
-
-
- ${modelList.map(model => ` -
-
- -
- ${escapeHtml(model)} -
- -
-
- `).join('')} -
-
- `; - } - - container.innerHTML = html; -} - -/** - * 获取提供商显示名称 - * @param {string} providerType - 提供商类型 - * @returns {string} 显示名称 - */ -function getProviderDisplayName(providerType) { - // 优先从外部传入的配置中获取名称 - if (currentProviderConfigs) { - const config = currentProviderConfigs.find(c => c.id === providerType); - if (config && config.name) { - return config.name; - } - } - - const displayNames = { - 'gemini-cli-oauth': 'Gemini CLI (OAuth)', - 'gemini-antigravity': 'Gemini Antigravity', - 'claude-custom': 'Claude Custom', - 'claude-kiro-oauth': 'Claude Kiro (OAuth)', - 'openai-custom': 'OpenAI Custom', - 'openaiResponses-custom': 'OpenAI Responses Custom', - 'openai-qwen-oauth': 'Qwen (OAuth)', - 'openai-iflow': 'iFlow', - 'openai-codex-oauth': 'OpenAI Codex (OAuth)' - }; - - return displayNames[providerType] || providerType; -} - -/** - * 获取提供商图标 - * @param {string} providerType - 提供商类型 - * @returns {string} 图标类名 - */ -function getProviderIcon(providerType) { - // 优先从外部传入的配置中获取图标 - if (currentProviderConfigs) { - const config = currentProviderConfigs.find(c => c.id === providerType); - if (config && config.icon) { - // 如果 icon 已经包含 fa- 则直接使用,否则加上 fas - return config.icon.startsWith('fa-') ? `fas ${config.icon}` : config.icon; - } - } - - if (providerType.includes('gemini')) { - return 'fas fa-gem'; - } else if (providerType.includes('claude')) { - return 'fas fa-robot'; - } else if (providerType.includes('openai') || providerType.includes('qwen') || providerType.includes('iflow')) { - return 'fas fa-brain'; - } - return 'fas fa-server'; -} - -/** - * HTML 转义 - * @param {string} text - 原始文本 - * @returns {string} 转义后的文本 - */ -function escapeHtml(text) { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; -} - -/** - * 切换提供商模型列表的展开/折叠状态 - * @param {string} providerType - 提供商类型 - */ -function toggleProviderModels(providerType) { - const group = document.querySelector(`.provider-models-group[data-provider="${providerType}"]`); - if (!group) return; - - const header = group.querySelector('.provider-models-header'); - const content = group.querySelector('.provider-models-content'); - - if (content.classList.contains('collapsed')) { - content.classList.remove('collapsed'); - header.classList.remove('collapsed'); - } else { - content.classList.add('collapsed'); - header.classList.add('collapsed'); - } -} - -/** - * 复制模型名称 - * @param {string} modelName - 模型名称 - * @param {HTMLElement} element - 点击的元素 - */ -async function copyModelName(modelName, element) { - const success = await copyToClipboard(modelName); - - if (success) { - // 添加复制成功的视觉反馈 - element.classList.add('copied'); - setTimeout(() => { - element.classList.remove('copied'); - }, 1000); - - // 显示 Toast 提示 - showCopyToast(modelName); - } -} - -/** - * 初始化模型管理器 - */ -async function initModelsManager() { - const container = document.getElementById('modelsList'); - if (!container) return; - - try { - const models = await fetchProviderModels(); - renderModelsList(models); - } catch (error) { - container.innerHTML = ` -
- -

${t('models.loadError') || '加载模型列表失败'}

-
- `; - } -} - -/** - * 刷新模型列表 - */ -async function refreshModels() { - modelsCache = null; - await initModelsManager(); -} - -// 导出到全局作用域供 HTML 调用 -window.toggleProviderModels = toggleProviderModels; -window.copyModelName = copyModelName; -window.refreshModels = refreshModels; - -// 监听组件加载完成事件 -window.addEventListener('componentsLoaded', () => { - initModelsManager(); -}); - -// 导出函数 -export { - initModelsManager, - refreshModels, - fetchProviderModels, - updateModelsProviderConfigs -}; diff --git a/static/app/navigation.js b/static/app/navigation.js deleted file mode 100644 index 142b80ae9462ee42e14badc8031632271f512ad8..0000000000000000000000000000000000000000 --- a/static/app/navigation.js +++ /dev/null @@ -1,115 +0,0 @@ -// 导航功能模块 - -import { elements } from './constants.js'; - -/** - * 初始化导航功能 - */ -function initNavigation() { - if (!elements.navItems || !elements.sections) { - console.warn('导航元素未找到'); - return; - } - - elements.navItems.forEach(item => { - item.addEventListener('click', (e) => { - e.preventDefault(); - const sectionId = item.dataset.section; - - // 更新导航状态 - elements.navItems.forEach(nav => nav.classList.remove('active')); - item.classList.add('active'); - - // 显示对应章节 - elements.sections.forEach(section => { - section.classList.remove('active'); - if (section.id === sectionId) { - section.classList.add('active'); - - // 如果是日志页面,默认滚动到底部 - if (sectionId === 'logs') { - setTimeout(() => { - const logsContainer = document.getElementById('logsContainer'); - if (logsContainer) { - logsContainer.scrollTop = logsContainer.scrollHeight; - } - }, 100); - } - } - }); - - // 滚动到顶部 - scrollToTop(); - }); - }); -} - -/** - * 切换到指定章节 - * @param {string} sectionId - 章节ID - */ -function switchToSection(sectionId) { - // 更新导航状态 - elements.navItems.forEach(nav => { - nav.classList.remove('active'); - if (nav.dataset.section === sectionId) { - nav.classList.add('active'); - } - }); - - // 显示对应章节 - elements.sections.forEach(section => { - section.classList.remove('active'); - if (section.id === sectionId) { - section.classList.add('active'); - - // 如果是日志页面,默认滚动到底部 - if (sectionId === 'logs') { - setTimeout(() => { - const logsContainer = document.getElementById('logsContainer'); - if (logsContainer) { - logsContainer.scrollTop = logsContainer.scrollHeight; - } - }, 100); - } - } - }); - - // 滚动到顶部 - scrollToTop(); -} - -/** - * 滚动到页面顶部 - */ -function scrollToTop() { - // 尝试滚动内容区域 - const contentContainer = document.getElementById('content-container'); - if (contentContainer) { - contentContainer.scrollTop = 0; - } - - // 同时滚动窗口到顶部 - window.scrollTo(0, 0); -} - -/** - * 切换到仪表盘页面 - */ -function switchToDashboard() { - switchToSection('dashboard'); -} - -/** - * 切换到提供商页面 - */ -function switchToProviders() { - switchToSection('providers'); -} - -export { - initNavigation, - switchToSection, - switchToDashboard, - switchToProviders -}; \ No newline at end of file diff --git a/static/app/plugin-manager.js b/static/app/plugin-manager.js deleted file mode 100644 index b0e21cec528f6bc15f510d6461bce68f9e2b0161..0000000000000000000000000000000000000000 --- a/static/app/plugin-manager.js +++ /dev/null @@ -1,143 +0,0 @@ -import { t } from './i18n.js'; -import { showToast, apiRequest } from './utils.js'; - -// 插件列表状态 -let pluginsList = []; - -/** - * 初始化插件管理器 - */ -export function initPluginManager() { - const refreshBtn = document.getElementById('refreshPluginsBtn'); - if (refreshBtn) { - refreshBtn.addEventListener('click', loadPlugins); - } - - // 初始加载 - loadPlugins(); -} - -/** - * 加载插件列表 - */ -export async function loadPlugins() { - const loadingEl = document.getElementById('pluginsLoading'); - const emptyEl = document.getElementById('pluginsEmpty'); - const listEl = document.getElementById('pluginsList'); - const totalEl = document.getElementById('totalPlugins'); - const enabledEl = document.getElementById('enabledPlugins'); - const disabledEl = document.getElementById('disabledPlugins'); - - if (loadingEl) loadingEl.style.display = 'block'; - if (emptyEl) emptyEl.style.display = 'none'; - if (listEl) listEl.innerHTML = ''; - - try { - const response = await apiRequest('/api/plugins'); - - if (response && response.plugins) { - pluginsList = response.plugins; - renderPluginsList(); - - // 更新统计信息 - if (totalEl) totalEl.textContent = pluginsList.length; - if (enabledEl) enabledEl.textContent = pluginsList.filter(p => p.enabled).length; - if (disabledEl) disabledEl.textContent = pluginsList.filter(p => !p.enabled).length; - } else { - if (emptyEl) emptyEl.style.display = 'flex'; - } - } catch (error) { - console.error('Failed to load plugins:', error); - showToast(t('common.error'), t('plugins.load.failed'), 'error'); - if (emptyEl) emptyEl.style.display = 'flex'; - } finally { - if (loadingEl) loadingEl.style.display = 'none'; - } -} - -/** - * 渲染插件列表 - */ -function renderPluginsList() { - const listEl = document.getElementById('pluginsList'); - const emptyEl = document.getElementById('pluginsEmpty'); - - if (!listEl) return; - - listEl.innerHTML = ''; - - if (pluginsList.length === 0) { - if (emptyEl) emptyEl.style.display = 'flex'; - return; - } - - if (emptyEl) emptyEl.style.display = 'none'; - - pluginsList.forEach(plugin => { - const card = document.createElement('div'); - card.className = `plugin-card ${plugin.enabled ? 'enabled' : 'disabled'}`; - - // 构建标签 HTML - let badgesHtml = ''; - if (plugin.hasMiddleware) { - badgesHtml += `Middleware`; - } - if (plugin.hasRoutes) { - badgesHtml += `Routes`; - } - if (plugin.hasHooks) { - badgesHtml += `Hooks`; - } - - card.innerHTML = ` -
-
-

${plugin.name}

- v${plugin.version} -
-
- -
-
-
${plugin.description || t('plugins.noDescription')}
-
- ${badgesHtml} -
-
- ${plugin.enabled ? t('plugins.status.enabled') : t('plugins.status.disabled')} -
- `; - - listEl.appendChild(card); - }); -} - -/** - * 切换插件启用状态 - * @param {string} pluginName - 插件名称 - * @param {boolean} enabled - 是否启用 - */ -export async function togglePlugin(pluginName, enabled) { - try { - await apiRequest(`/api/plugins/${encodeURIComponent(pluginName)}/toggle`, { - method: 'POST', - body: JSON.stringify({ enabled }) - }); - - showToast(t('common.success'), t('plugins.toggle.success', { name: pluginName, status: enabled ? t('common.enabled') : t('common.disabled') }), 'success'); - - // 重新加载列表以更新状态 - loadPlugins(); - - // 提示需要重启 - showToast(t('common.info'), t('plugins.restart.required'), 'info'); - } catch (error) { - console.error(`Failed to toggle plugin ${pluginName}:`, error); - showToast(t('common.error'), t('plugins.toggle.failed'), 'error'); - // 恢复开关状态 - loadPlugins(); - } -} \ No newline at end of file diff --git a/static/app/provider-manager.js b/static/app/provider-manager.js deleted file mode 100644 index 1a60c14df85fa49b0d24af480803269c11937cae..0000000000000000000000000000000000000000 --- a/static/app/provider-manager.js +++ /dev/null @@ -1,3106 +0,0 @@ -// 提供商管理功能模块 - -import { providerStats, updateProviderStats } from './constants.js'; -import { showToast, formatUptime, getProviderConfigs } from './utils.js'; -import { fileUploadHandler } from './file-upload.js'; -import { t, getCurrentLanguage } from './i18n.js'; -import { renderRoutingExamples } from './routing-examples.js'; -import { updateModelsProviderConfigs } from './models-manager.js'; -import { updateTutorialProviderConfigs } from './tutorial-manager.js'; -import { updateUsageProviderConfigs } from './usage-manager.js'; -import { updateConfigProviderConfigs } from './config-manager.js'; -import { loadConfigList, updateProviderFilterOptions } from './upload-config-manager.js'; -import { setServiceMode } from './event-handlers.js'; - -// 保存初始服务器时间和运行时间 -let initialServerTime = null; -let initialUptime = null; -let initialLoadTime = null; -let isStaticProviderConfigsUpdated = false; -let cachedSupportedProviders = null; - -/** - * 加载系统信息 - */ -async function loadSystemInfo() { - try { - const data = await window.apiClient.get('/system'); - - const appVersionEl = document.getElementById('appVersion'); - const nodeVersionEl = document.getElementById('nodeVersion'); - const serverTimeEl = document.getElementById('serverTime'); - const memoryUsageEl = document.getElementById('memoryUsage'); - const cpuUsageEl = document.getElementById('cpuUsage'); - const uptimeEl = document.getElementById('uptime'); - - if (appVersionEl) appVersionEl.textContent = data.appVersion ? `v${data.appVersion}` : '--'; - - // 自动检查更新 - if (data.appVersion) { - checkUpdate(true); - } - - if (nodeVersionEl) nodeVersionEl.textContent = data.nodeVersion || '--'; - if (memoryUsageEl) memoryUsageEl.textContent = data.memoryUsage || '--'; - if (cpuUsageEl) cpuUsageEl.textContent = data.cpuUsage || '--'; - - // 保存初始时间用于本地计算 - if (data.serverTime && data.uptime !== undefined) { - initialServerTime = new Date(data.serverTime); - initialUptime = data.uptime; - initialLoadTime = Date.now(); - } - - // 初始显示 - if (serverTimeEl) { - serverTimeEl.textContent = data.serverTime ? new Date(data.serverTime).toLocaleString(getCurrentLanguage()) : '--'; - } - if (uptimeEl) uptimeEl.textContent = data.uptime ? formatUptime(data.uptime) : '--'; - - // 加载服务模式信息 - await loadServiceModeInfo(); - - } catch (error) { - console.error('Failed to load system info:', error); - } -} - -/** - * 加载服务运行模式信息 - */ -async function loadServiceModeInfo() { - try { - const data = await window.apiClient.get('/service-mode'); - - const serviceModeEl = document.getElementById('serviceMode'); - const processPidEl = document.getElementById('processPid'); - const platformInfoEl = document.getElementById('platformInfo'); - - // 更新服务模式到 event-handlers - setServiceMode(data.mode || 'worker'); - - // 更新重启/重载按钮显示 - updateRestartButton(data.mode); - - if (serviceModeEl) { - const modeText = data.mode === 'worker' - ? t('dashboard.serviceMode.worker') - : t('dashboard.serviceMode.standalone'); - const canRestartIcon = data.canAutoRestart - ? '' - : ''; - serviceModeEl.innerHTML = modeText; - } - - if (processPidEl) { - processPidEl.textContent = data.pid || '--'; - } - - if (platformInfoEl) { - // 格式化平台信息 - const platformMap = { - 'win32': 'Windows', - 'darwin': 'macOS', - 'linux': 'Linux', - 'freebsd': 'FreeBSD' - }; - platformInfoEl.textContent = platformMap[data.platform] || data.platform || '--'; - } - - } catch (error) { - console.error('Failed to load service mode info:', error); - } -} - -/** - * 根据服务模式更新重启/重载按钮显示 - * @param {string} mode - 服务模式 ('worker' 或 'standalone') - */ -function updateRestartButton(mode) { - const restartBtn = document.getElementById('restartBtn'); - const restartBtnIcon = document.getElementById('restartBtnIcon'); - const restartBtnText = document.getElementById('restartBtnText'); - - if (!restartBtn) return; - - if (mode === 'standalone') { - // 独立模式:显示"重载"按钮 - if (restartBtnIcon) { - restartBtnIcon.className = 'fas fa-sync-alt'; - } - if (restartBtnText) { - restartBtnText.textContent = t('header.reload'); - restartBtnText.setAttribute('data-i18n', 'header.reload'); - } - restartBtn.setAttribute('aria-label', t('header.reload')); - restartBtn.setAttribute('data-i18n-aria-label', 'header.reload'); - restartBtn.title = t('header.reload'); - } else { - // 子进程模式:显示"重启"按钮 - if (restartBtnIcon) { - restartBtnIcon.className = 'fas fa-redo'; - } - if (restartBtnText) { - restartBtnText.textContent = t('header.restart'); - restartBtnText.setAttribute('data-i18n', 'header.restart'); - } - restartBtn.setAttribute('aria-label', t('header.restart')); - restartBtn.setAttribute('data-i18n-aria-label', 'header.restart'); - restartBtn.title = t('header.restart'); - } -} - -/** - * 更新服务器时间和运行时间显示(本地计算) - */ -function updateTimeDisplay() { - if (!initialServerTime || initialUptime === null || !initialLoadTime) { - return; - } - - const serverTimeEl = document.getElementById('serverTime'); - const uptimeEl = document.getElementById('uptime'); - - // 计算经过的秒数 - const elapsedSeconds = Math.floor((Date.now() - initialLoadTime) / 1000); - - // 更新服务器时间 - if (serverTimeEl) { - const currentServerTime = new Date(initialServerTime.getTime() + elapsedSeconds * 1000); - serverTimeEl.textContent = currentServerTime.toLocaleString(getCurrentLanguage()); - } - - // 更新运行时间 - if (uptimeEl) { - const currentUptime = initialUptime + elapsedSeconds; - uptimeEl.textContent = formatUptime(currentUptime); - } -} - -/** - * 加载提供商列表 - */ -async function loadProviders() { - try { - const providers = await window.apiClient.get('/providers'); - - // 动态更新其他模块的提供商信息,只需更新一次 - if (!isStaticProviderConfigsUpdated) { - cachedSupportedProviders = await window.apiClient.get('/providers/supported'); - const providerConfigs = getProviderConfigs(cachedSupportedProviders); - - // 动态更新凭据文件管理的提供商类型筛选项 - updateProviderFilterOptions(providerConfigs); - - // 动态更新仪表盘页面的路径路由调用示例 - renderRoutingExamples(providerConfigs); - - // 动态更新仪表盘页面的可用模型列表提供商信息 - updateModelsProviderConfigs(providerConfigs); - - // 动态更新配置教程页面的提供商信息 - updateTutorialProviderConfigs(providerConfigs); - - // 动态更新用量查询页面的提供商信息 - updateUsageProviderConfigs(providerConfigs); - - // 动态更新配置管理页面的提供商选择标签 - updateConfigProviderConfigs(providerConfigs); - - isStaticProviderConfigsUpdated = true; - } - - renderProviders(providers, cachedSupportedProviders); - } catch (error) { - console.error('Failed to load providers:', error); - } -} - -/** - * 渲染提供商列表 - * @param {Object} providers - 提供商数据 - * @param {string[]} supportedProviders - 已注册的提供商类型列表 - */ -function renderProviders(providers, supportedProviders = []) { - const container = document.getElementById('providersList'); - if (!container) return; - - container.innerHTML = ''; - - // 检查是否有提供商池数据 - const hasProviders = Object.keys(providers).length > 0; - const statsGrid = document.querySelector('#providers .stats-grid'); - - // 始终显示统计卡片 - if (statsGrid) statsGrid.style.display = 'grid'; - - const providerConfigs = getProviderConfigs(supportedProviders); - - // 提取显示的 ID 顺序 - const providerDisplayOrder = providerConfigs.filter(c => c.visible !== false).map(c => c.id); - - // 建立 ID 到配置的映射,方便获取显示名称 - const configMap = providerConfigs.reduce((map, config) => { - map[config.id] = config; - return map; - }, {}); - - // 获取所有提供商类型并按指定顺序排序 - // 优先显示预定义的所有提供商类型,即使某些提供商没有数据也要显示 - let allProviderTypes; - if (hasProviders) { - // 合并预定义类型和实际存在的类型,确保显示所有预定义提供商 - const actualProviderTypes = Object.keys(providers); - // 只保留配置中标记为 visible 的,或者不在配置中的(默认显示) - allProviderTypes = [...new Set([...providerDisplayOrder, ...actualProviderTypes])]; - } else { - allProviderTypes = providerDisplayOrder; - } - - // 过滤掉明确设置为不显示的提供商 - const sortedProviderTypes = providerDisplayOrder.filter(type => allProviderTypes.includes(type)) - .concat(allProviderTypes.filter(type => !providerDisplayOrder.some(t => t === type) && !configMap[type]?.visible === false)); - - // 计算总统计 - let totalAccounts = 0; - let totalHealthy = 0; - - // 按照排序后的提供商类型渲染 - sortedProviderTypes.forEach((providerType) => { - // 如果配置中明确设置为不显示,则跳过 - if (configMap[providerType] && configMap[providerType].visible === false) { - return; - } - - const accounts = hasProviders ? providers[providerType] || [] : []; - const providerDiv = document.createElement('div'); - providerDiv.className = 'provider-item'; - providerDiv.dataset.providerType = providerType; - providerDiv.style.cursor = 'pointer'; - - const healthyCount = accounts.filter(acc => acc.isHealthy && !acc.isDisabled).length; - const totalCount = accounts.length; - const usageCount = accounts.reduce((sum, acc) => sum + (acc.usageCount || 0), 0); - const errorCount = accounts.reduce((sum, acc) => sum + (acc.errorCount || 0), 0); - - totalAccounts += totalCount; - totalHealthy += healthyCount; - - // 更新全局统计变量 - if (!providerStats.providerTypeStats[providerType]) { - providerStats.providerTypeStats[providerType] = { - totalAccounts: 0, - healthyAccounts: 0, - totalUsage: 0, - totalErrors: 0, - lastUpdate: null - }; - } - - const typeStats = providerStats.providerTypeStats[providerType]; - typeStats.totalAccounts = totalCount; - typeStats.healthyAccounts = healthyCount; - typeStats.totalUsage = usageCount; - typeStats.totalErrors = errorCount; - typeStats.lastUpdate = new Date().toISOString(); - - // 为无数据状态设置特殊样式 - const isEmptyState = !hasProviders || totalCount === 0; - const statusClass = isEmptyState ? 'status-empty' : (healthyCount === totalCount ? 'status-healthy' : 'status-unhealthy'); - const statusIcon = isEmptyState ? 'fa-info-circle' : (healthyCount === totalCount ? 'fa-check-circle' : 'fa-exclamation-triangle'); - const statusText = isEmptyState ? t('providers.status.empty') : t('providers.status.healthy', { healthy: healthyCount, total: totalCount }); - - // 获取显示名称 - const displayName = configMap[providerType]?.name || providerType; - - providerDiv.innerHTML = ` -
-
- ${displayName} -
-
- ${generateAuthButton(providerType)} -
- - ${statusText} -
-
-
-
-
- ${t('providers.stat.totalAccounts')} - ${totalCount} -
-
- ${t('providers.stat.healthyAccounts')} - ${healthyCount} -
-
- ${t('providers.stat.usageCount')} - ${usageCount} -
-
- ${t('providers.stat.errorCount')} - ${errorCount} -
-
- `; - - // 如果是空状态,添加特殊样式 - if (isEmptyState) { - providerDiv.classList.add('empty-provider'); - } - - // 添加点击事件 - 整个提供商组都可以点击 - providerDiv.addEventListener('click', (e) => { - e.preventDefault(); - openProviderManager(providerType); - }); - - container.appendChild(providerDiv); - - // 为授权按钮添加事件监听 - const authBtn = providerDiv.querySelector('.generate-auth-btn'); - if (authBtn) { - authBtn.addEventListener('click', (e) => { - e.stopPropagation(); // 阻止事件冒泡到父元素 - handleGenerateAuthUrl(providerType); - }); - } - }); - - // 更新统计卡片数据 - const activeProviders = hasProviders ? Object.keys(providers).length : 0; - updateProviderStatsDisplay(activeProviders, totalHealthy, totalAccounts); -} - -/** - * 更新提供商统计信息 - * @param {number} activeProviders - 活跃提供商数 - * @param {number} healthyProviders - 健康提供商数 - * @param {number} totalAccounts - 总账户数 - */ -function updateProviderStatsDisplay(activeProviders, healthyProviders, totalAccounts) { - // 更新全局统计变量 - const newStats = { - activeProviders, - healthyProviders, - totalAccounts, - lastUpdateTime: new Date().toISOString() - }; - - updateProviderStats(newStats); - - // 计算总请求数和错误数 - let totalUsage = 0; - let totalErrors = 0; - Object.values(providerStats.providerTypeStats).forEach(typeStats => { - totalUsage += typeStats.totalUsage || 0; - totalErrors += typeStats.totalErrors || 0; - }); - - const finalStats = { - ...newStats, - totalRequests: totalUsage, - totalErrors: totalErrors - }; - - updateProviderStats(finalStats); - - // 修改:根据使用次数统计"活跃提供商"和"活动连接" - // "活跃提供商":统计有使用次数(usageCount > 0)的提供商类型数量 - let activeProvidersByUsage = 0; - Object.entries(providerStats.providerTypeStats).forEach(([providerType, typeStats]) => { - if (typeStats.totalUsage > 0) { - activeProvidersByUsage++; - } - }); - - // "活动连接":统计所有提供商账户的使用次数总和 - const activeConnections = totalUsage; - - // 更新页面显示 - const activeProvidersEl = document.getElementById('activeProviders'); - const healthyProvidersEl = document.getElementById('healthyProviders'); - const activeConnectionsEl = document.getElementById('activeConnections'); - - if (activeProvidersEl) activeProvidersEl.textContent = activeProvidersByUsage; - if (healthyProvidersEl) healthyProvidersEl.textContent = healthyProviders; - if (activeConnectionsEl) activeConnectionsEl.textContent = activeConnections; - - // 打印调试信息到控制台 - console.log('Provider Stats Updated:', { - activeProviders, - activeProvidersByUsage, - healthyProviders, - totalAccounts, - totalUsage, - totalErrors, - providerTypeStats: providerStats.providerTypeStats - }); -} - -/** - * 打开提供商管理模态框 - * @param {string} providerType - 提供商类型 - */ -async function openProviderManager(providerType) { - try { - const data = await window.apiClient.get(`/providers/${encodeURIComponent(providerType)}`); - - showProviderManagerModal(data); - } catch (error) { - console.error('Failed to load provider details:', error); - showToast(t('common.error'), t('modal.provider.load.failed'), 'error'); - } -} - -/** - * 生成授权按钮HTML - * @param {string} providerType - 提供商类型 - * @returns {string} 授权按钮HTML - */ -function generateAuthButton(providerType) { - // 只为支持OAuth的提供商显示授权按钮 - const oauthProviders = ['gemini-cli-oauth', 'gemini-antigravity', 'openai-qwen-oauth', 'claude-kiro-oauth', 'openai-iflow', 'openai-codex-oauth']; - - if (!oauthProviders.includes(providerType)) { - return ''; - } - - // Codex 提供商使用特殊图标 - if (providerType === 'openai-codex-oauth') { - return ` - - `; - } - - return ` - - `; -} - -/** - * 处理生成授权链接 - * @param {string} providerType - 提供商类型 - */ -async function handleGenerateAuthUrl(providerType) { - // 如果是 Kiro OAuth,先显示认证方式选择对话框 - if (providerType === 'claude-kiro-oauth') { - showKiroAuthMethodSelector(providerType); - return; - } - - // 如果是 Gemini OAuth 或 Antigravity,显示认证方式选择对话框 - if (providerType === 'gemini-cli-oauth' || providerType === 'gemini-antigravity') { - showGeminiAuthMethodSelector(providerType); - return; - } - - // 如果是 Codex OAuth,显示认证方式选择对话框 - if (providerType === 'openai-codex-oauth') { - showCodexAuthMethodSelector(providerType); - return; - } - - await executeGenerateAuthUrl(providerType, {}); -} - -/** - * 显示 Codex OAuth 认证方式选择对话框 - * @param {string} providerType - 提供商类型 - */ -function showCodexAuthMethodSelector(providerType) { - const modal = document.createElement('div'); - modal.className = 'modal-overlay'; - modal.style.display = 'flex'; - - modal.innerHTML = ` - - `; - - document.body.appendChild(modal); - - // 关闭按钮事件 - const closeBtn = modal.querySelector('.modal-close'); - const cancelBtn = modal.querySelector('.modal-cancel'); - [closeBtn, cancelBtn].forEach(btn => { - btn.addEventListener('click', () => { - modal.remove(); - }); - }); - - // 认证方式选择按钮事件 - const methodBtns = modal.querySelectorAll('.auth-method-btn'); - methodBtns.forEach(btn => { - btn.addEventListener('mouseenter', () => { - btn.style.borderColor = '#4285f4'; - btn.style.background = '#f8faff'; - }); - btn.addEventListener('mouseleave', () => { - btn.style.borderColor = '#e0e0e0'; - btn.style.background = 'white'; - }); - btn.addEventListener('click', async () => { - const method = btn.dataset.method; - modal.remove(); - - if (method === 'batch-import') { - showCodexBatchImportModal(providerType); - } else { - await executeGenerateAuthUrl(providerType, {}); - } - }); - }); -} - -/** - * 显示 Codex 批量导入模态框 - * @param {string} providerType - 提供商类型 - */ -function showCodexBatchImportModal(providerType) { - const modal = document.createElement('div'); - modal.className = 'modal-overlay'; - modal.style.display = 'flex'; - - modal.innerHTML = ` - - `; - - document.body.appendChild(modal); - - const textarea = modal.querySelector('#batchCodexTokens'); - const statsDiv = modal.querySelector('#codexBatchStats'); - const tokenCountValue = modal.querySelector('#codexTokenCountValue'); - const progressDiv = modal.querySelector('#codexBatchProgress'); - const progressBar = modal.querySelector('#codexImportProgressBar'); - const resultDiv = modal.querySelector('#codexBatchResult'); - const submitBtn = modal.querySelector('#codexBatchSubmit'); - const closeBtn = modal.querySelector('.modal-close'); - const cancelBtn = modal.querySelector('.modal-cancel'); - - // 实时统计 token 数量 - textarea.addEventListener('input', () => { - try { - const val = textarea.value.trim(); - if (!val) { - statsDiv.style.display = 'none'; - return; - } - const data = JSON.parse(val); - const tokens = Array.isArray(data) ? data : [data]; - statsDiv.style.display = 'block'; - tokenCountValue.textContent = tokens.length; - } catch (e) { - statsDiv.style.display = 'none'; - } - }); - - // 关闭按钮事件 - [closeBtn, cancelBtn].forEach(btn => { - btn.addEventListener('click', () => { - modal.remove(); - }); - }); - - // 提交按钮事件 - submitBtn.addEventListener('click', async () => { - let tokens = []; - try { - const val = textarea.value.trim(); - const data = JSON.parse(val); - tokens = Array.isArray(data) ? data : [data]; - } catch (e) { - showToast(t('common.error'), t('oauth.codex.noTokens'), 'error'); - return; - } - - if (tokens.length === 0) { - showToast(t('common.warning'), t('oauth.codex.noTokens'), 'warning'); - return; - } - - // 禁用输入和按钮 - textarea.disabled = true; - submitBtn.disabled = true; - cancelBtn.disabled = true; - progressDiv.style.display = 'block'; - resultDiv.style.display = 'none'; - progressBar.style.width = '0%'; - - // 创建实时结果显示区域 - resultDiv.style.cssText = 'display: block; margin-top: 16px; padding: 12px; border-radius: 8px; background: #f3f4f6; border: 1px solid #d1d5db;'; - resultDiv.innerHTML = ` -
- - ${t('oauth.codex.importingProgress', { current: 0, total: tokens.length })} -
-
- `; - - const progressText = resultDiv.querySelector('#codexBatchProgressText'); - const resultsList = resultDiv.querySelector('#codexBatchResultsList'); - - let importSuccess = false; // 标记是否导入成功 - - try { - const response = await fetch('/api/codex/batch-import-tokens', { - method: 'POST', - headers: window.apiClient ? window.apiClient.getAuthHeaders() : { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ tokens }) - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; - - let eventType = ''; - let eventData = ''; - - for (const line of lines) { - if (line.startsWith('event: ')) { - eventType = line.substring(7).trim(); - } else if (line.startsWith('data: ')) { - eventData = line.substring(6).trim(); - - if (eventType && eventData) { - try { - const data = JSON.parse(eventData); - - if (eventType === 'progress') { - const { index, total, current } = data; - const percentage = Math.round((index / total) * 100); - progressBar.style.width = `${percentage}%`; - progressText.textContent = t('oauth.codex.importingProgress', { current: index, total: total }); - - const resultItem = document.createElement('div'); - resultItem.style.cssText = 'padding: 4px 0; border-bottom: 1px solid rgba(0,0,0,0.1);'; - if (current.success) { - resultItem.innerHTML = `Token ${current.index}: ✓ ${current.path}`; - } else if (current.error === 'duplicate') { - resultItem.innerHTML = `Token ${current.index}: ⚠ ${t('oauth.kiro.duplicateToken')} - ${current.existingPath ? `(${current.existingPath})` : ''}`; - } else { - resultItem.innerHTML = `Token ${current.index}: ✗ ${current.error}`; - } - resultsList.appendChild(resultItem); - resultsList.scrollTop = resultsList.scrollHeight; - } else if (eventType === 'complete') { - progressBar.style.width = '100%'; - progressDiv.style.display = 'none'; - - const isAllSuccess = data.failedCount === 0; - const isAllFailed = data.successCount === 0; - let resultClass, resultIcon, resultMessage; - - if (isAllSuccess) { - resultClass = 'background: #f0fdf4; border: 1px solid #bbf7d0; color: #166534;'; - resultIcon = 'fa-check-circle'; - resultMessage = t('oauth.codex.importSuccess', { count: data.successCount }); - } else if (isAllFailed) { - resultClass = 'background: #fef2f2; border: 1px solid #fecaca; color: #991b1b;'; - resultIcon = 'fa-times-circle'; - resultMessage = t('oauth.codex.importAllFailed', { count: data.failedCount }); - } else { - resultClass = 'background: #fffbeb; border: 1px solid #fde68a; color: #92400e;'; - resultIcon = 'fa-exclamation-triangle'; - resultMessage = t('oauth.codex.importPartial', { success: data.successCount, failed: data.failedCount }); - } - - resultDiv.style.cssText = `display: block; margin-top: 16px; padding: 12px; border-radius: 8px; ${resultClass}`; - const headerDiv = resultDiv.querySelector('div:first-child'); - headerDiv.innerHTML = ` ${resultMessage}`; - - if (data.successCount > 0) { - importSuccess = true; - loadProviders(); - loadConfigList(); - } - } else if (eventType === 'error') { - throw new Error(data.error); - } - } catch (parseError) { - console.warn('Failed to parse SSE data:', parseError); - } - eventType = ''; - eventData = ''; - } - } - } - } - } catch (error) { - console.error('[Codex Batch Import] Failed:', error); - progressDiv.style.display = 'none'; - resultDiv.style.cssText = 'display: block; margin-top: 16px; padding: 12px; border-radius: 8px; background: #fef2f2; border: 1px solid #fecaca; color: #991b1b;'; - resultDiv.innerHTML = ` -
- - ${t('oauth.codex.importError')}: ${error.message} -
- `; - } finally { - cancelBtn.disabled = false; - - if (!importSuccess) { - textarea.disabled = false; - submitBtn.disabled = false; - submitBtn.innerHTML = ` ${t('oauth.codex.startImport')}`; - } else { - submitBtn.innerHTML = ` ${t('common.success')}`; - } - } - }); -} - -/** - * 显示 Kiro OAuth 认证方式选择对话框 - * @param {string} providerType - 提供商类型 - */ -function showKiroAuthMethodSelector(providerType) { - const modal = document.createElement('div'); - modal.className = 'modal-overlay'; - modal.style.display = 'flex'; - - modal.innerHTML = ` - - `; - - document.body.appendChild(modal); - - // 关闭按钮事件 - const closeBtn = modal.querySelector('.modal-close'); - const cancelBtn = modal.querySelector('.modal-cancel'); - [closeBtn, cancelBtn].forEach(btn => { - btn.addEventListener('click', () => { - modal.remove(); - }); - }); - - // 认证方式选择按钮事件 - const methodBtns = modal.querySelectorAll('.auth-method-btn'); - methodBtns.forEach(btn => { - btn.addEventListener('mouseenter', () => { - btn.style.borderColor = '#00a67e'; - btn.style.background = '#f8fffe'; - }); - btn.addEventListener('mouseleave', () => { - btn.style.borderColor = '#e0e0e0'; - btn.style.background = 'white'; - }); - btn.addEventListener('click', async () => { - const method = btn.dataset.method; - modal.remove(); - - if (method === 'batch-import') { - showKiroBatchImportModal(); - } else if (method === 'aws-import') { - showKiroAwsImportModal(); - } else { - await executeGenerateAuthUrl(providerType, { method }); - } - }); - }); -} - -/** - * 显示 Gemini OAuth 认证方式选择对话框 - * @param {string} providerType - 提供商类型 - */ -function showGeminiAuthMethodSelector(providerType) { - const modal = document.createElement('div'); - modal.className = 'modal-overlay'; - modal.style.display = 'flex'; - - modal.innerHTML = ` - - `; - - document.body.appendChild(modal); - - // 关闭按钮事件 - const closeBtn = modal.querySelector('.modal-close'); - const cancelBtn = modal.querySelector('.modal-cancel'); - [closeBtn, cancelBtn].forEach(btn => { - btn.addEventListener('click', () => { - modal.remove(); - }); - }); - - // 认证方式选择按钮事件 - const methodBtns = modal.querySelectorAll('.auth-method-btn'); - methodBtns.forEach(btn => { - btn.addEventListener('mouseenter', () => { - btn.style.borderColor = '#4285f4'; - btn.style.background = '#f8faff'; - }); - btn.addEventListener('mouseleave', () => { - btn.style.borderColor = '#e0e0e0'; - btn.style.background = 'white'; - }); - btn.addEventListener('click', async () => { - const method = btn.dataset.method; - modal.remove(); - - if (method === 'batch-import') { - showGeminiBatchImportModal(providerType); - } else { - await executeGenerateAuthUrl(providerType, {}); - } - }); - }); -} - -/** - * 显示 Gemini 批量导入模态框 - * @param {string} providerType - 提供商类型 - */ -function showGeminiBatchImportModal(providerType) { - const modal = document.createElement('div'); - modal.className = 'modal-overlay'; - modal.style.display = 'flex'; - - modal.innerHTML = ` - - `; - - document.body.appendChild(modal); - - const textarea = modal.querySelector('#batchGeminiTokens'); - const statsDiv = modal.querySelector('#geminiBatchStats'); - const tokenCountValue = modal.querySelector('#geminiTokenCountValue'); - const progressDiv = modal.querySelector('#geminiBatchProgress'); - const progressBar = modal.querySelector('#geminiImportProgressBar'); - const resultDiv = modal.querySelector('#geminiBatchResult'); - const submitBtn = modal.querySelector('#geminiBatchSubmit'); - const closeBtn = modal.querySelector('.modal-close'); - const cancelBtn = modal.querySelector('.modal-cancel'); - - // 实时统计 token 数量 - textarea.addEventListener('input', () => { - try { - const val = textarea.value.trim(); - if (!val) { - statsDiv.style.display = 'none'; - return; - } - const data = JSON.parse(val); - const tokens = Array.isArray(data) ? data : [data]; - statsDiv.style.display = 'block'; - tokenCountValue.textContent = tokens.length; - } catch (e) { - statsDiv.style.display = 'none'; - } - }); - - // 关闭按钮事件 - [closeBtn, cancelBtn].forEach(btn => { - btn.addEventListener('click', () => { - modal.remove(); - }); - }); - - // 提交按钮事件 - submitBtn.addEventListener('click', async () => { - let tokens = []; - try { - const val = textarea.value.trim(); - const data = JSON.parse(val); - tokens = Array.isArray(data) ? data : [data]; - } catch (e) { - showToast(t('common.error'), t('oauth.gemini.noTokens'), 'error'); - return; - } - - if (tokens.length === 0) { - showToast(t('common.warning'), t('oauth.gemini.noTokens'), 'warning'); - return; - } - - // 禁用输入和按钮 - textarea.disabled = true; - submitBtn.disabled = true; - cancelBtn.disabled = true; - progressDiv.style.display = 'block'; - resultDiv.style.display = 'none'; - progressBar.style.width = '0%'; - - // 创建实时结果显示区域 - resultDiv.style.cssText = 'display: block; margin-top: 16px; padding: 12px; border-radius: 8px; background: #f3f4f6; border: 1px solid #d1d5db;'; - resultDiv.innerHTML = ` -
- - ${t('oauth.gemini.importingProgress', { current: 0, total: tokens.length })} -
-
- `; - - const progressText = resultDiv.querySelector('#geminiBatchProgressText'); - const resultsList = resultDiv.querySelector('#geminiBatchResultsList'); - - let importSuccess = false; // 标记是否导入成功 - - try { - const response = await fetch('/api/gemini/batch-import-tokens', { - method: 'POST', - headers: window.apiClient ? window.apiClient.getAuthHeaders() : { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ providerType, tokens }) - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; - - let eventType = ''; - let eventData = ''; - - for (const line of lines) { - if (line.startsWith('event: ')) { - eventType = line.substring(7).trim(); - } else if (line.startsWith('data: ')) { - eventData = line.substring(6).trim(); - - if (eventType && eventData) { - try { - const data = JSON.parse(eventData); - - if (eventType === 'progress') { - const { index, total, current } = data; - const percentage = Math.round((index / total) * 100); - progressBar.style.width = `${percentage}%`; - progressText.textContent = t('oauth.gemini.importingProgress', { current: index, total: total }); - - const resultItem = document.createElement('div'); - resultItem.style.cssText = 'padding: 4px 0; border-bottom: 1px solid rgba(0,0,0,0.1);'; - if (current.success) { - resultItem.innerHTML = `Token ${current.index}: ✓ ${current.path}`; - } else if (current.error === 'duplicate') { - resultItem.innerHTML = `Token ${current.index}: ⚠ ${t('oauth.kiro.duplicateToken')} - ${current.existingPath ? `(${current.existingPath})` : ''}`; - } else { - resultItem.innerHTML = `Token ${current.index}: ✗ ${current.error}`; - } - resultsList.appendChild(resultItem); - resultsList.scrollTop = resultsList.scrollHeight; - } else if (eventType === 'complete') { - progressBar.style.width = '100%'; - progressDiv.style.display = 'none'; - - const isAllSuccess = data.failedCount === 0; - const isAllFailed = data.successCount === 0; - let resultClass, resultIcon, resultMessage; - - if (isAllSuccess) { - resultClass = 'background: #f0fdf4; border: 1px solid #bbf7d0; color: #166534;'; - resultIcon = 'fa-check-circle'; - resultMessage = t('oauth.gemini.importSuccess', { count: data.successCount }); - } else if (isAllFailed) { - resultClass = 'background: #fef2f2; border: 1px solid #fecaca; color: #991b1b;'; - resultIcon = 'fa-times-circle'; - resultMessage = t('oauth.gemini.importAllFailed', { count: data.failedCount }); - } else { - resultClass = 'background: #fffbeb; border: 1px solid #fde68a; color: #92400e;'; - resultIcon = 'fa-exclamation-triangle'; - resultMessage = t('oauth.gemini.importPartial', { success: data.successCount, failed: data.failedCount }); - } - - resultDiv.style.cssText = `display: block; margin-top: 16px; padding: 12px; border-radius: 8px; ${resultClass}`; - const headerDiv = resultDiv.querySelector('div:first-child'); - headerDiv.innerHTML = ` ${resultMessage}`; - - if (data.successCount > 0) { - importSuccess = true; - loadProviders(); - loadConfigList(); - } - } else if (eventType === 'error') { - throw new Error(data.error); - } - } catch (parseError) { - console.warn('Failed to parse SSE data:', parseError); - } - eventType = ''; - eventData = ''; - } - } - } - } - } catch (error) { - console.error('[Gemini Batch Import] Failed:', error); - progressDiv.style.display = 'none'; - resultDiv.style.cssText = 'display: block; margin-top: 16px; padding: 12px; border-radius: 8px; background: #fef2f2; border: 1px solid #fecaca; color: #991b1b;'; - resultDiv.innerHTML = ` -
- - ${t('oauth.gemini.importError')}: ${error.message} -
- `; - } finally { - cancelBtn.disabled = false; - - if (!importSuccess) { - textarea.disabled = false; - submitBtn.disabled = false; - submitBtn.innerHTML = ` ${t('oauth.gemini.startImport')}`; - } else { - submitBtn.innerHTML = ` ${t('common.success')}`; - } - } - }); -} - -/** - * 显示 Kiro 批量导入 refreshToken 模态框 - */ -function showKiroBatchImportModal() { - const modal = document.createElement('div'); - modal.className = 'modal-overlay'; - modal.style.display = 'flex'; - - modal.innerHTML = ` - - `; - - document.body.appendChild(modal); - - const textarea = modal.querySelector('#batchRefreshTokens'); - const statsDiv = modal.querySelector('#batchImportStats'); - const tokenCountValue = modal.querySelector('#tokenCountValue'); - const progressDiv = modal.querySelector('#batchImportProgress'); - const progressBar = modal.querySelector('#importProgressBar'); - const resultDiv = modal.querySelector('#batchImportResult'); - const submitBtn = modal.querySelector('#batchImportSubmit'); - const closeBtn = modal.querySelector('.modal-close'); - const cancelBtn = modal.querySelector('.modal-cancel'); - - // 实时统计 token 数量 - textarea.addEventListener('input', () => { - const tokens = textarea.value.split('\n').filter(line => line.trim()); - if (tokens.length > 0) { - statsDiv.style.display = 'block'; - tokenCountValue.textContent = tokens.length; - } else { - statsDiv.style.display = 'none'; - } - }); - - // 关闭按钮事件 - [closeBtn, cancelBtn].forEach(btn => { - btn.addEventListener('click', () => { - modal.remove(); - }); - }); - - // 提交按钮事件 - 使用 SSE 流式响应实时显示进度 - submitBtn.addEventListener('click', async () => { - const tokens = textarea.value.split('\n').filter(line => line.trim()); - - if (tokens.length === 0) { - showToast(t('common.warning'), t('oauth.kiro.noTokens'), 'warning'); - return; - } - - // 禁用输入和按钮 - textarea.disabled = true; - submitBtn.disabled = true; - cancelBtn.disabled = true; - progressDiv.style.display = 'block'; - resultDiv.style.display = 'none'; - progressBar.style.width = '0%'; - - // 创建实时结果显示区域 - resultDiv.style.cssText = 'display: block; margin-top: 16px; padding: 12px; border-radius: 8px; background: #f3f4f6; border: 1px solid #d1d5db;'; - resultDiv.innerHTML = ` -
- - ${t('oauth.kiro.importingProgress', { current: 0, total: tokens.length })} -
-
- `; - - const progressText = resultDiv.querySelector('#batchProgressText'); - const resultsList = resultDiv.querySelector('#batchResultsList'); - - let successCount = 0; - let failedCount = 0; - const details = []; - let importSuccess = false; // 标记是否导入成功 - - try { - // 使用 fetch + SSE 获取流式响应(需要带认证头) - const response = await fetch('/api/kiro/batch-import-tokens', { - method: 'POST', - headers: window.apiClient ? window.apiClient.getAuthHeaders() : { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ refreshTokens: tokens }) - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - - // 解析 SSE 事件 - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; // 保留最后一个可能不完整的行 - - let eventType = ''; - let eventData = ''; - - for (const line of lines) { - if (line.startsWith('event: ')) { - eventType = line.substring(7).trim(); - } else if (line.startsWith('data: ')) { - eventData = line.substring(6).trim(); - - if (eventType && eventData) { - try { - const data = JSON.parse(eventData); - - if (eventType === 'start') { - // 开始事件 - console.log(`[Batch Import] Starting import of ${data.total} tokens`); - } else if (eventType === 'progress') { - // 进度更新 - const { index, total, current, successCount: sc, failedCount: fc } = data; - successCount = sc; - failedCount = fc; - details.push(current); - - // 更新进度条 - const percentage = Math.round((index / total) * 100); - progressBar.style.width = `${percentage}%`; - - // 更新进度文本 - progressText.textContent = t('oauth.kiro.importingProgress', { current: index, total: total }); - - // 添加结果项 - const resultItem = document.createElement('div'); - resultItem.style.cssText = 'padding: 4px 0; border-bottom: 1px solid rgba(0,0,0,0.1);'; - - if (current.success) { - resultItem.innerHTML = `Token ${current.index}: ✓ ${current.path}`; - } else if (current.error === 'duplicate') { - resultItem.innerHTML = `Token ${current.index}: ⚠ ${t('oauth.kiro.duplicateToken')} - ${current.existingPath ? `(${current.existingPath})` : ''}`; - } else { - resultItem.innerHTML = `Token ${current.index}: ✗ ${current.error}`; - } - - resultsList.appendChild(resultItem); - // 自动滚动到底部 - resultsList.scrollTop = resultsList.scrollHeight; - - } else if (eventType === 'complete') { - // 完成事件 - progressBar.style.width = '100%'; - progressDiv.style.display = 'none'; - - const isAllSuccess = data.failedCount === 0; - const isAllFailed = data.successCount === 0; - - let resultClass, resultIcon, resultMessage; - if (isAllSuccess) { - resultClass = 'background: #f0fdf4; border: 1px solid #bbf7d0; color: #166534;'; - resultIcon = 'fa-check-circle'; - resultMessage = t('oauth.kiro.importSuccess', { count: data.successCount }); - } else if (isAllFailed) { - resultClass = 'background: #fef2f2; border: 1px solid #fecaca; color: #991b1b;'; - resultIcon = 'fa-times-circle'; - resultMessage = t('oauth.kiro.importAllFailed', { count: data.failedCount }); - } else { - resultClass = 'background: #fffbeb; border: 1px solid #fde68a; color: #92400e;'; - resultIcon = 'fa-exclamation-triangle'; - resultMessage = t('oauth.kiro.importPartial', { success: data.successCount, failed: data.failedCount }); - } - - // 更新结果区域样式 - resultDiv.style.cssText = `display: block; margin-top: 16px; padding: 12px; border-radius: 8px; ${resultClass}`; - - // 更新标题 - const headerDiv = resultDiv.querySelector('div:first-child'); - headerDiv.innerHTML = ` ${resultMessage}`; - - // 如果有成功的,刷新提供商列表 - if (data.successCount > 0) { - importSuccess = true; - loadProviders(); - loadConfigList(); - } - - } else if (eventType === 'error') { - throw new Error(data.error); - } - } catch (parseError) { - console.warn('Failed to parse SSE data:', parseError); - } - - eventType = ''; - eventData = ''; - } - } - } - } - - } catch (error) { - console.error('[Kiro Batch Import] Failed:', error); - progressDiv.style.display = 'none'; - resultDiv.style.cssText = 'display: block; margin-top: 16px; padding: 12px; border-radius: 8px; background: #fef2f2; border: 1px solid #fecaca; color: #991b1b;'; - resultDiv.innerHTML = ` -
- - ${t('oauth.kiro.importError')}: ${error.message} -
- `; - } finally { - // 重新启用按钮 - cancelBtn.disabled = false; - if (!importSuccess) { - textarea.disabled = false; - submitBtn.disabled = false; - submitBtn.innerHTML = ` ${t('oauth.kiro.startImport')}`; - } else { - submitBtn.innerHTML = ` ${t('common.success')}`; - } - } - }); -} - -/** - * 显示 Kiro AWS 账号导入模态框 - * 支持从 AWS SSO cache 目录导入凭据文件,或直接粘贴 JSON - */ -function showKiroAwsImportModal() { - const modal = document.createElement('div'); - modal.className = 'modal-overlay'; - modal.style.display = 'flex'; - - modal.innerHTML = ` - - `; - - document.body.appendChild(modal); - - const fileInput = modal.querySelector('#awsFilesInput'); - const uploadArea = modal.querySelector('.aws-file-upload-area'); - const filesListDiv = modal.querySelector('#awsFilesList'); - const filesContainer = modal.querySelector('#awsFilesContainer'); - const clearFilesBtn = modal.querySelector('#clearFilesBtn'); - const validationResult = modal.querySelector('#awsValidationResult'); - const jsonPreview = modal.querySelector('#awsJsonPreview'); - const jsonContent = modal.querySelector('#awsJsonContent'); - const submitBtn = modal.querySelector('#awsImportSubmit'); - const closeBtn = modal.querySelector('.modal-close'); - const cancelBtn = modal.querySelector('.modal-cancel'); - const modeBtns = modal.querySelectorAll('.mode-btn'); - const fileModeSection = modal.querySelector('#fileModeSection'); - const jsonModeSection = modal.querySelector('#jsonModeSection'); - const jsonInputTextarea = modal.querySelector('#awsJsonInput'); - - let uploadedFiles = []; - let mergedCredentials = null; - let currentMode = 'file'; - - // 清空文件按钮事件 - clearFilesBtn.addEventListener('click', () => { - uploadedFiles = []; - filesContainer.innerHTML = ''; - filesListDiv.style.display = 'none'; - validationResult.style.display = 'none'; - jsonPreview.style.display = 'none'; - submitBtn.disabled = true; - mergedCredentials = null; - // 清空 file input - fileInput.value = ''; - }); - - // 清空按钮 hover 效果 - clearFilesBtn.addEventListener('mouseenter', () => { - clearFilesBtn.style.background = '#fef2f2'; - }); - clearFilesBtn.addEventListener('mouseleave', () => { - clearFilesBtn.style.background = 'none'; - }); - - // 模式切换 - modeBtns.forEach(btn => { - btn.addEventListener('click', () => { - const mode = btn.dataset.mode; - if (mode === currentMode) return; - - currentMode = mode; - - // 更新按钮样式 - modeBtns.forEach(b => { - if (b.dataset.mode === mode) { - b.style.borderColor = '#ff9900'; - b.style.background = '#fff7ed'; - b.style.color = '#9a3412'; - b.classList.add('active'); - } else { - b.style.borderColor = '#d1d5db'; - b.style.background = 'white'; - b.style.color = '#6b7280'; - b.classList.remove('active'); - } - }); - - // 切换显示区域 - if (mode === 'file') { - fileModeSection.style.display = 'block'; - jsonModeSection.style.display = 'none'; - // 重新验证文件模式的内容 - validateAndPreview(); - } else { - fileModeSection.style.display = 'none'; - jsonModeSection.style.display = 'block'; - // 验证 JSON 输入 - validateJsonInput(); - } - }); - }); - - // JSON 输入实时验证 - jsonInputTextarea.addEventListener('input', () => { - validateJsonInput(); - }); - - // 验证 JSON 输入 - function validateJsonInput() { - const inputValue = jsonInputTextarea.value.trim(); - - if (!inputValue) { - validationResult.style.display = 'none'; - jsonPreview.style.display = 'none'; - submitBtn.disabled = true; - mergedCredentials = null; - return; - } - - try { - mergedCredentials = JSON.parse(inputValue); - validateAndShowResult(); - } catch (error) { - validationResult.style.cssText = 'display: block; margin-bottom: 16px; padding: 12px; border-radius: 8px; background: #fef2f2; border: 1px solid #fecaca; color: #991b1b;'; - validationResult.innerHTML = ` -
- - ${t('oauth.kiro.awsJsonParseError')} -
-

${error.message}

- `; - jsonPreview.style.display = 'none'; - submitBtn.disabled = true; - mergedCredentials = null; - } - } - - // 文件上传区域交互 - uploadArea.addEventListener('click', () => fileInput.click()); - - uploadArea.addEventListener('dragover', (e) => { - e.preventDefault(); - uploadArea.style.borderColor = '#ff9900'; - uploadArea.style.background = '#fffbeb'; - }); - - uploadArea.addEventListener('dragleave', (e) => { - e.preventDefault(); - uploadArea.style.borderColor = '#d1d5db'; - uploadArea.style.background = 'transparent'; - }); - - uploadArea.addEventListener('drop', (e) => { - e.preventDefault(); - uploadArea.style.borderColor = '#d1d5db'; - uploadArea.style.background = 'transparent'; - - const files = Array.from(e.dataTransfer.files).filter(f => f.name.endsWith('.json')); - if (files.length > 0) { - processFiles(files); - } - }); - - fileInput.addEventListener('change', () => { - const files = Array.from(fileInput.files); - if (files.length > 0) { - processFiles(files); - } - }); - - // 处理上传的文件(支持追加) - async function processFiles(files) { - for (const file of files) { - // 检查是否已存在同名文件 - const existingIndex = uploadedFiles.findIndex(f => f.name === file.name); - - try { - const content = await readFileAsText(file); - const json = JSON.parse(content); - - if (existingIndex >= 0) { - // 替换已存在的同名文件 - uploadedFiles[existingIndex] = { - name: file.name, - content: json - }; - showToast(t('common.info'), t('oauth.kiro.awsFileReplaced', { filename: file.name }), 'info'); - } else { - // 追加新文件 - uploadedFiles.push({ - name: file.name, - content: json - }); - } - } catch (error) { - console.error(`Failed to parse ${file.name}:`, error); - showToast(t('common.error'), t('oauth.kiro.awsParseError', { filename: file.name }), 'error'); - } - } - - // 重新渲染文件列表 - renderFilesList(); - - filesListDiv.style.display = uploadedFiles.length > 0 ? 'block' : 'none'; - - // 清空 file input 以便可以再次选择相同文件 - fileInput.value = ''; - - validateAndPreview(); - } - - // 渲染文件列表 - function renderFilesList() { - filesContainer.innerHTML = ''; - - for (const file of uploadedFiles) { - const fileDiv = document.createElement('div'); - fileDiv.style.cssText = 'display: flex; justify-content: space-between; align-items: center; padding: 8px; background: white; border-radius: 4px; margin-bottom: 4px;'; - fileDiv.dataset.filename = file.name; - - const fields = Object.keys(file.content).slice(0, 5).join(', '); - const moreFields = Object.keys(file.content).length > 5 ? '...' : ''; - - fileDiv.innerHTML = ` -
- - ${file.name} -
${fields}${moreFields}
-
- - `; - filesContainer.appendChild(fileDiv); - } - - // 添加删除文件按钮事件 - filesContainer.querySelectorAll('.remove-file-btn').forEach(btn => { - btn.addEventListener('click', (e) => { - const filename = e.currentTarget.dataset.filename; - uploadedFiles = uploadedFiles.filter(f => f.name !== filename); - renderFilesList(); - filesListDiv.style.display = uploadedFiles.length > 0 ? 'block' : 'none'; - validateAndPreview(); - }); - }); - } - - // 验证并预览(文件模式) - function validateAndPreview() { - if (currentMode !== 'file') return; - - if (uploadedFiles.length === 0) { - validationResult.style.display = 'none'; - jsonPreview.style.display = 'none'; - submitBtn.disabled = true; - mergedCredentials = null; - return; - } - - // 智能合并所有文件的内容 - // 如果多个文件都有 expiresAt,使用包含 refreshToken 的文件中的 expiresAt - mergedCredentials = {}; - let expiresAtFromRefreshTokenFile = null; - - for (const file of uploadedFiles) { - // 如果这个文件包含 refreshToken,记录它的 expiresAt - if (file.content.refreshToken && file.content.expiresAt) { - expiresAtFromRefreshTokenFile = file.content.expiresAt; - } - Object.assign(mergedCredentials, file.content); - } - - // 如果找到了包含 refreshToken 的文件的 expiresAt,使用它 - if (expiresAtFromRefreshTokenFile) { - mergedCredentials.expiresAt = expiresAtFromRefreshTokenFile; - } - - validateAndShowResult(); - } - - // 验证并显示结果(通用) - function validateAndShowResult() { - if (!mergedCredentials) { - validationResult.style.display = 'none'; - jsonPreview.style.display = 'none'; - submitBtn.disabled = true; - return; - } - - // 检查是否为批量导入(数组) - const isBatchImport = Array.isArray(mergedCredentials); - - if (isBatchImport) { - // 批量导入模式:验证数组中的每个对象 - let allValid = true; - const credentialsValidation = mergedCredentials.map((cred, index) => { - const hasClientId = !!cred.clientId; - const hasClientSecret = !!cred.clientSecret; - const hasAccessToken = !!cred.accessToken; - const hasRefreshToken = !!cred.refreshToken; - const isValid = hasClientId && hasClientSecret && hasAccessToken && hasRefreshToken; - - if (!isValid) allValid = false; - - return { - index: index + 1, - isValid, - fields: [ - { key: 'clientId', has: hasClientId }, - { key: 'clientSecret', has: hasClientSecret }, - { key: 'accessToken', has: hasAccessToken }, - { key: 'refreshToken', has: hasRefreshToken } - ] - }; - }); - - // 构建批量验证结果HTML - const credentialsHtml = credentialsValidation.map(cv => { - const statusIcon = cv.isValid ? '✓' : '✗'; - const statusColor = cv.isValid ? '#166534' : '#991b1b'; - const fieldsHtml = cv.fields.map(f => ` - ${f.key}: ${f.has - ? `` - : `` - } - `).join(''); - - return ` -
-
- ${statusIcon} 凭据 ${cv.index} -
-
- ${fieldsHtml} -
-
- `; - }).join(''); - - if (allValid) { - validationResult.style.cssText = 'display: block; margin-bottom: 16px; padding: 12px; border-radius: 8px; background: #f0fdf4; border: 1px solid #bbf7d0; color: #166534;'; - validationResult.innerHTML = ` -
- - 批量验证通过 (${mergedCredentials.length} 个凭据) -
-
- ${credentialsHtml} -
- `; - submitBtn.disabled = false; - } else { - const validCount = credentialsValidation.filter(cv => cv.isValid).length; - const invalidCount = credentialsValidation.length - validCount; - validationResult.style.cssText = 'display: block; margin-bottom: 16px; padding: 12px; border-radius: 8px; background: #fef2f2; border: 1px solid #fecaca; color: #991b1b;'; - validationResult.innerHTML = ` -
- - 批量验证失败 - (${invalidCount} 个凭据缺少必需字段) -
-
- ${credentialsHtml} -
-

- - 请确保每个凭据都包含所有必需字段:clientId, clientSecret, accessToken, refreshToken -

- `; - submitBtn.disabled = true; - } - - // 显示 JSON 预览(批量模式) - jsonPreview.style.display = 'block'; - const previewData = mergedCredentials.map(cred => { - const preview = { ...cred }; - if (preview.clientSecret) { - preview.clientSecret = preview.clientSecret.substring(0, 8) + '...' + preview.clientSecret.slice(-4); - } - if (preview.accessToken) { - preview.accessToken = preview.accessToken.substring(0, 20) + '...' + preview.accessToken.slice(-10); - } - if (preview.refreshToken) { - preview.refreshToken = preview.refreshToken.substring(0, 10) + '...' + preview.refreshToken.slice(-6); - } - return preview; - }); - jsonContent.textContent = JSON.stringify(previewData, null, 2); - - } else { - // 单个导入模式:原有逻辑 - const hasClientId = !!mergedCredentials.clientId; - const hasClientSecret = !!mergedCredentials.clientSecret; - const hasAccessToken = !!mergedCredentials.accessToken; - const hasRefreshToken = !!mergedCredentials.refreshToken; - - // 所有四个字段都必须存在 - const isValid = hasClientId && hasClientSecret && hasAccessToken && hasRefreshToken; - - // 构建字段状态列表 - const fieldsList = [ - { key: 'clientId', has: hasClientId }, - { key: 'clientSecret', has: hasClientSecret }, - { key: 'accessToken', has: hasAccessToken }, - { key: 'refreshToken', has: hasRefreshToken } - ]; - - const fieldsHtml = fieldsList.map(f => ` -
  • ${f.key}: ${f.has - ? `✓ ${t('common.found')}` - : `✗ ${t('common.missing')}` - }
  • - `).join(''); - - if (isValid) { - validationResult.style.cssText = 'display: block; margin-bottom: 16px; padding: 12px; border-radius: 8px; background: #f0fdf4; border: 1px solid #bbf7d0; color: #166534;'; - validationResult.innerHTML = ` -
    - - ${t('oauth.kiro.awsValidationSuccess')} -
    -
      - ${fieldsHtml} -
    - `; - submitBtn.disabled = false; - } else { - const missingCount = fieldsList.filter(f => !f.has).length; - validationResult.style.cssText = 'display: block; margin-bottom: 16px; padding: 12px; border-radius: 8px; background: #fef2f2; border: 1px solid #fecaca; color: #991b1b;'; - validationResult.innerHTML = ` -
    - - ${t('oauth.kiro.awsValidationFailed')} - (${t('oauth.kiro.awsMissingFields', { count: missingCount })}) -
    -
      - ${fieldsHtml} -
    -

    - - ${t('oauth.kiro.awsUploadMore')} -

    - `; - submitBtn.disabled = true; - } - - // 显示 JSON 预览(单个模式) - jsonPreview.style.display = 'block'; - - // 隐藏敏感信息的部分内容 - const previewData = { ...mergedCredentials }; - if (previewData.clientSecret) { - previewData.clientSecret = previewData.clientSecret.substring(0, 8) + '...' + previewData.clientSecret.slice(-4); - } - if (previewData.accessToken) { - previewData.accessToken = previewData.accessToken.substring(0, 20) + '...' + previewData.accessToken.slice(-10); - } - if (previewData.refreshToken) { - previewData.refreshToken = previewData.refreshToken.substring(0, 10) + '...' + previewData.refreshToken.slice(-6); - } - - jsonContent.textContent = JSON.stringify(previewData, null, 2); - } - } - - // 读取文件内容 - function readFileAsText(file) { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = (e) => resolve(e.target.result); - reader.onerror = (e) => reject(e); - reader.readAsText(file); - }); - } - - // 关闭按钮事件 - [closeBtn, cancelBtn].forEach(btn => { - btn.addEventListener('click', () => { - modal.remove(); - }); - }); - - // 提交按钮事件 - submitBtn.addEventListener('click', async () => { - if (!mergedCredentials) { - showToast(t('common.warning'), t('oauth.kiro.awsNoCredentials'), 'warning'); - return; - } - - // 检查是否为批量导入(数组) - const isBatchImport = Array.isArray(mergedCredentials); - - // 禁用按钮和输入 - submitBtn.disabled = true; - cancelBtn.disabled = true; - submitBtn.innerHTML = ` ${t('oauth.kiro.awsImporting')}`; - - if (currentMode === 'json') { - jsonInputTextarea.disabled = true; - } - - let importSuccess = false; // 标记是否导入成功 - - try { - if (isBatchImport) { - // 批量导入模式 - 使用 SSE 流式响应 - // 确保每个凭据都有 authMethod - const credentialsToImport = mergedCredentials.map(cred => ({ - ...cred, - authMethod: cred.authMethod || 'builder-id' - })); - - // 创建进度显示区域 - validationResult.style.cssText = 'display: block; margin-top: 16px; padding: 12px; border-radius: 8px; background: #f3f4f6; border: 1px solid #d1d5db;'; - validationResult.innerHTML = ` -
    - - ${t('oauth.kiro.importingProgress', { current: 0, total: credentialsToImport.length })} -
    -
    -
    -
    -
    - `; - - const progressText = validationResult.querySelector('#awsBatchProgressText'); - const progressBar = validationResult.querySelector('#awsImportProgressBar'); - const resultsList = validationResult.querySelector('#awsBatchResultsList'); - - // 使用 fetch + SSE 获取流式响应 - const response = await fetch('/api/kiro/import-aws-credentials', { - method: 'POST', - headers: window.apiClient ? window.apiClient.getAuthHeaders() : { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ credentials: credentialsToImport }) - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - - let successCount = 0; - let failedCount = 0; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - - buffer += decoder.decode(value, { stream: true }); - - // 解析 SSE 事件 - const lines = buffer.split('\n'); - buffer = lines.pop() || ''; - - let eventType = ''; - let eventData = ''; - - for (const line of lines) { - if (line.startsWith('event: ')) { - eventType = line.substring(7).trim(); - } else if (line.startsWith('data: ')) { - eventData = line.substring(6).trim(); - - if (eventType && eventData) { - try { - const data = JSON.parse(eventData); - - if (eventType === 'start') { - console.log(`[AWS Batch Import] Starting import of ${data.total} credentials`); - } else if (eventType === 'progress') { - const { index, total, current, successCount: sc, failedCount: fc } = data; - successCount = sc; - failedCount = fc; - - // 更新进度条 - const percentage = Math.round((index / total) * 100); - progressBar.style.width = `${percentage}%`; - - // 更新进度文本 - progressText.textContent = t('oauth.kiro.importingProgress', { current: index, total: total }); - - // 添加结果项 - const resultItem = document.createElement('div'); - resultItem.style.cssText = 'padding: 4px 0; border-bottom: 1px solid rgba(0,0,0,0.1);'; - - if (current.success) { - resultItem.innerHTML = `凭据 ${current.index}: ✓ ${current.path}`; - } else if (current.error === 'duplicate') { - resultItem.innerHTML = `凭据 ${current.index}: ⚠ ${t('oauth.kiro.duplicateCredentials')} - ${current.existingPath ? `(${current.existingPath})` : ''}`; - } else { - resultItem.innerHTML = `凭据 ${current.index}: ✗ ${current.error}`; - } - - resultsList.appendChild(resultItem); - resultsList.scrollTop = resultsList.scrollHeight; - - } else if (eventType === 'complete') { - progressBar.style.width = '100%'; - - const isAllSuccess = data.failedCount === 0; - const isAllFailed = data.successCount === 0; - - let resultClass, resultIcon, resultMessage; - if (isAllSuccess) { - resultClass = 'background: #f0fdf4; border: 1px solid #bbf7d0; color: #166534;'; - resultIcon = 'fa-check-circle'; - resultMessage = t('oauth.kiro.awsImportSuccess') + ` (${data.successCount})`; - } else if (isAllFailed) { - resultClass = 'background: #fef2f2; border: 1px solid #fecaca; color: #991b1b;'; - resultIcon = 'fa-times-circle'; - resultMessage = t('oauth.kiro.awsImportAllFailed', { count: data.failedCount }); - } else { - resultClass = 'background: #fffbeb; border: 1px solid #fde68a; color: #92400e;'; - resultIcon = 'fa-exclamation-triangle'; - resultMessage = t('oauth.kiro.importPartial', { success: data.successCount, failed: data.failedCount }); - } - - validationResult.style.cssText = `display: block; margin-top: 16px; padding: 12px; border-radius: 8px; ${resultClass}`; - - const headerDiv = validationResult.querySelector('div:first-child'); - headerDiv.innerHTML = ` ${resultMessage}`; - - // 如果有成功的,标记为成功并刷新提供商列表 - if (data.successCount > 0) { - importSuccess = true; - loadProviders(); - loadConfigList(); - } - - } else if (eventType === 'error') { - throw new Error(data.error); - } - } catch (parseError) { - console.warn('Failed to parse SSE data:', parseError); - } - - eventType = ''; - eventData = ''; - } - } - } - } - - } else { - // 单个导入模式 - // 确保 authMethod 为 builder-id(AWS 账号模式) - if (!mergedCredentials.authMethod) { - mergedCredentials.authMethod = 'builder-id'; - } - - const response = await window.apiClient.post('/kiro/import-aws-credentials', { - credentials: mergedCredentials - }); - - if (response.success) { - importSuccess = true; - showToast(t('common.success'), t('oauth.kiro.awsImportSuccess'), 'success'); - modal.remove(); - - // 刷新提供商列表和配置列表 - loadProviders(); - loadConfigList(); - } else if (response.error === 'duplicate') { - // 显示重复凭据警告 - const existingPath = response.existingPath || ''; - showToast(t('common.warning'), t('oauth.kiro.duplicateCredentials') + (existingPath ? ` (${existingPath})` : ''), 'warning'); - } else { - showToast(t('common.error'), response.error || t('oauth.kiro.awsImportFailed'), 'error'); - } - } - } catch (error) { - console.error('AWS import failed:', error); - - // 更新错误显示 - validationResult.style.cssText = 'display: block; margin-top: 16px; padding: 12px; border-radius: 8px; background: #fef2f2; border: 1px solid #fecaca; color: #991b1b;'; - validationResult.innerHTML = ` -
    - - ${t('oauth.kiro.awsImportFailed')}: ${error.message} -
    - `; - - showToast(t('common.error'), t('oauth.kiro.awsImportFailed') + ': ' + error.message, 'error'); - } finally { - // 取消按钮始终可用 - cancelBtn.disabled = false; - - // 只有在导入失败时才重新启用提交按钮 - if (!importSuccess) { - submitBtn.disabled = false; - submitBtn.innerHTML = ` ${t('oauth.kiro.awsConfirmImport')}`; - - if (currentMode === 'json') { - jsonInputTextarea.disabled = false; - } - } else { - // 导入成功后,保持提交按钮禁用状态,并显示成功图标 - submitBtn.innerHTML = ` ${t('common.success')}`; - } - } - }); -} - -/** - * 执行生成授权链接 - * @param {string} providerType - 提供商类型 - * @param {Object} extraOptions - 额外选项 - */ -async function executeGenerateAuthUrl(providerType, extraOptions = {}) { - try { - showToast(t('common.info'), t('modal.provider.auth.initializing'), 'info'); - - // 使用 fileUploadHandler 中的 getProviderKey 获取目录名称 - const providerDir = fileUploadHandler.getProviderKey(providerType); - - const response = await window.apiClient.post( - `/providers/${encodeURIComponent(providerType)}/generate-auth-url`, - { - saveToConfigs: true, - providerDir: providerDir, - ...extraOptions - } - ); - - if (response.success && response.authUrl) { - // 如果提供了 targetInputId,设置成功监听器 - if (extraOptions.targetInputId) { - const targetInputId = extraOptions.targetInputId; - const handleSuccess = (e) => { - const data = e.detail; - if (data.provider === providerType && data.relativePath) { - const input = document.getElementById(targetInputId); - if (input) { - input.value = data.relativePath; - input.dispatchEvent(new Event('input', { bubbles: true })); - showToast(t('common.success'), t('modal.provider.auth.success'), 'success'); - } - window.removeEventListener('oauth_success_event', handleSuccess); - } - }; - window.addEventListener('oauth_success_event', handleSuccess); - } - - // 显示授权信息模态框 - showAuthModal(response.authUrl, response.authInfo); - } else { - showToast(t('common.error'), t('modal.provider.auth.failed'), 'error'); - } - } catch (error) { - console.error('生成授权链接失败:', error); - showToast(t('common.error'), t('modal.provider.auth.failed') + `: ${error.message}`, 'error'); - } -} - -/** - * 获取提供商的授权文件路径 - * @param {string} provider - 提供商类型 - * @returns {string} 授权文件路径 - */ -function getAuthFilePath(provider) { - const authFilePaths = { - 'gemini-cli-oauth': '~/.gemini/oauth_creds.json', - 'gemini-antigravity': '~/.antigravity/oauth_creds.json', - 'openai-qwen-oauth': '~/.qwen/oauth_creds.json', - 'claude-kiro-oauth': '~/.aws/sso/cache/kiro-auth-token.json', - 'openai-iflow': '~/.iflow/oauth_creds.json' - }; - return authFilePaths[provider] || (getCurrentLanguage() === 'en-US' ? 'Unknown Path' : '未知路径'); -} - -/** - * 显示授权信息模态框 - * @param {string} authUrl - 授权URL - * @param {Object} authInfo - 授权信息 - */ -function showAuthModal(authUrl, authInfo) { - const modal = document.createElement('div'); - modal.className = 'modal-overlay'; - modal.style.display = 'flex'; - - // 获取授权文件路径 - const authFilePath = getAuthFilePath(authInfo.provider); - - // 获取需要开放的端口号(从 authInfo 或当前页面 URL) - const requiredPort = authInfo.callbackPort || authInfo.port || window.location.port || '3000'; - const isDeviceFlow = authInfo.provider === 'openai-qwen-oauth' || (authInfo.provider === 'claude-kiro-oauth' && authInfo.authMethod === 'builder-id'); - - let instructionsHtml = ''; - if (authInfo.provider === 'openai-qwen-oauth') { - instructionsHtml = ` -
    -

    ${t('oauth.modal.steps')}

    -
      -
    1. ${t('oauth.modal.step1')}
    2. -
    3. ${t('oauth.modal.step2.qwen')}
    4. -
    5. ${t('oauth.modal.step3')}
    6. -
    7. ${t('oauth.modal.step4.qwen', { min: Math.floor(authInfo.expiresIn / 60) })}
    8. -
    -
    - `; - } else if (authInfo.provider === 'claude-kiro-oauth') { - const methodDisplay = authInfo.authMethod === 'builder-id' ? 'AWS Builder ID' : `Social (${authInfo.socialProvider || 'Google'})`; - const methodAccount = authInfo.authMethod === 'builder-id' ? 'AWS Builder ID' : authInfo.socialProvider || 'Google'; - instructionsHtml = ` -
    -

    ${t('oauth.modal.steps')}

    -

    ${t('oauth.kiro.authMethodLabel')} ${methodDisplay}

    -
      -
    1. ${t('oauth.kiro.step1')}
    2. -
    3. ${t('oauth.kiro.step2', { method: methodAccount })}
    4. -
    5. ${t('oauth.kiro.step3')}
    6. -
    7. ${t('oauth.kiro.step4')}
    8. -
    -
    - `; - } else if (authInfo.provider === 'openai-iflow') { - instructionsHtml = ` -
    -

    ${t('oauth.modal.steps')}

    -
      -
    1. ${t('oauth.iflow.step1')}
    2. -
    3. ${t('oauth.iflow.step2')}
    4. -
    5. ${t('oauth.iflow.step3')}
    6. -
    7. ${t('oauth.iflow.step4')}
    8. -
    -
    - `; - } else { - instructionsHtml = ` -
    -

    ${t('oauth.modal.steps')}

    -
      -
    1. ${t('oauth.modal.step1')}
    2. -
    3. ${t('oauth.modal.step2.google')}
    4. -
    5. ${t('oauth.modal.step4.google')}
    6. -
    7. ${t('oauth.modal.step3')}
    8. -
    -
    - `; - } - - modal.innerHTML = ` - - `; - - document.body.appendChild(modal); - - // 关闭按钮事件 - const closeBtn = modal.querySelector('.modal-close'); - const cancelBtn = modal.querySelector('.modal-cancel'); - [closeBtn, cancelBtn].forEach(btn => { - btn.addEventListener('click', () => { - modal.remove(); - }); - }); - - // 重新生成按钮事件 - const regenerateBtn = modal.querySelector('.regenerate-port-btn'); - if (regenerateBtn) { - regenerateBtn.onclick = async () => { - const newPort = modal.querySelector('.auth-port-input').value; - if (newPort && newPort !== requiredPort) { - modal.remove(); - // 构造重新请求的参数 - const options = { ...authInfo, port: newPort }; - // 移除不需要传递回后端的字段 - delete options.provider; - delete options.redirectUri; - delete options.callbackPort; - - await executeGenerateAuthUrl(authInfo.provider, options); - } - }; - } - - // Builder ID Start URL 重新生成按钮事件 - const regenerateBuilderIdBtn = modal.querySelector('.regenerate-builder-id-btn'); - if (regenerateBuilderIdBtn) { - regenerateBuilderIdBtn.onclick = async () => { - const builderIdStartUrl = modal.querySelector('.builder-id-start-url-input').value.trim(); - const region = modal.querySelector('.builder-id-region-input').value.trim(); - modal.remove(); - // 构造重新请求的参数 - const options = { - ...authInfo, - builderIDStartURL: builderIdStartUrl || 'https://view.awsapps.com/start', - region: region || 'us-east-1' - }; - // 移除不需要传递回后端的字段 - delete options.provider; - delete options.redirectUri; - delete options.callbackPort; - - await executeGenerateAuthUrl(authInfo.provider, options); - }; - } - - // 复制链接按钮 - const copyBtn = modal.querySelector('.copy-btn'); - copyBtn.addEventListener('click', () => { - const input = modal.querySelector('.auth-url-input'); - input.select(); - document.execCommand('copy'); - showToast(t('common.success'), t('oauth.success.msg'), 'success'); - }); - - // 在浏览器中打开按钮 - const openBtn = modal.querySelector('.open-auth-btn'); - openBtn.addEventListener('click', () => { - // 使用子窗口打开,以便监听 URL 变化 - const width = 600; - const height = 700; - const left = (window.screen.width - width) / 2 + 600; - const top = (window.screen.height - height) / 2; - - const authWindow = window.open( - authUrl, - 'OAuthAuthWindow', - `width=${width},height=${height},left=${left},top=${top},status=no,resizable=yes,scrollbars=yes` - ); - - // 监听 OAuth 成功事件,自动关闭窗口和模态框 - const handleOAuthSuccess = () => { - if (authWindow && !authWindow.closed) { - authWindow.close(); - } - modal.remove(); - window.removeEventListener('oauth_success_event', handleOAuthSuccess); - - // 授权成功后刷新配置和提供商列表 - loadProviders(); - loadConfigList(); - }; - window.addEventListener('oauth_success_event', handleOAuthSuccess); - - if (authWindow) { - showToast(t('common.info'), t('oauth.window.opened'), 'info'); - - // 添加手动输入回调 URL 的 UI - const urlSection = modal.querySelector('.auth-url-section'); - if (urlSection && !modal.querySelector('.manual-callback-section')) { - const manualInputHtml = ` -
    -

    ${t('oauth.manual.title')}

    -

    ${t('oauth.manual.desc')}

    -
    - - -
    -
    - `; - urlSection.insertAdjacentHTML('afterend', manualInputHtml); - } - - const manualInput = modal.querySelector('.manual-callback-input'); - const applyBtn = modal.querySelector('.apply-callback-btn'); - - // 处理回调 URL 的核心逻辑 - const processCallback = (urlStr, isManualInput = false) => { - try { - // 尝试清理 URL(有些用户可能会复制多余的文字) - const cleanUrlStr = urlStr.trim().match(/https?:\/\/[^\s]+/)?.[0] || urlStr.trim(); - const url = new URL(cleanUrlStr); - - if (url.searchParams.has('code') || url.searchParams.has('token')) { - clearInterval(pollTimer); - // 构造本地可处理的 URL,只修改 hostname,保持原始 URL 的端口号不变 - const localUrl = new URL(url.href); - localUrl.hostname = window.location.hostname; - localUrl.protocol = window.location.protocol; - - showToast(t('common.info'), t('oauth.processing'), 'info'); - - // 如果是手动输入,直接通过 fetch 请求处理,然后关闭子窗口 - if (isManualInput) { - // 关闭子窗口 - if (authWindow && !authWindow.closed) { - authWindow.close(); - } - // 通过服务端API处理手动输入的回调URL - window.apiClient.post('/oauth/manual-callback', { - provider: authInfo.provider, - callbackUrl: url.href, //使用localhost访问 - authMethod: authInfo.authMethod - }) - .then(response => { - if (response.success) { - console.log('OAuth 回调处理成功'); - showToast(t('common.success'), t('oauth.success.msg'), 'success'); - } else { - console.error('OAuth 回调处理失败:', response.error); - showToast(t('common.error'), response.error || t('oauth.error.process'), 'error'); - } - }) - .catch(err => { - console.error('OAuth 回调请求失败:', err); - showToast(t('common.error'), t('oauth.error.process'), 'error'); - }); - } else { - // 自动监听模式:优先在子窗口中跳转(如果没关) - if (authWindow && !authWindow.closed) { - authWindow.location.href = localUrl.href; - } else { - // 备选方案:通过 fetch 请求 - // 通过 fetch 请求本地服务器处理回调 - fetch(localUrl.href) - .then(response => { - if (response.ok) { - console.log('OAuth 回调处理成功'); - } else { - console.error('OAuth 回调处理失败:', response.status); - } - }) - .catch(err => { - console.error('OAuth 回调请求失败:', err); - }); - } - } - - } else { - showToast(t('common.warning'), t('oauth.invalid.url'), 'warning'); - } - } catch (err) { - console.error('处理回调失败:', err); - showToast(t('common.error'), t('oauth.error.format'), 'error'); - } - }; - - applyBtn.addEventListener('click', () => { - processCallback(manualInput.value, true); - }); - - // 启动定时器轮询子窗口 URL - const pollTimer = setInterval(() => { - try { - if (authWindow.closed) { - clearInterval(pollTimer); - return; - } - // 如果能读到说明回到了同域 - const currentUrl = authWindow.location.href; - if (currentUrl && (currentUrl.includes('code=') || currentUrl.includes('token='))) { - processCallback(currentUrl); - } - } catch (e) { - // 跨域受限是正常的 - } - }, 1000); - } else { - showToast(t('common.error'), t('oauth.window.blocked'), 'error'); - } - }); - -} - -/** - * 显示需要重启的提示模态框 - * @param {string} version - 更新到的版本号 - */ -function showRestartRequiredModal(version) { - const modal = document.createElement('div'); - modal.className = 'modal-overlay restart-required-modal'; - modal.style.display = 'flex'; - - modal.innerHTML = ` - - `; - - document.body.appendChild(modal); - - // 关闭按钮事件 - const closeBtn = modal.querySelector('.modal-close'); - const confirmBtn = modal.querySelector('.restart-confirm-btn'); - - const closeModal = () => { - modal.remove(); - }; - - closeBtn.addEventListener('click', closeModal); - confirmBtn.addEventListener('click', closeModal); - - // 点击遮罩层关闭 - modal.addEventListener('click', (e) => { - if (e.target === modal) { - closeModal(); - } - }); -} - -/** - * 检查更新 - * @param {boolean} silent - 是否静默检查(不显示 Toast) - */ -async function checkUpdate(silent = false) { - const checkBtn = document.getElementById('checkUpdateBtn'); - const updateBtn = document.getElementById('performUpdateBtn'); - const updateBadge = document.getElementById('updateBadge'); - const latestVersionText = document.getElementById('latestVersionText'); - const checkBtnIcon = checkBtn?.querySelector('i'); - const checkBtnText = checkBtn?.querySelector('span'); - - try { - if (!silent && checkBtn) { - checkBtn.disabled = true; - if (checkBtnIcon) checkBtnIcon.className = 'fas fa-spinner fa-spin'; - if (checkBtnText) checkBtnText.textContent = t('dashboard.update.checking'); - } - - const data = await window.apiClient.get('/check-update'); - - if (data.hasUpdate) { - if (updateBtn) updateBtn.style.display = 'inline-flex'; - if (updateBadge) updateBadge.style.display = 'inline-flex'; - if (latestVersionText) latestVersionText.textContent = data.latestVersion; - - if (!silent) { - showToast(t('common.info'), t('dashboard.update.hasUpdate', { version: data.latestVersion }), 'info'); - } - } else { - if (updateBtn) updateBtn.style.display = 'none'; - if (updateBadge) updateBadge.style.display = 'none'; - if (!silent) { - showToast(t('common.info'), t('dashboard.update.upToDate'), 'success'); - } - } - } catch (error) { - console.error('Check update failed:', error); - if (!silent) { - showToast(t('common.error'), t('dashboard.update.failed', { error: error.message }), 'error'); - } - } finally { - if (checkBtn) { - checkBtn.disabled = false; - if (checkBtnIcon) checkBtnIcon.className = 'fas fa-sync-alt'; - if (checkBtnText) checkBtnText.textContent = t('dashboard.update.check'); - } - } -} - -/** - * 执行更新 - */ -async function performUpdate() { - const updateBtn = document.getElementById('performUpdateBtn'); - const latestVersionText = document.getElementById('latestVersionText'); - const version = latestVersionText?.textContent || ''; - - if (!confirm(t('dashboard.update.confirmMsg', { version }))) { - return; - } - - const updateBtnIcon = updateBtn?.querySelector('i'); - const updateBtnText = updateBtn?.querySelector('span'); - - try { - if (updateBtn) { - updateBtn.disabled = true; - if (updateBtnIcon) updateBtnIcon.className = 'fas fa-spinner fa-spin'; - if (updateBtnText) updateBtnText.textContent = t('dashboard.update.updating'); - } - - showToast(t('common.info'), t('dashboard.update.updating'), 'info'); - - const data = await window.apiClient.post('/update'); - - if (data.success) { - if (data.updated) { - // 代码已更新,直接调用重启服务 - showToast(t('common.success'), t('dashboard.update.success'), 'success'); - - // 自动重启服务 - await restartServiceAfterUpdate(); - } else { - // 已是最新版本 - showToast(t('common.info'), t('dashboard.update.upToDate'), 'info'); - } - } - } catch (error) { - console.error('Update failed:', error); - showToast(t('common.error'), t('dashboard.update.failed', { error: error.message }), 'error'); - } finally { - if (updateBtn) { - updateBtn.disabled = false; - if (updateBtnIcon) updateBtnIcon.className = 'fas fa-download'; - if (updateBtnText) updateBtnText.textContent = t('dashboard.update.perform'); - } - } -} - -/** - * 更新后自动重启服务 - */ -async function restartServiceAfterUpdate() { - try { - showToast(t('common.info'), t('header.restart.requesting'), 'info'); - - const token = localStorage.getItem('authToken'); - const response = await fetch('/api/restart-service', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': token ? `Bearer ${token}` : '' - } - }); - - const result = await response.json(); - - if (response.ok && result.success) { - showToast(t('common.success'), result.message || t('header.restart.success'), 'success'); - - // 如果是 worker 模式,服务会自动重启,等待几秒后刷新页面 - if (result.mode === 'worker') { - setTimeout(() => { - showToast(t('common.info'), t('header.restart.reconnecting'), 'info'); - // 等待服务重启后刷新页面 - setTimeout(() => { - window.location.reload(); - }, 3000); - }, 2000); - } - } else { - // 显示错误信息 - const errorMsg = result.message || result.error?.message || t('header.restart.failed'); - showToast(t('common.error'), errorMsg, 'error'); - - // 如果是独立模式,显示提示 - if (result.mode === 'standalone') { - showToast(t('common.info'), result.hint, 'warning'); - } - } - } catch (error) { - console.error('Restart after update failed:', error); - showToast(t('common.error'), t('header.restart.failed') + ': ' + error.message, 'error'); - } -} - -export { - loadSystemInfo, - updateTimeDisplay, - loadProviders, - renderProviders, - updateProviderStatsDisplay, - openProviderManager, - showAuthModal, - executeGenerateAuthUrl, - handleGenerateAuthUrl, - checkUpdate, - performUpdate -}; \ No newline at end of file diff --git a/static/app/routing-examples.js b/static/app/routing-examples.js deleted file mode 100644 index ed71b3b05f5bdd920cde80943345b3044c64284c..0000000000000000000000000000000000000000 --- a/static/app/routing-examples.js +++ /dev/null @@ -1,544 +0,0 @@ -// 路径路由示例功能模块 - -import { showToast } from './utils.js'; -import { t } from './i18n.js'; - -/** - * 初始化路径路由示例功能 - */ -function initRoutingExamples() { - // 延迟初始化,确保所有DOM都加载完成 - setTimeout(() => { - initProtocolTabs(); - initCopyButtons(); - initCardInteractions(); - }, 100); -} - -/** - * 初始化协议标签切换功能 - */ -function initProtocolTabs() { - // 使用事件委托方式绑定点击事件 - document.addEventListener('click', function(e) { - // 检查点击的是不是协议标签或者其子元素 - const tab = e.target.classList.contains('protocol-tab') ? e.target : e.target.closest('.protocol-tab'); - - if (tab) { - e.preventDefault(); - e.stopPropagation(); - - const targetProtocol = tab.dataset.protocol; - const card = tab.closest('.routing-example-card'); - - if (!card) { - return; - } - - // 移除当前卡片中所有标签和内容的活动状态 - const cardTabs = card.querySelectorAll('.protocol-tab'); - const cardContents = card.querySelectorAll('.protocol-content'); - - cardTabs.forEach(t => t.classList.remove('active')); - cardContents.forEach(c => c.classList.remove('active')); - - // 为当前标签和对应内容添加活动状态 - tab.classList.add('active'); - - // 使用更精确的选择器来查找对应的内容 - const targetContent = card.querySelector(`.protocol-content[data-protocol="${targetProtocol}"]`); - if (targetContent) { - targetContent.classList.add('active'); - } - } - }); -} - -/** - * 初始化复制按钮功能 - */ -function initCopyButtons() { - document.addEventListener('click', async function(e) { - if (e.target.closest('.copy-btn')) { - e.stopPropagation(); - - const button = e.target.closest('.copy-btn'); - const path = button.dataset.path; - if (!path) return; - - try { - await navigator.clipboard.writeText(path); - showToast(t('common.success'), `${t('common.success')}: ${path}`, 'success'); - - // 临时更改按钮图标 - const icon = button.querySelector('i'); - if (icon) { - const originalClass = icon.className; - icon.className = 'fas fa-check'; - button.style.color = 'var(--success-color)'; - - setTimeout(() => { - icon.className = originalClass; - button.style.color = ''; - }, 2000); - } - - } catch (error) { - console.error('Failed to copy to clipboard:', error); - showToast(t('common.error'), t('common.error'), 'error'); - } - } - }); -} - -/** - * 初始化卡片交互功能 - */ -function initCardInteractions() { - const routingCards = document.querySelectorAll('.routing-example-card'); - - routingCards.forEach(card => { - // 添加悬停效果 - card.addEventListener('mouseenter', () => { - card.style.transform = 'translateY(-4px)'; - card.style.boxShadow = 'var(--shadow-lg)'; - }); - - card.addEventListener('mouseleave', () => { - card.style.transform = ''; - card.style.boxShadow = ''; - }); - - }); -} - -/** - * 获取所有可用的路由端点 - * @returns {Array} 路由端点数组 - */ -function getAvailableRoutes() { - return [ - { - provider: 'forward-api', - name: 'NewAPI', - paths: { - openai: '/forward-api/v1/chat/completions', - claude: '/forward-api/v1/messages' - }, - description: t('dashboard.routing.official'), - badge: t('dashboard.routing.official'), - badgeClass: 'official' - }, - { - provider: 'claude-custom', - name: 'Claude Custom', - paths: { - openai: '/claude-custom/v1/chat/completions', - claude: '/claude-custom/v1/messages' - }, - description: t('dashboard.routing.official'), - badge: t('dashboard.routing.official'), - badgeClass: 'official' - }, - { - provider: 'claude-kiro-oauth', - name: 'Claude Kiro OAuth', - paths: { - openai: '/claude-kiro-oauth/v1/chat/completions', - claude: '/claude-kiro-oauth/v1/messages' - }, - description: t('dashboard.routing.free'), - badge: t('dashboard.routing.free'), - badgeClass: 'oauth' - }, - { - provider: 'openai-custom', - name: 'OpenAI Custom', - paths: { - openai: '/openai-custom/v1/chat/completions', - claude: '/openai-custom/v1/messages' - }, - description: t('dashboard.routing.official'), - badge: t('dashboard.routing.official'), - badgeClass: 'official' - }, - { - provider: 'gemini-cli-oauth', - name: 'Gemini CLI OAuth', - paths: { - openai: '/gemini-cli-oauth/v1/chat/completions', - claude: '/gemini-cli-oauth/v1/messages' - }, - description: t('dashboard.routing.oauth'), - badge: t('dashboard.routing.oauth'), - badgeClass: 'oauth' - }, - { - provider: 'gemini-antigravity', - name: 'Gemini Antigravity', - paths: { - openai: '/gemini-antigravity/v1/chat/completions', - claude: '/gemini-antigravity/v1/messages' - }, - description: t('dashboard.routing.experimental') || '实验性', - badge: t('dashboard.routing.experimental') || '实验性', - badgeClass: 'oauth' - }, - { - provider: 'openai-qwen-oauth', - name: 'Qwen OAuth', - paths: { - openai: '/openai-qwen-oauth/v1/chat/completions', - claude: '/openai-qwen-oauth/v1/messages' - }, - description: 'Qwen Code Plus', - badge: t('dashboard.routing.oauth'), - badgeClass: 'oauth' - }, - { - provider: 'openai-iflow', - name: 'iFlow OAuth', - paths: { - openai: '/openai-iflow/v1/chat/completions', - claude: '/openai-iflow/v1/messages' - }, - description: t('dashboard.routing.oauth'), - badge: t('dashboard.routing.oauth'), - badgeClass: 'oauth' - }, - { - provider: 'openai-codex-oauth', - name: 'OpenAI Codex OAuth', - paths: { - openai: '/openai-codex-oauth/v1/chat/completions', - claude: '/openai-codex-oauth/v1/messages' - }, - description: t('dashboard.routing.oauth'), - badge: t('dashboard.routing.oauth'), - badgeClass: 'oauth' - }, - { - provider: 'openaiResponses-custom', - name: 'OpenAI Responses', - paths: { - openai: '/openaiResponses-custom/v1/responses', - claude: '/openaiResponses-custom/v1/messages' - }, - description: '结构化对话API', - badge: 'Responses', - badgeClass: 'responses' - }, - { - provider: 'grok-custom', - name: 'Grok Reverse', - paths: { - openai: '/grok-custom/v1/chat/completions', - claude: '/grok-custom/v1/messages' - }, - description: t('dashboard.routing.free'), - badge: t('dashboard.routing.free'), - badgeClass: 'oauth' - } - ]; -} - -/** - * 高亮显示特定提供商路由 - * @param {string} provider - 提供商标识 - */ -function highlightProviderRoute(provider) { - const card = document.querySelector(`[data-provider="${provider}"]`); - if (card) { - card.scrollIntoView({ behavior: 'smooth', block: 'center' }); - card.style.borderColor = 'var(--success-color)'; - card.style.boxShadow = '0 0 0 3px rgba(16, 185, 129, 0.1)'; - - setTimeout(() => { - card.style.borderColor = ''; - card.style.boxShadow = ''; - }, 3000); - - showToast(t('common.success'), t('common.success') + `: ${provider}`, 'success'); - } -} - -/** - * 复制curl命令示例 - * @param {string} provider - 提供商标识 - * @param {Object} options - 选项参数 - */ -async function copyCurlExample(provider, options = {}) { - const routes = getAvailableRoutes(); - const route = routes.find(r => r.provider === provider); - - if (!route) { - showToast(t('common.error'), t('common.error'), 'error'); - return; - } - - const { protocol = 'openai', model = 'default-model', message = 'Hello!' } = options; - const path = route.paths[protocol]; - - if (!path) { - showToast(t('common.error'), t('common.error'), 'error'); - return; - } - - let curlCommand = ''; - - // 根据不同提供商和协议生成对应的curl命令 - switch (provider) { - case 'claude-custom': - case 'claude-kiro-oauth': - if (protocol === 'openai') { - curlCommand = `curl http://localhost:3000${path} \\ - -H "Content-Type: application/json" \\ - -H "Authorization: Bearer YOUR_API_KEY" \\ - -d '{ - "model": "${model}", - "messages": [{"role": "user", "content": "${message}"}], - "max_tokens": 1000 - }'`; - } else { - curlCommand = `curl http://localhost:3000${path} \\ - -H "Content-Type: application/json" \\ - -d '{ - "model": "${model}", - "max_tokens": 1000, - "messages": [{"role": "user", "content": "${message}"}] - }'`; - } - break; - - case 'openai-custom': - case 'openai-qwen-oauth': - if (protocol === 'openai') { - curlCommand = `curl http://localhost:3000${path} \\ - -H "Content-Type: application/json" \\ - -H "Authorization: Bearer YOUR_API_KEY" \\ - -d '{ - "model": "${model}", - "messages": [{"role": "user", "content": "${message}"}], - "max_tokens": 1000 - }'`; - } else { - curlCommand = `curl http://localhost:3000${path} \\ - -H "Content-Type: application/json" \\ - -H "X-API-Key: YOUR_API_KEY" \\ - -d '{ - "model": "${model}", - "max_tokens": 1000, - "messages": [{"role": "user", "content": "${message}"}] - }'`; - } - break; - - case 'gemini-cli-oauth': - if (protocol === 'openai') { - curlCommand = `curl http://localhost:3000${path} \\ - -H "Content-Type: application/json" \\ - -d '{ - "model": "gemini-3.1-pro-preview", - "messages": [{"role": "user", "content": "${message}"}], - "max_tokens": 1000 - }'`; - } else { - curlCommand = `curl http://localhost:3000${path} \\ - -H "Content-Type: application/json" \\ - -d '{ - "model": "gemini-3.1-pro-preview", - "max_tokens": 1000, - "messages": [{"role": "user", "content": "${message}"}] - }'`; - } - break; - - case 'openaiResponses-custom': - if (protocol === 'openai') { - curlCommand = `curl http://localhost:3000${path} \\ - -H "Content-Type: application/json" \\ - -H "Authorization: Bearer YOUR_API_KEY" \\ - -d '{ - "model": "${model}", - "input": "${message}", - "max_output_tokens": 1000 - }'`; - } else { - curlCommand = `curl http://localhost:3000${path} \\ - -H "Content-Type: application/json" \\ - -H "X-API-Key: YOUR_API_KEY" \\ - -d '{ - "model": "${model}", - "max_tokens": 1000, - "messages": [{"role": "user", "content": "${message}"}] - }'`; - } - break; - case 'grok-custom': - if (protocol === 'openai') { - curlCommand = `curl http://localhost:3000${path} \\ - -H "Content-Type: application/json" \\ - -H "Authorization: Bearer YOUR_API_KEY" \\ - -d '{ - "model": "grok-3", - "messages": [{"role": "user", "content": "${message}"}], - "stream": true - }'`; - } else { - curlCommand = `curl http://localhost:3000${path} \\ - -H "Content-Type: application/json" \\ - -H "X-API-Key: YOUR_API_KEY" \\ - -d '{ - "model": "grok-3", - "max_tokens": 1000, - "messages": [{"role": "user", "content": "${message}"}] - }'`; - } - break; - } - - try { - await navigator.clipboard.writeText(curlCommand); - showToast(t('common.success'), t('oauth.success.msg'), 'success'); - } catch (error) { - console.error('Failed to copy curl command:', error); - showToast(t('common.error'), t('common.error'), 'error'); - } -} - -/** - * 动态渲染路径路由示例 - * @param {Array} providerConfigs - 提供商配置列表 - */ -function renderRoutingExamples(providerConfigs) { - const container = document.querySelector('.routing-examples-grid'); - if (!container) return; - - container.innerHTML = ''; - - // 获取路由端点基础信息 - const routes = getAvailableRoutes(); - - // 图标映射 - const iconMap = { - 'forward-api': 'fa-share-square', - 'gemini-cli-oauth': 'fa-gem', - 'gemini-antigravity': 'fa-rocket', - 'openai-custom': 'fa-comments', - 'claude-custom': 'fa-brain', - 'claude-kiro-oauth': 'fa-robot', - 'openai-qwen-oauth': 'fa-code', - 'openaiResponses-custom': 'fa-comment-alt', - 'openai-iflow': 'fa-wind', - 'openai-codex-oauth': 'fa-keyboard', - 'grok-custom': 'fa-search' - }; - - // 默认模型映射 (用于 curl 示例) - const modelMap = { - 'gemini-cli-oauth': 'gemini-3-flash-preview', - 'gemini-antigravity': 'gemini-3-flash-preview', - 'claude-custom': 'claude-sonnet-4-6', - 'claude-kiro-oauth': 'claude-sonnet-4-6', - 'openai-custom': 'gpt-4o', - 'openai-qwen-oauth': 'qwen3-coder-plus', - 'openai-iflow': 'qwen3-max', - 'openai-codex-oauth': 'gpt-5', - 'grok-custom': 'grok-3', - 'openaiResponses-custom': 'gpt-4o' - }; - - providerConfigs.forEach(config => { - if (config.visible === false) return; - - let routeInfo = routes.find(r => r.provider === config.id); - - // 如果没找到,则创建一个默认的 - if (!routeInfo) { - routeInfo = { - provider: config.id, - name: config.name, - paths: { - openai: `/${config.id}/v1/chat/completions`, - claude: `/${config.id}/v1/messages` - }, - description: t('dashboard.routing.oauth'), - badge: t('dashboard.routing.oauth'), - badgeClass: 'oauth' - }; - } - - const icon = iconMap[config.id] || 'fa-route'; - const defaultModel = modelMap[config.id] || 'default-model'; - const hostname = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1' ? - `http://${window.location.host}` : - `${window.location.protocol}//${window.location.host}`; - - const card = document.createElement('div'); - card.className = 'routing-example-card'; - card.dataset.provider = `${config.id}-card`; - - card.innerHTML = ` -
    - -

    ${routeInfo.name}

    - ${routeInfo.badge} -
    -
    -
    - - -
    - -
    -
    - - ${routeInfo.paths.openai} -
    -
    - -
    curl ${hostname}${routeInfo.paths.openai} \\
    -  -H "Content-Type: application/json" \\
    -  -H "Authorization: Bearer YOUR_API_KEY" \\
    -  -d '{
    -    "model": "${defaultModel}",
    -    "messages": [{"role": "user", "content": "Hello!"}],
    -    "max_tokens": 1000
    -  }'
    -
    -
    - -
    -
    - - ${routeInfo.paths.claude} -
    -
    - -
    curl ${hostname}${routeInfo.paths.claude} \\
    -  -H "Content-Type: application/json" \\
    -  -H "X-API-Key: YOUR_API_KEY" \\
    -  -d '{
    -    "model": "${defaultModel}",
    -    "max_tokens": 1000,
    -    "messages": [{"role": "user", "content": "Hello!"}]
    -  }'
    -
    -
    -
    - `; - - container.appendChild(card); - }); - - // 重新初始化卡片交互 - initCardInteractions(); -} - -export { - initRoutingExamples, - getAvailableRoutes, - highlightProviderRoute, - copyCurlExample, - renderRoutingExamples -}; diff --git a/static/app/theme-switcher.js b/static/app/theme-switcher.js deleted file mode 100644 index 70dc42a8ecf3eb06d42961a9951364a47de519ca..0000000000000000000000000000000000000000 --- a/static/app/theme-switcher.js +++ /dev/null @@ -1,118 +0,0 @@ -/** - * 主题切换模块 - * 支持亮色/暗黑主题切换,并保存用户偏好到 localStorage - */ - -// 主题常量 -const THEME_KEY = 'theme'; -const THEME_LIGHT = 'light'; -const THEME_DARK = 'dark'; - -/** - * 获取当前主题 - * @returns {string} 当前主题 ('light' 或 'dark') - */ -export function getCurrentTheme() { - // 优先从 localStorage 获取 - const savedTheme = localStorage.getItem(THEME_KEY); - if (savedTheme) { - return savedTheme; - } - - // 检查系统偏好 - if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { - return THEME_DARK; - } - - return THEME_LIGHT; -} - -/** - * 设置主题 - * @param {string} theme - 主题名称 ('light' 或 'dark') - */ -export function setTheme(theme) { - const root = document.documentElement; - - if (theme === THEME_DARK) { - root.setAttribute('data-theme', THEME_DARK); - } else { - root.removeAttribute('data-theme'); - } - - // 保存到 localStorage - localStorage.setItem(THEME_KEY, theme); - - // 更新 meta theme-color - updateMetaThemeColor(theme); - - // 触发自定义事件 - window.dispatchEvent(new CustomEvent('themechange', { detail: { theme } })); -} - -/** - * 切换主题 - * @returns {string} 切换后的主题 - */ -export function toggleTheme() { - const currentTheme = getCurrentTheme(); - const newTheme = currentTheme === THEME_DARK ? THEME_LIGHT : THEME_DARK; - setTheme(newTheme); - return newTheme; -} - -/** - * 更新 meta theme-color - * @param {string} theme - 主题名称 - */ -function updateMetaThemeColor(theme) { - const metaThemeColor = document.querySelector('meta[name="theme-color"]'); - if (metaThemeColor) { - // 暗黑主题使用深色,亮色主题使用主色调 - metaThemeColor.setAttribute('content', theme === THEME_DARK ? '#1f2937' : '#059669'); - } -} - -/** - * 初始化主题切换器 - * @param {string} [toggleButtonId='themeToggleBtn'] - 切换按钮的 ID - */ -export function initThemeSwitcher(toggleButtonId = 'themeToggleBtn') { - // 应用保存的主题或系统偏好 - const savedTheme = getCurrentTheme(); - setTheme(savedTheme); - - // 绑定切换按钮事件 - const toggleBtn = document.getElementById(toggleButtonId); - if (toggleBtn) { - toggleBtn.addEventListener('click', () => { - const newTheme = toggleTheme(); - console.log(`主题已切换为: ${newTheme === THEME_DARK ? '暗黑模式' : '亮色模式'}`); - }); - } - - // 监听系统主题变化 - if (window.matchMedia) { - const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); - mediaQuery.addEventListener('change', (e) => { - // 只有在用户没有手动设置主题时才跟随系统 - const savedTheme = localStorage.getItem(THEME_KEY); - if (!savedTheme) { - setTheme(e.matches ? THEME_DARK : THEME_LIGHT); - } - }); - } - - console.log(`主题切换器已初始化,当前主题: ${savedTheme === THEME_DARK ? '暗黑模式' : '亮色模式'}`); -} - -/** - * 检查当前是否为暗黑主题 - * @returns {boolean} - */ -export function isDarkTheme() { - return getCurrentTheme() === THEME_DARK; -} - -// 导出常量 -export { THEME_LIGHT, THEME_DARK }; \ No newline at end of file diff --git a/static/app/tutorial-manager.js b/static/app/tutorial-manager.js deleted file mode 100644 index f3b01b87d9e6d23a2a2c791c1c48e2dffc509c28..0000000000000000000000000000000000000000 --- a/static/app/tutorial-manager.js +++ /dev/null @@ -1,57 +0,0 @@ -// 教程管理模块 -import { getProviderConfigs } from './utils.js'; - -// 提供商配置缓存 -let currentProviderConfigs = null; - -/** - * 初始化教程功能 - */ -function initTutorialManager() { - renderOauthPaths(); - - // 监听语言切换事件 - window.addEventListener('languageChanged', () => { - renderOauthPaths(currentProviderConfigs); - }); -} - -/** - * 更新提供商配置 - * @param {Array} configs - 提供商配置列表 - */ -function updateTutorialProviderConfigs(configs) { - currentProviderConfigs = configs; - renderOauthPaths(configs); -} - -/** - * 渲染 OAuth 授权路径列表 - * @param {Array} configs - 提供商配置列表(可选) - */ -function renderOauthPaths(configs = null) { - const oauthPathList = document.getElementById('oauthPathList'); - if (!oauthPathList) return; - - // 获取所有提供商配置 - const providers = configs || getProviderConfigs([]); - - // 过滤出有默认路径配置的提供商(即 OAuth 类提供商)且可见的 - const oauthProviders = providers.filter(p => p.defaultPath && p.visible !== false); - - oauthPathList.innerHTML = oauthProviders.map(p => ` -
    -
    - - ${p.name} -
    - ${p.defaultPath} -
    - `).join(''); -} - -export { - initTutorialManager, - renderOauthPaths, - updateTutorialProviderConfigs -}; diff --git a/static/app/upload-config-manager.js b/static/app/upload-config-manager.js deleted file mode 100644 index 967b26cd4d7633bbf54db940b8b540194cf8a2be..0000000000000000000000000000000000000000 --- a/static/app/upload-config-manager.js +++ /dev/null @@ -1,1357 +0,0 @@ -// 配置管理功能模块 - -import { showToast } from './utils.js'; -import { t } from './i18n.js'; - -let allConfigs = []; // 存储所有配置数据 -let filteredConfigs = []; // 存储过滤后的配置数据 -let isLoadingConfigs = false; // 防止重复加载配置 - -/** - * 搜索配置 - * @param {string} searchTerm - 搜索关键词 - * @param {string} statusFilter - 状态过滤 - */ -function searchConfigs(searchTerm = '', statusFilter = '', providerFilter = '') { - // 确保 searchTerm 是字符串,防止事件对象等非字符串被传入 - if (typeof searchTerm !== 'string') { - searchTerm = ''; - } - - if (!allConfigs.length) { - console.log('没有配置数据可搜索'); - return; - } - - filteredConfigs = allConfigs.filter(config => { - // 搜索过滤 - const matchesSearch = !searchTerm || - config.name.toLowerCase().includes(searchTerm.toLowerCase()) || - config.path.toLowerCase().includes(searchTerm.toLowerCase()) || - (config.content && config.content.toLowerCase().includes(searchTerm.toLowerCase())); - - // 状态过滤 - 从布尔值 isUsed 转换为状态字符串 - const configStatus = config.isUsed ? 'used' : 'unused'; - const matchesStatus = !statusFilter || configStatus === statusFilter; - - // 提供商类型过滤 - let matchesProvider = true; - if (providerFilter) { - const providerInfo = detectProviderFromPath(config.path); - if (providerFilter === 'other') { - // "其他/未识别" 选项:匹配没有识别到提供商的配置 - matchesProvider = providerInfo === null; - } else { - // 匹配特定提供商类型 - matchesProvider = providerInfo !== null && providerInfo.providerType === providerFilter; - } - } - - return matchesSearch && matchesStatus && matchesProvider; - }); - - renderConfigList(); - updateStats(); -} - -/** - * 渲染配置列表 - */ -function renderConfigList() { - const container = document.getElementById('configList'); - if (!container) return; - - container.innerHTML = ''; - - if (!filteredConfigs.length) { - container.innerHTML = `

    ${t('upload.noConfigs')}

    `; - return; - } - - filteredConfigs.forEach((config, index) => { - const configItem = createConfigItemElement(config, index); - container.appendChild(configItem); - }); -} - -/** - * 创建配置项元素 - * @param {Object} config - 配置数据 - * @param {number} index - 索引 - * @returns {HTMLElement} 配置项元素 - */ -function createConfigItemElement(config, index) { - // 从布尔值 isUsed 转换为状态字符串用于显示 - const configStatus = config.isUsed ? 'used' : 'unused'; - const item = document.createElement('div'); - item.className = `config-item-manager ${configStatus}`; - item.dataset.index = index; - - const statusIcon = config.isUsed ? 'fa-check-circle' : 'fa-circle-question'; - const statusText = config.isUsed ? t('upload.statusFilter.used') : t('upload.statusFilter.unused'); - - const typeIcon = config.type === 'oauth' ? 'fa-key' : - config.type === 'api-key' ? 'fa-lock' : - config.type === 'provider-pool' ? 'fa-network-wired' : - config.type === 'system-prompt' ? 'fa-file-text' : - config.type === 'plugins' ? 'fa-plug' : - config.type === 'usage' ? 'fa-chart-line' : - config.type === 'config' ? 'fa-cog' : - config.type === 'database' ? 'fa-database' : 'fa-file-code'; - - // 检测提供商信息 - const providerInfo = detectProviderFromPath(config.path); - const providerBadge = providerInfo ? - ` - ${providerInfo.displayName} - ` : ''; - - // 生成关联详情HTML - const usageInfoHtml = generateUsageInfoHtml(config); - - // 获取关联的节点简要信息 - let linkedNodesInfo = ''; - if (config.isUsed && config.usageInfo && config.usageInfo.usageDetails) { - const details = config.usageInfo.usageDetails; - - // 收集节点信息及其状态 - const nodes = details.map(d => { - let name = ''; - let isPool = false; - if (d.type === 'Provider Pool' || d.type === '提供商池') { - isPool = true; - if (d.nodeName) name = d.nodeName; - else if (d.uuid) name = d.uuid.substring(0, 8); - else name = d.location; - } else if (d.type === 'Main Config' || d.type === '主要配置') { - name = t('upload.usage.mainConfig'); - } - - if (!name) return null; - - return { - name, - isPool, - isHealthy: d.isHealthy, - isDisabled: d.isDisabled - }; - }).filter(Boolean); - - if (nodes.length > 0) { - // 去重,但保留状态信息(如果有多个相同名称的节点,状态可能不同,这里按名称去重以节省空间,取第一个) - const uniqueNodes = []; - const seenNames = new Set(); - for (const node of nodes) { - if (!seenNames.has(node.name)) { - uniqueNodes.push(node); - seenNames.add(node.name); - } - } - - linkedNodesInfo = `
    - ${uniqueNodes.map(node => { - let statusClass = ''; - let statusIcon = 'fa-link'; - - if (node.isPool) { - if (node.isDisabled) { - statusClass = 'status-disabled'; - statusIcon = 'fa-ban'; - } else if (!node.isHealthy) { - statusClass = 'status-unhealthy'; - statusIcon = 'fa-exclamation-circle'; - } else { - statusClass = 'status-healthy'; - statusIcon = 'fa-check-circle'; - } - } - - return ` ${node.name}`; - }).join('')} -
    `; - } - } - - // 判断是否可以一键关联(未关联且路径包含支持的提供商目录) - const canQuickLink = !config.isUsed && providerInfo !== null; - const quickLinkBtnHtml = canQuickLink ? - `` : ''; - - item.innerHTML = ` -
    -
    -
    - -
    -
    -
    - ${config.name} - ${providerBadge} -
    -
    - ${config.path} -
    -
    -
    - -
    -
    - - ${formatFileSize(config.size)} - - - ${formatDate(config.modified)} - -
    -
    - -
    -
    -
    - - ${statusText} -
    - ${linkedNodesInfo} - ${quickLinkBtnHtml} -
    -
    - -
    -
    -
    - -
    -
    -
    -
    文件完整路径
    -
    ${config.path}
    -
    -
    -
    文件大小
    -
    ${formatFileSize(config.size)}
    -
    -
    -
    最后修改时间
    -
    ${formatDate(config.modified)}
    -
    -
    -
    当前关联状态
    -
    ${statusText}
    -
    -
    - ${usageInfoHtml} -
    - - - -
    -
    - `; - - // 添加按钮事件监听器 - const viewBtn = item.querySelector('.btn-view'); - const downloadBtn = item.querySelector('.btn-download'); - const deleteBtn = item.querySelector('.btn-delete-small'); - - if (viewBtn) { - viewBtn.addEventListener('click', (e) => { - e.stopPropagation(); - viewConfig(config.path); - }); - } - - if (downloadBtn) { - downloadBtn.addEventListener('click', (e) => { - e.stopPropagation(); - downloadSingleConfig(config.path); - }); - } - - if (deleteBtn) { - deleteBtn.addEventListener('click', (e) => { - e.stopPropagation(); - deleteConfig(config.path); - }); - } - - // 一键关联按钮事件 - const quickLinkBtn = item.querySelector('.btn-quick-link-main'); - if (quickLinkBtn) { - quickLinkBtn.addEventListener('click', (e) => { - e.stopPropagation(); - quickLinkProviderConfig(config.path); - }); - } - - // 添加点击事件展开/折叠详情 - item.addEventListener('click', (e) => { - if (!e.target.closest('.config-item-actions') && !e.target.closest('.config-detail-value')) { - item.classList.toggle('expanded'); - } - }); - - // 点击路径复制 - const pathValueEl = item.querySelector('.path-item .config-detail-value'); - if (pathValueEl) { - pathValueEl.addEventListener('click', async (e) => { - e.stopPropagation(); - const textToCopy = config.path; - - // 优先使用 Clipboard API - if (navigator.clipboard && navigator.clipboard.writeText) { - try { - await navigator.clipboard.writeText(textToCopy); - showToast(t('common.success'), t('common.copy.success'), 'success'); - } catch (err) { - console.error('Clipboard API failed:', err); - fallbackCopyTextToClipboard(textToCopy); - } - } else { - fallbackCopyTextToClipboard(textToCopy); - } - }); - pathValueEl.title = t('models.clickToCopy') || '点击复制'; - } - - return item; -} - -/** - * 降级复制方案 - * @param {string} text - 要复制的文本 - */ -function fallbackCopyTextToClipboard(text) { - const textArea = document.createElement("textarea"); - textArea.value = text; - - // 确保不可见且不影响布局 - textArea.style.position = "fixed"; - textArea.style.left = "-9999px"; - textArea.style.top = "0"; - document.body.appendChild(textArea); - - textArea.focus(); - textArea.select(); - - try { - const successful = document.execCommand('copy'); - if (successful) { - showToast(t('common.success'), t('common.copy.success'), 'success'); - } else { - showToast(t('common.error'), t('common.copy.failed'), 'error'); - } - } catch (err) { - console.error('Fallback copy failed:', err); - showToast(t('common.error'), t('common.copy.failed'), 'error'); - } - - document.body.removeChild(textArea); -} - -/** - * 生成关联详情HTML - * @param {Object} config - 配置数据 - * @returns {string} HTML字符串 - */ -function generateUsageInfoHtml(config) { - if (!config.usageInfo || !config.usageInfo.isUsed) { - return ''; - } - - const { usageType, usageDetails } = config.usageInfo; - - if (!usageDetails || usageDetails.length === 0) { - return ''; - } - - const typeLabels = { - 'main_config': t('upload.usage.mainConfig'), - 'provider_pool': t('upload.usage.providerPool'), - 'multiple': t('upload.usage.multiple') - }; - - const typeLabel = typeLabels[usageType] || (t('common.info') === 'Info' ? 'Unknown' : '未知用途'); - - let detailsHtml = ''; - usageDetails.forEach(detail => { - const isMain = detail.type === '主要配置' || detail.type === 'Main Config'; - const icon = isMain ? 'fa-cog' : 'fa-network-wired'; - const usageTypeKey = isMain ? 'main_config' : 'provider_pool'; - - // 严格遵循显示优先级:自定义名称 > UUID > 默认位置描述 - let displayTitle = ''; - let subtitle = ''; - - if (detail.nodeName) { - displayTitle = detail.nodeName; - subtitle = detail.providerType ? `${detail.providerType} - ${detail.location}` : detail.location; - } else if (detail.uuid) { - displayTitle = detail.uuid; - subtitle = detail.providerType ? `${detail.providerType} - ${detail.location}` : detail.location; - } else { - displayTitle = detail.location; - subtitle = detail.providerType || ''; - } - - // 生成节点状态标签 - let statusTag = ''; - if (detail.type === 'Provider Pool' || detail.type === '提供商池') { - if (detail.isDisabled) { - statusTag = `${t('modal.provider.status.disabled')}`; - } else if (!detail.isHealthy) { - statusTag = `${t('modal.provider.status.unhealthy')}`; - } else { - statusTag = `${t('modal.provider.status.healthy')}`; - } - } - - detailsHtml += ` -
    - -
    -
    - ${detail.type} - ${displayTitle} - ${statusTag} -
    - ${subtitle ? `
    ${subtitle}
    ` : ''} -
    -
    - `; - }); - - return ` -
    -
    - - 关联详情 (${typeLabel}) -
    -
    - ${detailsHtml} -
    -
    - `; -} - -/** - * 对配置列表进行排序 - * 规则:未关联的排在前面,然后按修改时间倒序排列 - * @param {Array} configs - 配置列表 - * @returns {Array} 排序后的列表 - */ -function sortConfigs(configs) { - if (!configs || !configs.length) return []; - - return configs.sort((a, b) => { - // 1. 未关联优先 (isUsed 为 false 的排在前面) - if (a.isUsed !== b.isUsed) { - return a.isUsed ? 1 : -1; - } - - // 2. 时间倒序 (最新的排在前面) - const dateA = new Date(a.modified); - const dateB = new Date(b.modified); - return dateB - dateA; - }); -} - -/** - * 格式化文件大小 - * @param {number} bytes - 字节数 - * @returns {string} 格式化后的大小 - */ -function formatFileSize(bytes) { - if (bytes === 0) return '0 B'; - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; -} - -/** - * 格式化日期 - * @param {string} dateString - 日期字符串 - * @returns {string} 格式化后的日期 - */ -function formatDate(dateString) { - const date = new Date(dateString); - return date.toLocaleString('zh-CN', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit' - }); -} - -/** - * 更新统计信息 - */ -function updateStats() { - const totalCount = filteredConfigs.length; - const usedCount = filteredConfigs.filter(config => config.isUsed).length; - const unusedCount = filteredConfigs.filter(config => !config.isUsed).length; - - const totalEl = document.getElementById('configCount'); - const usedEl = document.getElementById('usedConfigCount'); - const unusedEl = document.getElementById('unusedConfigCount'); - - if (totalEl) { - totalEl.textContent = t('upload.count', { count: totalCount }); - totalEl.setAttribute('data-i18n-params', JSON.stringify({ count: totalCount.toString() })); - } - if (usedEl) { - usedEl.textContent = t('upload.usedCount', { count: usedCount }); - usedEl.setAttribute('data-i18n-params', JSON.stringify({ count: usedCount.toString() })); - } - if (unusedEl) { - unusedEl.textContent = t('upload.unusedCount', { count: unusedCount }); - unusedEl.setAttribute('data-i18n-params', JSON.stringify({ count: unusedCount.toString() })); - } -} - -/** - * 加载配置文件列表 - * @param {string} searchTerm - 搜索关键词 - * @param {string} statusFilter - 状态过滤 - * @param {string} providerFilter - 提供商过滤 - */ -async function loadConfigList(searchTerm = '', statusFilter = '', providerFilter = '') { - // 确保 searchTerm 是字符串,处理事件监听器直接调用的情况 - if (typeof searchTerm !== 'string') { - searchTerm = ''; - } - - // 防止重复加载 - if (isLoadingConfigs) { - console.log('正在加载配置列表,跳过重复调用'); - return; - } - - isLoadingConfigs = true; - console.log('开始加载配置列表...'); - - try { - const result = await window.apiClient.get('/upload-configs'); - allConfigs = sortConfigs(result); - - // 如果提供了过滤参数,则执行搜索过滤,否则显示全部 - if (searchTerm || statusFilter || providerFilter) { - searchConfigs(searchTerm, statusFilter, providerFilter); - } else { - filteredConfigs = [...allConfigs]; - renderConfigList(); - updateStats(); - } - - console.log('配置列表加载成功,共', allConfigs.length, '个项目'); - } catch (error) { - console.error('加载配置列表失败:', error); - showToast(t('common.error'), t('common.error') + ': ' + error.message, 'error'); - allConfigs = []; - filteredConfigs = []; - renderConfigList(); - updateStats(); - } finally { - isLoadingConfigs = false; - console.log('配置列表加载完成'); - } -} - -/** - * 下载单个配置文件 - * @param {string} filePath - 文件路径 - */ -async function downloadSingleConfig(filePath) { - if (!filePath) return; - - try { - const fileName = filePath.split(/[/\\]/).pop(); - - const token = localStorage.getItem('authToken'); - const headers = { - 'Authorization': token ? `Bearer ${token}` : '' - }; - - const response = await fetch(`/api/upload-configs/download/${encodeURIComponent(filePath)}`, { - method: 'GET', - headers: headers - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - - const blob = await response.blob(); - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = fileName; - document.body.appendChild(a); - a.click(); - window.URL.revokeObjectURL(url); - document.body.removeChild(a); - - showToast(t('common.success'), t('usage.card.downloadSuccess') || '文件下载成功', 'success'); - } catch (error) { - console.error('下载配置文件失败:', error); - showToast(t('common.error'), (t('usage.card.downloadFailed') || '下载配置文件失败') + ': ' + error.message, 'error'); - } -} - -/** - * 查看配置 - * @param {string} path - 文件路径 - */ -async function viewConfig(path) { - try { - const fileData = await window.apiClient.get(`/upload-configs/view/${encodeURIComponent(path)}`); - showConfigModal(fileData); - } catch (error) { - console.error('查看配置失败:', error); - showToast(t('common.error'), t('upload.action.view.failed') + ': ' + error.message, 'error'); - } -} - -/** - * 显示配置模态框 - * @param {Object} fileData - 文件数据 - */ -function showConfigModal(fileData) { - // 创建模态框 - const modal = document.createElement('div'); - modal.className = 'config-view-modal'; - modal.innerHTML = ` -
    -
    -

    ${t('nav.config')}: ${fileData.name}

    - -
    -
    -
    -
    - ${t('upload.detail.path')}: - ${fileData.path} -
    -
    - ${t('upload.detail.size')}: - ${formatFileSize(fileData.size)} -
    -
    - ${t('upload.detail.modified')}: - ${formatDate(fileData.modified)} -
    -
    -
    - -
    ${escapeHtml(fileData.content)}
    -
    -
    - -
    - `; - - // 添加到页面 - document.body.appendChild(modal); - - // 添加按钮事件监听器 - const closeBtn = modal.querySelector('.btn-close-modal'); - const copyBtn = modal.querySelector('.btn-copy-content'); - const modalCloseBtn = modal.querySelector('.modal-close'); - - if (closeBtn) { - closeBtn.addEventListener('click', () => { - closeConfigModal(); - }); - } - - if (copyBtn) { - copyBtn.addEventListener('click', () => { - const path = copyBtn.dataset.path; - copyConfigContent(path); - }); - } - - if (modalCloseBtn) { - modalCloseBtn.addEventListener('click', () => { - closeConfigModal(); - }); - } - - // 显示模态框 - setTimeout(() => modal.classList.add('show'), 10); -} - -/** - * 关闭配置模态框 - */ -function closeConfigModal() { - const modal = document.querySelector('.config-view-modal'); - if (modal) { - modal.classList.remove('show'); - setTimeout(() => modal.remove(), 300); - } -} - -/** - * 复制配置内容 - * @param {string} path - 文件路径 - */ -async function copyConfigContent(path) { - try { - const fileData = await window.apiClient.get(`/upload-configs/view/${encodeURIComponent(path)}`); - const textToCopy = fileData.content; - - // 优先使用 Clipboard API - if (navigator.clipboard && navigator.clipboard.writeText) { - try { - await navigator.clipboard.writeText(textToCopy); - showToast(t('common.success'), t('common.copy.success'), 'success'); - } catch (err) { - console.error('Clipboard API failed:', err); - fallbackCopyTextToClipboard(textToCopy); - } - } else { - fallbackCopyTextToClipboard(textToCopy); - } - } catch (error) { - console.error('复制失败:', error); - showToast(t('common.error'), t('common.copy.failed') + ': ' + error.message, 'error'); - } -} - -/** - * HTML转义 - * @param {string} text - 要转义的文本 - * @returns {string} 转义后的文本 - */ -function escapeHtml(text) { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; -} - -/** - * 显示删除确认模态框 - * @param {Object} config - 配置数据 - */ -function showDeleteConfirmModal(config) { - const isUsed = config.isUsed; - const modalClass = isUsed ? 'delete-confirm-modal used' : 'delete-confirm-modal unused'; - const title = isUsed ? t('upload.delete.confirmTitleUsed') : t('upload.delete.confirmTitle'); - const icon = isUsed ? 'fas fa-exclamation-triangle' : 'fas fa-trash'; - const buttonClass = isUsed ? 'btn btn-danger' : 'btn btn-warning'; - - const modal = document.createElement('div'); - modal.className = modalClass; - - modal.innerHTML = ` -
    -
    -

    ${title}

    - -
    -
    -
    -
    - -
    -
    - ${isUsed ? - `

    ${t('upload.delete.warningUsedTitle')}

    ${t('upload.delete.warningUsedDesc')}

    ` : - `

    ${t('upload.delete.warningUnusedTitle')}

    ${t('upload.delete.warningUnusedDesc')}

    ` - } -
    -
    - -
    -
    - 文件名: - ${config.name} -
    -
    - 文件路径: - ${config.path} -
    -
    - 文件大小: - ${formatFileSize(config.size)} -
    -
    - 关联状态: - - ${isUsed ? t('upload.statusFilter.used') : t('upload.statusFilter.unused')} - -
    -
    - - ${isUsed ? ` -
    -
    - -
    -
    -
    ${t('upload.delete.usageAlertTitle')}
    -

    ${t('upload.delete.usageAlertDesc')}

    -
      -
    • ${t('upload.delete.usageAlertItem1')}
    • -
    • ${t('upload.delete.usageAlertItem2')}
    • -
    • ${t('upload.delete.usageAlertItem3')}
    • -
    -

    ${t('upload.delete.usageAlertAdvice')}

    -
    -
    - ` : ''} -
    - -
    - `; - - // 添加到页面 - document.body.appendChild(modal); - - // 添加事件监听器 - const closeBtn = modal.querySelector('.modal-close'); - const cancelBtn = modal.querySelector('.btn-cancel-delete'); - const confirmBtn = modal.querySelector('.btn-confirm-delete'); - - const closeModal = () => { - modal.classList.remove('show'); - setTimeout(() => modal.remove(), 300); - }; - - if (closeBtn) { - closeBtn.addEventListener('click', closeModal); - } - - if (cancelBtn) { - cancelBtn.addEventListener('click', closeModal); - } - - if (confirmBtn) { - confirmBtn.addEventListener('click', () => { - const path = confirmBtn.dataset.path; - performDelete(path); - closeModal(); - }); - } - - // 点击外部关闭 - modal.addEventListener('click', (e) => { - if (e.target === modal) { - closeModal(); - } - }); - - // ESC键关闭 - const handleEsc = (e) => { - if (e.key === 'Escape') { - closeModal(); - document.removeEventListener('keydown', handleEsc); - } - }; - document.addEventListener('keydown', handleEsc); - - // 显示模态框 - setTimeout(() => modal.classList.add('show'), 10); -} - -/** - * 执行删除操作 - * @param {string} path - 文件路径 - */ -async function performDelete(path) { - try { - const result = await window.apiClient.delete(`/upload-configs/delete/${encodeURIComponent(path)}`); - showToast(t('common.success'), result.message, 'success'); - - // 从本地列表中移除 - allConfigs = allConfigs.filter(c => c.path !== path); - filteredConfigs = filteredConfigs.filter(c => c.path !== path); - renderConfigList(); - updateStats(); - } catch (error) { - console.error('删除配置失败:', error); - showToast(t('common.error'), t('upload.action.delete.failed') + ': ' + error.message, 'error'); - } -} - -/** - * 删除配置 - * @param {string} path - 文件路径 - */ -async function deleteConfig(path) { - const config = filteredConfigs.find(c => c.path === path) || allConfigs.find(c => c.path === path); - if (!config) { - showToast(t('common.error'), t('upload.config.notExist'), 'error'); - return; - } - - // 显示删除确认模态框 - showDeleteConfirmModal(config); -} - -/** - * 初始化配置管理页面 - */ -function initUploadConfigManager() { - // 绑定搜索事件 - const searchInput = document.getElementById('configSearch'); - const searchBtn = document.getElementById('searchConfigBtn'); - const statusFilter = document.getElementById('configStatusFilter'); - const providerFilter = document.getElementById('configProviderFilter'); - const refreshBtn = document.getElementById('refreshConfigList'); - const downloadAllBtn = document.getElementById('downloadAllConfigs'); - - if (searchInput) { - searchInput.addEventListener('input', debounce(() => { - const searchTerm = searchInput.value.trim(); - const currentStatusFilter = statusFilter?.value || ''; - const currentProviderFilter = providerFilter?.value || ''; - searchConfigs(searchTerm, currentStatusFilter, currentProviderFilter); - }, 300)); - } - - if (searchBtn) { - searchBtn.addEventListener('click', () => { - const searchTerm = searchInput?.value.trim() || ''; - const currentStatusFilter = statusFilter?.value || ''; - const currentProviderFilter = providerFilter?.value || ''; - // 点击搜索按钮时,调接口刷新数据 - loadConfigList(searchTerm, currentStatusFilter, currentProviderFilter); - }); - } - - if (statusFilter) { - statusFilter.addEventListener('change', () => { - const searchTerm = searchInput?.value.trim() || ''; - const currentStatusFilter = statusFilter.value; - const currentProviderFilter = providerFilter?.value || ''; - searchConfigs(searchTerm, currentStatusFilter, currentProviderFilter); - }); - } - - if (providerFilter) { - providerFilter.addEventListener('change', () => { - const searchTerm = searchInput?.value.trim() || ''; - const currentStatusFilter = statusFilter?.value || ''; - const currentProviderFilter = providerFilter.value; - searchConfigs(searchTerm, currentStatusFilter, currentProviderFilter); - }); - } - - if (refreshBtn) { - refreshBtn.addEventListener('click', () => loadConfigList()); - } - - if (downloadAllBtn) { - downloadAllBtn.addEventListener('click', downloadAllConfigs); - } - - // 批量关联配置按钮 - const batchLinkBtn = document.getElementById('batchLinkKiroBtn') || document.getElementById('batchLinkProviderBtn'); - if (batchLinkBtn) { - batchLinkBtn.addEventListener('click', batchLinkProviderConfigs); - } - - // 删除未绑定配置按钮 - const deleteUnboundBtn = document.getElementById('deleteUnboundBtn'); - if (deleteUnboundBtn) { - deleteUnboundBtn.addEventListener('click', deleteUnboundConfigs); - } - - // 初始加载配置列表 - loadConfigList(); -} - -/** - * 重新加载配置文件 - */ -async function reloadConfig() { - // 防止重复重载 - if (isLoadingConfigs) { - console.log('正在重载配置,跳过重复调用'); - return; - } - - try { - const result = await window.apiClient.post('/reload-config'); - showToast(t('common.success'), result.message, 'success'); - - // 重新加载配置列表以反映最新的关联状态 - await loadConfigList(); - - // 注意:不再发送 configReloaded 事件,避免重复调用 - // window.dispatchEvent(new CustomEvent('configReloaded', { - // detail: result.details - // })); - - } catch (error) { - console.error('重载配置失败:', error); - showToast(t('common.error'), t('common.refresh.failed') + ': ' + error.message, 'error'); - } -} - -/** - * 根据文件路径检测对应的提供商类型 - * @param {string} filePath - 文件路径 - * @returns {Object|null} 提供商信息对象或null - */ -function detectProviderFromPath(filePath) { - const normalizedPath = filePath.replace(/\\/g, '/').toLowerCase(); - - // 定义目录到提供商的映射关系 - const providerMappings = [ - { - patterns: ['configs/kiro/', '/kiro/'], - providerType: 'claude-kiro-oauth', - displayName: 'Claude Kiro OAuth', - shortName: 'kiro-oauth' - }, - { - patterns: ['configs/gemini/', '/gemini/', 'configs/gemini-cli/'], - providerType: 'gemini-cli-oauth', - displayName: 'Gemini CLI OAuth', - shortName: 'gemini-oauth' - }, - { - patterns: ['configs/qwen/', '/qwen/'], - providerType: 'openai-qwen-oauth', - displayName: 'Qwen OAuth', - shortName: 'qwen-oauth' - }, - { - patterns: ['configs/antigravity/', '/antigravity/'], - providerType: 'gemini-antigravity', - displayName: 'Gemini Antigravity', - shortName: 'antigravity' - }, - { - patterns: ['configs/codex/', '/codex/'], - providerType: 'openai-codex-oauth', - displayName: 'OpenAI Codex OAuth', - shortName: 'codex-oauth' - }, - { - patterns: ['configs/iflow/', '/iflow/'], - providerType: 'openai-iflow', - displayName: 'OpenAI iFlow OAuth', - shortName: 'iflow-oauth' - } - ]; - - // 遍历映射关系,查找匹配的提供商 - for (const mapping of providerMappings) { - for (const pattern of mapping.patterns) { - if (normalizedPath.includes(pattern)) { - return { - providerType: mapping.providerType, - displayName: mapping.displayName, - shortName: mapping.shortName - }; - } - } - } - - return null; -} - -/** - * 一键关联配置到对应的提供商 - * @param {string} filePath - 配置文件路径 - */ -async function quickLinkProviderConfig(filePath) { - try { - const providerInfo = detectProviderFromPath(filePath); - if (!providerInfo) { - showToast(t('common.error'), t('upload.link.failed.identify'), 'error'); - return; - } - - showToast(t('common.info'), t('upload.link.processing', { name: providerInfo.displayName }), 'info'); - - const result = await window.apiClient.post('/quick-link-provider', { - filePath: filePath - }); - - showToast(t('common.success'), result.message || t('upload.link.success'), 'success'); - - // 刷新配置列表 - await loadConfigList(); - } catch (error) { - console.error('一键关联失败:', error); - showToast(t('common.error'), t('upload.link.failed') + ': ' + error.message, 'error'); - } -} - -/** - * 批量关联所有支持的提供商目录下的未关联配置 - */ -async function batchLinkProviderConfigs() { - // 筛选出所有支持的提供商目录下的未关联配置 - const unlinkedConfigs = allConfigs.filter(config => { - if (config.isUsed) return false; - const providerInfo = detectProviderFromPath(config.path); - return providerInfo !== null; - }); - - if (unlinkedConfigs.length === 0) { - showToast(t('common.info'), t('upload.batchLink.none'), 'info'); - return; - } - - // 按提供商类型分组统计 - const groupedByProvider = {}; - unlinkedConfigs.forEach(config => { - const providerInfo = detectProviderFromPath(config.path); - if (providerInfo) { - if (!groupedByProvider[providerInfo.displayName]) { - groupedByProvider[providerInfo.displayName] = 0; - } - groupedByProvider[providerInfo.displayName]++; - } - }); - - const providerSummary = Object.entries(groupedByProvider) - .map(([name, count]) => `${name}: ${count}个`) - .join(', '); - - const confirmMsg = t('upload.batchLink.confirm', { count: unlinkedConfigs.length, summary: providerSummary }); - if (!confirm(confirmMsg)) { - return; - } - - showToast(t('common.info'), t('upload.batchLink.processing', { count: unlinkedConfigs.length }), 'info'); - - try { - // 一次性传递所有文件路径进行批量关联 - const filePaths = unlinkedConfigs.map(config => config.path); - const result = await window.apiClient.post('/quick-link-provider', { - filePaths: filePaths - }); - - // 刷新配置列表 - await loadConfigList(); - - if (result.failCount === 0) { - showToast(t('common.success'), t('upload.batchLink.success', { count: result.successCount }), 'success'); - } else { - showToast(t('common.warning'), t('upload.batchLink.partial', { success: result.successCount, fail: result.failCount }), 'warning'); - } - } catch (error) { - console.error('批量关联失败:', error); - showToast(t('common.error'), t('upload.batchLink.failed') + ': ' + error.message, 'error'); - - // 即使失败也刷新列表,可能部分成功 - await loadConfigList(); - } -} - -/** - * 删除所有未绑定的配置文件 - * 只删除 configs/xxx/ 子目录下的未绑定配置文件 - */ -async function deleteUnboundConfigs() { - // 统计未绑定的配置数量,并且必须在 configs/xxx/ 子目录下 - const unboundConfigs = allConfigs.filter(config => { - if (config.isUsed) return false; - - // 检查路径是否在 configs/xxx/ 子目录下 - const normalizedPath = config.path.replace(/\\/g, '/'); - const pathParts = normalizedPath.split('/'); - - // 路径至少需要3部分:configs/子目录/文件名 - // 例如:configs/kiro/xxx.json 或 configs/gemini/xxx.json - if (pathParts.length >= 3 && pathParts[0] === 'configs') { - return true; - } - - return false; - }); - - if (unboundConfigs.length === 0) { - showToast(t('common.info'), t('upload.deleteUnbound.none'), 'info'); - return; - } - - // 显示确认对话框 - const confirmMsg = t('upload.deleteUnbound.confirm', { count: unboundConfigs.length }); - if (!confirm(confirmMsg)) { - return; - } - - try { - showToast(t('common.info'), t('upload.deleteUnbound.processing'), 'info'); - - const result = await window.apiClient.delete('/upload-configs/delete-unbound'); - - if (result.deletedCount > 0) { - showToast(t('common.success'), t('upload.deleteUnbound.success', { count: result.deletedCount }), 'success'); - - // 刷新配置列表 - await loadConfigList(); - } else { - showToast(t('common.info'), t('upload.deleteUnbound.none'), 'info'); - } - - // 如果有失败的文件,显示警告 - if (result.failedCount > 0) { - console.warn('部分文件删除失败:', result.failedFiles); - showToast(t('common.warning'), t('upload.deleteUnbound.partial', { - success: result.deletedCount, - fail: result.failedCount - }), 'warning'); - } - } catch (error) { - console.error('删除未绑定配置失败:', error); - showToast(t('common.error'), t('upload.deleteUnbound.failed') + ': ' + error.message, 'error'); - } -} - -/** - * 防抖函数 - * @param {Function} func - 要防抖的函数 - * @param {number} wait - 等待时间(毫秒) - * @returns {Function} 防抖后的函数 - */ -function debounce(func, wait) { - let timeout; - return function executedFunction(...args) { - const later = () => { - clearTimeout(timeout); - func(...args); - }; - clearTimeout(timeout); - timeout = setTimeout(later, wait); - }; -} - -/** - * 打包下载所有配置文件 - */ -async function downloadAllConfigs() { - try { - showToast(t('common.info'), t('common.loading'), 'info'); - - // 使用 window.apiClient.get 获取 Blob 数据 - // 由于 apiClient 默认可能是处理 JSON 的,我们需要直接调用 fetch 或者确保 apiClient 支持返回原始响应 - const token = localStorage.getItem('authToken'); - const headers = { - 'Authorization': token ? `Bearer ${token}` : '' - }; - - const response = await fetch('/api/upload-configs/download-all', { headers }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.error?.message || '下载失败'); - } - - const blob = await response.blob(); - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - - // 从 Content-Disposition 中提取文件名,或者使用默认名 - const contentDisposition = response.headers.get('Content-Disposition'); - let filename = `configs_backup_${new Date().toISOString().slice(0, 10)}.zip`; - if (contentDisposition && contentDisposition.indexOf('filename=') !== -1) { - const matches = /filename="([^"]+)"/.exec(contentDisposition); - if (matches && matches[1]) filename = matches[1]; - } - - a.download = filename; - document.body.appendChild(a); - a.click(); - window.URL.revokeObjectURL(url); - document.body.removeChild(a); - - showToast(t('common.success'), t('common.success'), 'success'); - } catch (error) { - console.error('打包下载失败:', error); - showToast(t('common.error'), t('common.error') + ': ' + error.message, 'error'); - } -} - -/** - * 动态更新提供商筛选下拉框选项 - * @param {Array} providerConfigs - 提供商配置列表 - */ -function updateProviderFilterOptions(providerConfigs) { - const filterSelect = document.getElementById('configProviderFilter'); - if (!filterSelect) return; - - // 保存当前选中的值 - const currentValue = filterSelect.value; - - // 清空现有选项(保留第一个"全部提供商") - const firstOption = filterSelect.options[0]; - filterSelect.innerHTML = ''; - if (firstOption) { - filterSelect.appendChild(firstOption); - } else { - const option = document.createElement('option'); - option.value = ''; - option.setAttribute('data-i18n', 'upload.providerFilter.all'); - option.textContent = t('upload.providerFilter.all'); - filterSelect.appendChild(option); - } - - // 添加动态选项 - providerConfigs.forEach(config => { - // 根据是否有 defaultPath 来过滤,这意味着该提供商支持 OAuth 凭据文件管理 - if (config.visible !== false && config.defaultPath) { - const option = document.createElement('option'); - option.value = config.id; - option.textContent = config.name; - filterSelect.appendChild(option); - } - }); - - // 添加"其他"选项 - const otherOption = document.createElement('option'); - otherOption.value = 'other'; - otherOption.setAttribute('data-i18n', 'upload.providerFilter.other'); - otherOption.textContent = t('upload.providerFilter.other'); - filterSelect.appendChild(otherOption); - - // 恢复选中的值(如果还存在) - filterSelect.value = currentValue; -} - -// 导出函数 -export { - initUploadConfigManager, - searchConfigs, - loadConfigList, - viewConfig, - deleteConfig, - closeConfigModal, - copyConfigContent, - reloadConfig, - deleteUnboundConfigs, - updateProviderFilterOptions -}; diff --git a/static/app/usage-manager.js b/static/app/usage-manager.js deleted file mode 100644 index 62c2b20db931b687f664745bd5d252b8cff9a92e..0000000000000000000000000000000000000000 --- a/static/app/usage-manager.js +++ /dev/null @@ -1,999 +0,0 @@ -// 用量管理模块 - -import { showToast } from './utils.js'; -import { getAuthHeaders } from './auth.js'; -import { t, getCurrentLanguage } from './i18n.js'; - -/** - * 不支持显示用量数据的提供商列表 - * 这些提供商只显示模型名称和重置时间,不显示用量数字和进度条 - */ -const PROVIDERS_WITHOUT_USAGE_DISPLAY = [ - 'gemini-antigravity' -]; - -// 提供商配置缓存 -let currentProviderConfigs = null; - -/** - * 更新提供商配置 - * @param {Array} configs - 提供商配置列表 - */ -export function updateUsageProviderConfigs(configs) { - currentProviderConfigs = configs; - // 重新触发列表加载,以应用最新的可见性过滤、名称和图标 - loadSupportedProviders(); - loadUsage(); -} - -/** - * 检查提供商是否支持显示用量 - * @param {string} providerType - 提供商类型 - * @returns {boolean} 是否支持显示用量 - */ -function shouldShowUsage(providerType) { - return !PROVIDERS_WITHOUT_USAGE_DISPLAY.includes(providerType); -} - -/** - * 初始化用量管理功能 - */ -export function initUsageManager() { - const refreshBtn = document.getElementById('refreshUsageBtn'); - if (refreshBtn) { - refreshBtn.addEventListener('click', refreshUsage); - } - - // 初始化时自动加载缓存数据 - loadUsage(); - loadSupportedProviders(); -} - -/** - * 加载支持用量查询的提供商列表 - */ -async function loadSupportedProviders() { - const listEl = document.getElementById('supportedProvidersList'); - if (!listEl) return; - - try { - const response = await fetch('/api/usage/supported-providers', { - method: 'GET', - headers: getAuthHeaders() - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - - const providers = await response.json(); - - listEl.innerHTML = ''; - - // 按照 currentProviderConfigs 的顺序渲染,确保顺序一致性 - const displayOrder = currentProviderConfigs - ? currentProviderConfigs.map(c => c.id) - : providers; - - displayOrder.forEach(providerId => { - // 必须是后端支持且前端配置可见的提供商 - const isSupported = providers.includes(providerId); - if (!isSupported) return; - - if (currentProviderConfigs) { - const config = currentProviderConfigs.find(c => c.id === providerId); - if (config && config.visible === false) return; - } - - const tag = document.createElement('span'); - tag.className = 'provider-tag'; - tag.textContent = getProviderDisplayName(providerId); - tag.title = t('usage.doubleClickToRefresh') || '双击刷新该提供商用量'; - tag.setAttribute('data-i18n-title', 'usage.doubleClickToRefresh'); - - // 添加双击事件 - tag.addEventListener('dblclick', () => { - refreshProviderUsage(providerId); - }); - - listEl.appendChild(tag); - }); - } catch (error) { - console.error('获取支持的提供商列表失败:', error); - listEl.innerHTML = `${t('usage.failedToLoad')}`; - } -} - -/** - * 加载用量数据(优先从缓存读取) - */ -export async function loadUsage() { - const loadingEl = document.getElementById('usageLoading'); - const errorEl = document.getElementById('usageError'); - const contentEl = document.getElementById('usageContent'); - const emptyEl = document.getElementById('usageEmpty'); - const lastUpdateEl = document.getElementById('usageLastUpdate'); - - // 显示加载状态 - if (loadingEl) loadingEl.style.display = 'block'; - if (errorEl) errorEl.style.display = 'none'; - if (emptyEl) emptyEl.style.display = 'none'; - - try { - // 不带 refresh 参数,优先读取缓存 - const response = await fetch('/api/usage', { - method: 'GET', - headers: getAuthHeaders() - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const data = await response.json(); - - // 隐藏加载状态 - if (loadingEl) loadingEl.style.display = 'none'; - - // 渲染用量数据 - renderUsageData(data, contentEl); - - // 更新服务端系统时间 - if (data.serverTime) { - const serverTimeEl = document.getElementById('serverTimeValue'); - if (serverTimeEl) { - serverTimeEl.textContent = new Date(data.serverTime).toLocaleString(getCurrentLanguage()); - } - } - - // 更新最后更新时间 - if (lastUpdateEl) { - const timeStr = new Date(data.timestamp || Date.now()).toLocaleString(getCurrentLanguage()); - if (data.fromCache && data.timestamp) { - lastUpdateEl.textContent = t('usage.lastUpdateCache', { time: timeStr }); - lastUpdateEl.setAttribute('data-i18n', 'usage.lastUpdateCache'); - lastUpdateEl.setAttribute('data-i18n-params', JSON.stringify({ time: timeStr })); - } else { - lastUpdateEl.textContent = t('usage.lastUpdate', { time: timeStr }); - lastUpdateEl.setAttribute('data-i18n', 'usage.lastUpdate'); - lastUpdateEl.setAttribute('data-i18n-params', JSON.stringify({ time: timeStr })); - } - } - } catch (error) { - console.error('获取用量数据失败:', error); - - if (loadingEl) loadingEl.style.display = 'none'; - if (errorEl) { - errorEl.style.display = 'block'; - const errorMsgEl = document.getElementById('usageErrorMessage'); - if (errorMsgEl) { - errorMsgEl.textContent = error.message || (t('usage.title') + t('common.refresh.failed')); - } - } - } -} - -/** - * 刷新用量数据(强制从服务器获取最新数据) - */ -export async function refreshUsage() { - const loadingEl = document.getElementById('usageLoading'); - const errorEl = document.getElementById('usageError'); - const contentEl = document.getElementById('usageContent'); - const emptyEl = document.getElementById('usageEmpty'); - const lastUpdateEl = document.getElementById('usageLastUpdate'); - const refreshBtn = document.getElementById('refreshUsageBtn'); - - // 显示加载状态 - if (loadingEl) loadingEl.style.display = 'block'; - if (errorEl) errorEl.style.display = 'none'; - if (emptyEl) emptyEl.style.display = 'none'; - if (refreshBtn) refreshBtn.disabled = true; - - try { - // 带 refresh=true 参数,强制刷新 - const response = await fetch('/api/usage?refresh=true', { - method: 'GET', - headers: getAuthHeaders() - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const data = await response.json(); - - // 隐藏加载状态 - if (loadingEl) loadingEl.style.display = 'none'; - - // 渲染用量数据 - renderUsageData(data, contentEl); - - // 更新服务端系统时间 - if (data.serverTime) { - const serverTimeEl = document.getElementById('serverTimeValue'); - if (serverTimeEl) { - serverTimeEl.textContent = new Date(data.serverTime).toLocaleString(getCurrentLanguage()); - } - } - - // 更新最后更新时间 - if (lastUpdateEl) { - const timeStr = new Date().toLocaleString(getCurrentLanguage()); - lastUpdateEl.textContent = t('usage.lastUpdate', { time: timeStr }); - lastUpdateEl.setAttribute('data-i18n', 'usage.lastUpdate'); - lastUpdateEl.setAttribute('data-i18n-params', JSON.stringify({ time: timeStr })); - } - - showToast(t('common.success'), t('common.refresh.success'), 'success'); - } catch (error) { - console.error('获取用量数据失败:', error); - - if (loadingEl) loadingEl.style.display = 'none'; - if (errorEl) { - errorEl.style.display = 'block'; - const errorMsgEl = document.getElementById('usageErrorMessage'); - if (errorMsgEl) { - errorMsgEl.textContent = error.message || (t('usage.title') + t('common.refresh.failed')); - } - } - - showToast(t('common.error'), t('common.refresh.failed') + ': ' + error.message, 'error'); - } finally { - if (refreshBtn) refreshBtn.disabled = false; - } -} - -/** - * 渲染用量数据 - * @param {Object} data - 用量数据 - * @param {HTMLElement} container - 容器元素 - */ -function renderUsageData(data, container) { - if (!container) return; - - // 清空容器 - container.innerHTML = ''; - - if (!data || !data.providers || Object.keys(data.providers).length === 0) { - container.innerHTML = ` -
    - -

    ${t('usage.noData')}

    -
    - `; - return; - } - - // 按提供商分组收集已初始化且未禁用的实例 - const groupedInstances = {}; - - for (const [providerType, providerData] of Object.entries(data.providers)) { - // 如果配置了不可见,则跳过 - if (currentProviderConfigs) { - const config = currentProviderConfigs.find(c => c.id === providerType); - if (config && config.visible === false) continue; - } - - if (providerData.instances && providerData.instances.length > 0) { - const validInstances = []; - for (const instance of providerData.instances) { - // 过滤掉服务实例未初始化的 - if (instance.error === '服务实例未初始化' || instance.error === 'Service instance not initialized') { - continue; - } - // 过滤掉已禁用的提供商 - if (instance.isDisabled) { - continue; - } - validInstances.push(instance); - } - if (validInstances.length > 0) { - groupedInstances[providerType] = validInstances; - } - } - } - - if (Object.keys(groupedInstances).length === 0) { - container.innerHTML = ` -
    - -

    ${t('usage.noInstances')}

    -
    - `; - return; - } - - // 按提供商分组渲染,使用统一的显示顺序 - const displayOrder = currentProviderConfigs - ? currentProviderConfigs.map(c => c.id) - : Object.keys(groupedInstances); - - displayOrder.forEach(providerType => { - const instances = groupedInstances[providerType]; - if (instances && instances.length > 0) { - const groupContainer = createProviderGroup(providerType, instances); - container.appendChild(groupContainer); - } - }); -} - -/** - * 刷新特定提供商类型的用量数据 - * @param {string} providerType - 提供商类型 - */ -export async function refreshProviderUsage(providerType) { - const loadingEl = document.getElementById('usageLoading'); - const refreshBtn = document.getElementById('refreshUsageBtn'); - const contentEl = document.getElementById('usageContent'); - - // 显示加载状态 - if (loadingEl) loadingEl.style.display = 'block'; - if (refreshBtn) refreshBtn.disabled = true; - - try { - const providerName = getProviderDisplayName(providerType); - showToast(t('common.info'), t('usage.refreshingProvider', { name: providerName }), 'info'); - - // 调用按提供商刷新的 API - const response = await fetch(`/api/usage/${providerType}?refresh=true`, { - method: 'GET', - headers: getAuthHeaders() - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - const providerData = await response.json(); - - // 获取当前完整数据并更新其中一个提供商的数据 - // 注意:这里为了保持页面一致性,我们重新获取一次完整数据(走缓存)来重新渲染 - // 或者手动在当前 DOM 中更新该提供商的部分。 - // 为了简单可靠,我们重新 loadUsage(),它会读取刚刚更新过的后端缓存 - await loadUsage(); - - showToast(t('common.success'), t('common.refresh.success'), 'success'); - } catch (error) { - console.error(`刷新提供商 ${providerType} 失败:`, error); - showToast(t('common.error'), t('common.refresh.failed') + ': ' + error.message, 'error'); - } finally { - if (loadingEl) loadingEl.style.display = 'none'; - if (refreshBtn) refreshBtn.disabled = false; - } -} - -/** - * 创建提供商分组容器 - * @param {string} providerType - 提供商类型 - * @param {Array} instances - 实例数组 - * @returns {HTMLElement} 分组容器元素 - */ -function createProviderGroup(providerType, instances) { - const groupContainer = document.createElement('div'); - groupContainer.className = 'usage-provider-group collapsed'; - - const providerDisplayName = getProviderDisplayName(providerType); - const providerIcon = getProviderIcon(providerType); - const instanceCount = instances.length; - const successCount = instances.filter(i => i.success).length; - - // 分组头部(可点击折叠) - const header = document.createElement('div'); - header.className = 'usage-group-header'; - header.innerHTML = ` -
    - - - ${providerDisplayName} - ${t('usage.group.instances', { count: instanceCount })} - ${t('usage.group.success', { count: successCount, total: instanceCount })} -
    -
    - -
    - `; - - // 点击头部切换分组折叠状态 - const titleDiv = header.querySelector('.usage-group-title'); - titleDiv.addEventListener('click', () => { - groupContainer.classList.toggle('collapsed'); - }); - - groupContainer.appendChild(header); - - // 展开/折叠所有卡片按钮事件 - const toggleCardsBtn = header.querySelector('.btn-toggle-cards'); - toggleCardsBtn.addEventListener('click', (e) => { - e.stopPropagation(); // 阻止事件冒泡到分组头部 - - const cards = groupContainer.querySelectorAll('.usage-instance-card'); - const allCollapsed = Array.from(cards).every(card => card.classList.contains('collapsed')); - - // 如果全部折叠,则全部展开;否则全部折叠 - cards.forEach(card => { - if (allCollapsed) { - card.classList.remove('collapsed'); - } else { - card.classList.add('collapsed'); - } - }); - - // 更新按钮图标和提示文本 - const icon = toggleCardsBtn.querySelector('i'); - if (allCollapsed) { - icon.className = 'fas fa-compress-alt'; - toggleCardsBtn.title = t('usage.group.collapseAll'); - } else { - icon.className = 'fas fa-expand-alt'; - toggleCardsBtn.title = t('usage.group.expandAll'); - } - }); - - // 分组内容(卡片网格) - const content = document.createElement('div'); - content.className = 'usage-group-content'; - - const gridContainer = document.createElement('div'); - gridContainer.className = 'usage-cards-grid'; - - for (const instance of instances) { - const instanceCard = createInstanceUsageCard(instance, providerType); - gridContainer.appendChild(instanceCard); - } - - content.appendChild(gridContainer); - groupContainer.appendChild(content); - - return groupContainer; -} - -/** - * 创建实例用量卡片 - * @param {Object} instance - 实例数据 - * @param {string} providerType - 提供商类型 - * @returns {HTMLElement} 卡片元素 - */ -function createInstanceUsageCard(instance, providerType) { - const card = document.createElement('div'); - card.className = `usage-instance-card ${instance.success ? 'success' : 'error'} collapsed`; - - const providerDisplayName = getProviderDisplayName(providerType); - const providerIcon = getProviderIcon(providerType); - - // 检查是否应该显示用量信息 - const showUsage = shouldShowUsage(providerType); - - // 计算总用量(用于折叠摘要显示) - const totalUsage = instance.usage ? calculateTotalUsage(instance.usage.usageBreakdown) : { hasData: false, percent: 0 }; - const progressClass = totalUsage.percent >= 90 ? 'danger' : (totalUsage.percent >= 70 ? 'warning' : 'normal'); - - // 折叠摘要 - 两行显示 - const collapsedSummary = document.createElement('div'); - collapsedSummary.className = 'usage-card-collapsed-summary'; - - const statusIcon = instance.success - ? '' - : ''; - - // 显示名称:优先自定义名称,其次 uuid - const displayName = instance.name || instance.uuid; - - const displayUsageText = totalUsage.isCodex - ? `${totalUsage.percent.toFixed(1)}%` - : `${formatNumber(totalUsage.used)} / ${formatNumber(totalUsage.limit)}`; - - collapsedSummary.innerHTML = ` -
    - - ${displayName} - ${statusIcon} -
    - ${showUsage ? ` -
    - ${totalUsage.hasData ? ` -
    -
    -
    - ${totalUsage.percent.toFixed(1)}% - ${displayUsageText} - ` : (instance.error ? `${t('common.error')}` : '')} -
    - ` : ''} - `; - - // 点击折叠摘要切换展开状态 - collapsedSummary.addEventListener('click', (e) => { - e.stopPropagation(); - card.classList.toggle('collapsed'); - }); - - card.appendChild(collapsedSummary); - - // 展开内容区域 - const expandedContent = document.createElement('div'); - expandedContent.className = 'usage-card-expanded-content'; - - // 实例头部 - 整合用户信息 - const header = document.createElement('div'); - header.className = 'usage-instance-header'; - - const healthBadge = instance.isDisabled - ? `${t('usage.card.status.disabled')}` - : (instance.isHealthy - ? `${t('usage.card.status.healthy')}` - : `${t('usage.card.status.unhealthy')}`); - - // 下载按钮 - const downloadBtnHTML = instance.configFilePath ? ` - - ` : ''; - - // 获取用户邮箱和订阅信息 - const userEmail = instance.usage?.user?.email || ''; - const subscriptionTitle = instance.usage?.subscription?.title || ''; - - // 用户信息行 - const userInfoHTML = userEmail ? ` - - ` : ''; - - header.innerHTML = ` -
    -
    - - ${providerDisplayName} -
    -
    - ${downloadBtnHTML} - ${statusIcon} - ${healthBadge} -
    -
    -
    - ${instance.name || instance.uuid} -
    - ${userInfoHTML} - `; - - // 添加下载按钮点击事件 - if (instance.configFilePath) { - const downloadBtn = header.querySelector('.btn-download-config'); - if (downloadBtn) { - downloadBtn.addEventListener('click', (e) => { - e.stopPropagation(); - downloadConfigFile(instance.configFilePath); - }); - } - } - - expandedContent.appendChild(header); - - // 实例内容 - 只显示用量和到期时间 - const content = document.createElement('div'); - content.className = 'usage-instance-content'; - - if (instance.error) { - content.innerHTML = ` -
    - - ${instance.error} -
    - `; - } else if (instance.usage) { - content.appendChild(renderUsageDetails(instance.usage, providerType)); - } - - expandedContent.appendChild(content); - card.appendChild(expandedContent); - - return card; -} - -/** - * 渲染用量详情 - 显示总用量、用量明细和到期时间 - * @param {Object} usage - 用量数据 - * @param {string} providerType - 提供商类型 - * @returns {HTMLElement} 详情元素 - */ -function renderUsageDetails(usage, providerType) { - const container = document.createElement('div'); - container.className = 'usage-details'; - - // 检查是否应该显示用量信息 - const showUsage = shouldShowUsage(providerType); - - // 计算总用量 - const totalUsage = calculateTotalUsage(usage.usageBreakdown); - - // 总用量进度条(不支持显示用量的提供商不显示) - if (totalUsage.hasData && showUsage) { - const totalSection = document.createElement('div'); - totalSection.className = 'usage-section total-usage'; - - const progressClass = totalUsage.percent >= 90 ? 'danger' : (totalUsage.percent >= 70 ? 'warning' : 'normal'); - - // 提取第一个有重置时间的条目(通常是总配额) - let resetTimeHTML = ''; - if (totalUsage.isCodex && totalUsage.resetAfterSeconds !== undefined) { - const resetTimeText = formatTimeRemaining(totalUsage.resetAfterSeconds); - resetTimeHTML = ` -
    - ${t('usage.resetInfo', { time: resetTimeText })} -
    - `; - } else { - const resetTimeEntry = usage.usageBreakdown.find(b => b.resetTime && b.resetTime !== '--'); - if (resetTimeEntry) { - const formattedResetTime = formatDate(resetTimeEntry.resetTime); - resetTimeHTML = ` -
    - ${t('usage.card.resetAt', { time: formattedResetTime })} -
    - `; - } - } - - const displayValue = totalUsage.isCodex - ? `${totalUsage.percent.toFixed(1)}%` - : `${formatNumber(totalUsage.used)} / ${formatNumber(totalUsage.limit)}`; - - totalSection.innerHTML = ` -
    - - - ${t('usage.card.totalUsage')} - - ${displayValue} -
    -
    -
    -
    - - `; - - container.appendChild(totalSection); - } - - // 用量明细(包含免费试用和奖励信息) - if (usage.usageBreakdown && usage.usageBreakdown.length > 0) { - const breakdownSection = document.createElement('div'); - breakdownSection.className = 'usage-section usage-breakdown-compact'; - - let breakdownHTML = ''; - - for (const breakdown of usage.usageBreakdown) { - breakdownHTML += createUsageBreakdownHTML(breakdown, providerType); - } - - breakdownSection.innerHTML = breakdownHTML; - container.appendChild(breakdownSection); - } - - return container; -} - -/** - * 创建用量明细 HTML(紧凑版) - * @param {Object} breakdown - 用量明细数据 - * @param {string} providerType - 提供商类型 - * @returns {string} HTML 字符串 - */ -function createUsageBreakdownHTML(breakdown, providerType) { - // 特殊处理 Codex - if (breakdown.rateLimit && breakdown.rateLimit.primary_window) { - return createCodexUsageBreakdownHTML(breakdown); - } - - // 检查是否应该显示用量信息 - const showUsage = shouldShowUsage(providerType); - - const usagePercent = breakdown.usageLimit > 0 - ? Math.min(100, (breakdown.currentUsage / breakdown.usageLimit) * 100) - : 0; - - const progressClass = usagePercent >= 90 ? 'danger' : (usagePercent >= 70 ? 'warning' : 'normal'); - - let html = ` -
    -
    - ${breakdown.displayName || breakdown.resourceType} - ${showUsage ? `${formatNumber(breakdown.currentUsage)} / ${formatNumber(breakdown.usageLimit)}` : ''} -
    - ${showUsage ? ` -
    -
    -
    - ` : ''} - `; - - // 如果有重置时间,则显示 - if (breakdown.resetTime && breakdown.resetTime !== '--') { - const formattedResetTime = formatDate(breakdown.resetTime); - const resetText = t('usage.card.resetAt', { time: formattedResetTime }); - html += ` -
    - - - ${resetText} - -
    - `; - } - - // 免费试用信息 - if (breakdown.freeTrial && breakdown.freeTrial.status === 'ACTIVE') { - html += ` -
    - ${t('usage.card.freeTrial')} - ${formatNumber(breakdown.freeTrial.currentUsage)} / ${formatNumber(breakdown.freeTrial.usageLimit)} - ${t('usage.card.expires', { time: formatDate(breakdown.freeTrial.expiresAt) })} -
    - `; - } - - // 奖励信息 - if (breakdown.bonuses && breakdown.bonuses.length > 0) { - for (const bonus of breakdown.bonuses) { - if (bonus.status === 'ACTIVE') { - html += ` -
    - ${bonus.displayName || bonus.code} - ${formatNumber(bonus.currentUsage)} / ${formatNumber(bonus.usageLimit)} - ${t('usage.card.expires', { time: formatDate(bonus.expiresAt) })} -
    - `; - } - } - } - - html += '
    '; - return html; -} - -/** - * 创建 Codex 专用的用量明细 HTML - * @param {Object} breakdown - 包含 rateLimit 的用量明细 - * @returns {string} HTML 字符串 - */ -function createCodexUsageBreakdownHTML(breakdown) { - const rl = breakdown.rateLimit; - const secondary = rl.secondary_window; - - if (!secondary) return ''; - - const secondaryPercent = secondary.used_percent || 0; - const secondaryProgressClass = secondaryPercent >= 90 ? 'danger' : (secondaryPercent >= 70 ? 'warning' : 'normal'); - const secondaryResetText = formatTimeRemaining(secondary.reset_after_seconds); - - return ` -
    -
    - ${t('usage.weeklyLimit')} - ${secondaryPercent}% -
    -
    -
    -
    -
    - ${t('usage.resetInfo', { time: secondaryResetText })} -
    -
    - `; -} - -/** - * 格式化剩余时间 - * @param {number} seconds - 秒数 - * @returns {string} 格式化后的时间 - */ -function formatTimeRemaining(seconds) { - if (seconds <= 0) return t('usage.time.soon'); - - const days = Math.floor(seconds / 86400); - const hours = Math.floor((seconds % 86400) / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - - if (days > 0) return t('usage.time.days', { days, hours }); - if (hours > 0) return t('usage.time.hours', { hours, minutes }); - return t('usage.time.minutes', { minutes }); -} - -/** - * 计算总用量(包含基础用量、免费试用和奖励) - * @param {Array} usageBreakdown - 用量明细数组 - * @returns {Object} 总用量信息 - */ -function calculateTotalUsage(usageBreakdown) { - if (!usageBreakdown || usageBreakdown.length === 0) { - return { hasData: false, used: 0, limit: 0, percent: 0 }; - } - - // 特殊处理 Codex - const codexEntry = usageBreakdown.find(b => b.rateLimit && b.rateLimit.secondary_window); - if (codexEntry) { - const secondary = codexEntry.rateLimit.secondary_window; - const secondaryPercent = secondary.used_percent || 0; - - // 只有当周限制达到 100% 时,总用量才显示 100% - // 否则按正常逻辑计算(或者这里可以理解为非 100% 时不改变原有的总用量逻辑, - // 但根据用户反馈,Codex 应该主要关注周限制) - // 重新审视需求:达到周限制时,总用量直接100%,重置时间设置为周限制时间 - - if (secondaryPercent >= 100) { - return { - hasData: true, - used: 100, - limit: 100, - percent: 100, - isCodex: true, - resetAfterSeconds: secondary.reset_after_seconds - }; - } - // 如果未达到 100%,则继续执行下面的常规计算逻辑 - } - - let totalUsed = 0; - let totalLimit = 0; - - for (const breakdown of usageBreakdown) { - // 基础用量 - totalUsed += breakdown.currentUsage || 0; - totalLimit += breakdown.usageLimit || 0; - - // 免费试用用量 - if (breakdown.freeTrial && breakdown.freeTrial.status === 'ACTIVE') { - totalUsed += breakdown.freeTrial.currentUsage || 0; - totalLimit += breakdown.freeTrial.usageLimit || 0; - } - - // 奖励用量 - if (breakdown.bonuses && breakdown.bonuses.length > 0) { - for (const bonus of breakdown.bonuses) { - if (bonus.status === 'ACTIVE') { - totalUsed += bonus.currentUsage || 0; - totalLimit += bonus.usageLimit || 0; - } - } - } - } - - const percent = totalLimit > 0 ? Math.min(100, (totalUsed / totalLimit) * 100) : 0; - - return { - hasData: true, - used: totalUsed, - limit: totalLimit, - percent: percent - }; -} - -/** - * 获取提供商显示名称 - * @param {string} providerType - 提供商类型 - * @returns {string} 显示名称 - */ -function getProviderDisplayName(providerType) { - // 优先从外部传入的配置中获取名称 - if (currentProviderConfigs) { - const config = currentProviderConfigs.find(c => c.id === providerType); - if (config && config.name) { - return config.name; - } - } - - const names = { - 'claude-kiro-oauth': 'Claude Kiro OAuth', - 'gemini-cli-oauth': 'Gemini CLI OAuth', - 'gemini-antigravity': 'Gemini Antigravity', - 'openai-codex-oauth': 'Codex OAuth', - 'openai-qwen-oauth': 'Qwen OAuth', - 'grok-custom': 'Grok Reverse' - }; - return names[providerType] || providerType; -} - -/** - * 获取提供商图标 - * @param {string} providerType - 提供商类型 - * @returns {string} 图标类名 - */ -function getProviderIcon(providerType) { - // 优先从外部传入的配置中获取图标 - if (currentProviderConfigs) { - const config = currentProviderConfigs.find(c => c.id === providerType); - if (config && config.icon) { - // 如果 icon 已经包含 fa- 则直接使用,否则加上 fas - return config.icon.startsWith('fa-') ? `fas ${config.icon}` : config.icon; - } - } - - const icons = { - 'claude-kiro-oauth': 'fas fa-robot', - 'gemini-cli-oauth': 'fas fa-gem', - 'gemini-antigravity': 'fas fa-rocket', - 'openai-codex-oauth': 'fas fa-terminal', - 'openai-qwen-oauth': 'fas fa-code', - 'grok-custom': 'fas fa-brain' - }; - return icons[providerType] || 'fas fa-server'; -} - - -/** - * 下载配置文件 - * @param {string} filePath - 文件路径 - */ -async function downloadConfigFile(filePath) { - if (!filePath) return; - - try { - const fileName = filePath.split(/[/\\]/).pop(); - const response = await fetch(`/api/upload-configs/download/${encodeURIComponent(filePath)}`, { - method: 'GET', - headers: getAuthHeaders() - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - - const blob = await response.blob(); - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = fileName; - document.body.appendChild(a); - a.click(); - window.URL.revokeObjectURL(url); - document.body.removeChild(a); - - showToast(t('common.success'), t('usage.card.downloadSuccess') || '文件下载成功', 'success'); - } catch (error) { - console.error('下载配置文件失败:', error); - showToast(t('common.error'), (t('usage.card.downloadFailed') || '下载配置文件失败') + ': ' + error.message, 'error'); - } -} - -/** - * 格式化数字(向上取整保留两位小数) - * @param {number} num - 数字 - * @returns {string} 格式化后的数字 - */ -function formatNumber(num) { - if (num === null || num === undefined) return '0.00'; - // 向上取整到两位小数 - const rounded = Math.ceil(num * 100) / 100; - return rounded.toFixed(2); -} - -/** - * 格式化日期 - * @param {string} dateStr - ISO 日期字符串 - * @returns {string} 格式化后的日期 - */ -function formatDate(dateStr) { - if (!dateStr) return '--'; - try { - const date = new Date(dateStr); - return date.toLocaleString(getCurrentLanguage(), { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit' - }); - } catch (e) { - return dateStr; - } -} \ No newline at end of file diff --git a/static/app/utils.js b/static/app/utils.js deleted file mode 100644 index f796c454330c4d4d7d9adde58ee7c19ce8780886..0000000000000000000000000000000000000000 --- a/static/app/utils.js +++ /dev/null @@ -1,466 +0,0 @@ -// 工具函数 -import { t, getCurrentLanguage } from './i18n.js'; -import { apiClient } from './auth.js'; - -/** - * 获取所有支持的提供商配置列表 - * @param {string[]} supportedProviders - 已注册的提供商类型列表 - * @returns {Object[]} 提供商配置对象数组 - */ -function getProviderConfigs(supportedProviders = []) { - return [ - { - id: 'forward-api', - name: 'NewAPI', - icon: 'fa-share-square', - visible: supportedProviders.includes('forward-api') - }, - { - id: 'gemini-cli-oauth', - name: t('dashboard.routing.nodeName.gemini'), - icon: 'fa-robot', - defaultPath: 'configs/gemini/', - visible: supportedProviders.includes('gemini-cli-oauth') - }, - { - id: 'gemini-antigravity', - name: t('dashboard.routing.nodeName.antigravity'), - icon: 'fa-rocket', - defaultPath: 'configs/antigravity/', - visible: supportedProviders.includes('gemini-antigravity') - }, - { - id: 'claude-kiro-oauth', - name: t('dashboard.routing.nodeName.kiro'), - icon: 'fa-key', - defaultPath: 'configs/kiro/', - visible: supportedProviders.includes('claude-kiro-oauth') - }, - { - id: 'openai-codex-oauth', - name: t('dashboard.routing.nodeName.codex'), - icon: 'fa-code', - defaultPath: 'configs/codex/', - visible: supportedProviders.includes('openai-codex-oauth') - }, - { - id: 'openai-qwen-oauth', - name: t('dashboard.routing.nodeName.qwen'), - icon: 'fa-cloud', - defaultPath: 'configs/qwen/', - visible: supportedProviders.includes('openai-qwen-oauth') - }, - { - id: 'openai-iflow', - name: t('dashboard.routing.nodeName.iflow'), - icon: 'fa-stream', - defaultPath: 'configs/iflow/', - visible: supportedProviders.includes('openai-iflow') - }, - { - id: 'grok-custom', - name: t('dashboard.routing.nodeName.grok'), - icon: 'fa-user-secret', - visible: supportedProviders.includes('grok-custom') - }, - { - id: 'openai-custom', - name: t('dashboard.routing.nodeName.openai'), - icon: 'fa-microchip', - visible: supportedProviders.includes('openai-custom') - }, - { - id: 'claude-custom', - name: t('dashboard.routing.nodeName.claude'), - icon: 'fa-brain', - visible: supportedProviders.includes('claude-custom') - }, - { - id: 'openaiResponses-custom', - name: 'OpenAI Responses', - icon: 'fa-reply-all', - visible: supportedProviders.includes('openaiResponses-custom') - }, - ]; -} - -/** - * 格式化运行时间 - * @param {number} seconds - 秒数 - * @returns {string} 格式化的时间字符串 - */ -function formatUptime(seconds) { - const days = Math.floor(seconds / 86400); - const hours = Math.floor((seconds % 86400) / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - const secs = Math.floor(seconds % 60); - - if (getCurrentLanguage() === 'en-US') { - return `${days}d ${hours}h ${minutes}m ${secs}s`; - } - return `${days}天 ${hours}小时 ${minutes}分 ${secs}秒`; -} - -/** - * HTML转义 - * @param {string} text - 要转义的文本 - * @returns {string} 转义后的文本 - */ -function escapeHtml(text) { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; -} - -/** - * 显示提示消息 - * @param {string} title - 提示标题 (可选,旧接口为 message) - * @param {string} message - 提示消息 - * @param {string} type - 消息类型 (info, success, error) - */ -function showToast(title, message, type = 'info') { - // 兼容旧接口 (message, type) - if (arguments.length === 2 && (message === 'success' || message === 'error' || message === 'info' || message === 'warning')) { - type = message; - message = title; - title = t(`common.${type}`); - } - - const toast = document.createElement('div'); - toast.className = `toast ${type}`; - toast.innerHTML = ` -
    ${escapeHtml(title)}
    -
    ${escapeHtml(message)}
    - `; - - // 获取toast容器 - const toastContainer = document.getElementById('toastContainer') || document.querySelector('.toast-container'); - if (toastContainer) { - toastContainer.appendChild(toast); - - setTimeout(() => { - toast.remove(); - }, 3000); - } -} - -/** - * 获取字段显示文案 - * @param {string} key - 字段键 - * @returns {string} 显示文案 - */ -function getFieldLabel(key) { - const labelMap = { - 'customName': t('modal.provider.customName') + ' ' + t('config.optional'), - 'checkModelName': t('modal.provider.checkModelName') + ' ' + t('config.optional'), - 'checkHealth': t('modal.provider.healthCheckLabel'), - 'concurrencyLimit': t('modal.provider.concurrencyLimit') + ' ' + t('config.optional'), - 'queueLimit': t('modal.provider.queueLimit') + ' ' + t('config.optional'), - 'OPENAI_API_KEY': 'OpenAI API Key', - 'OPENAI_BASE_URL': 'OpenAI Base URL', - 'CLAUDE_API_KEY': 'Claude API Key', - 'CLAUDE_BASE_URL': 'Claude Base URL', - 'PROJECT_ID': t('modal.provider.field.projectId'), - 'GEMINI_OAUTH_CREDS_FILE_PATH': t('modal.provider.field.oauthPath'), - 'KIRO_OAUTH_CREDS_FILE_PATH': t('modal.provider.field.oauthPath'), - 'QWEN_OAUTH_CREDS_FILE_PATH': t('modal.provider.field.oauthPath'), - 'ANTIGRAVITY_OAUTH_CREDS_FILE_PATH': t('modal.provider.field.oauthPath'), - 'IFLOW_OAUTH_CREDS_FILE_PATH': t('modal.provider.field.oauthPath'), - 'CODEX_OAUTH_CREDS_FILE_PATH': t('modal.provider.field.oauthPath'), - 'GROK_COOKIE_TOKEN': t('modal.provider.field.ssoToken'), - 'GROK_CF_CLEARANCE': t('modal.provider.field.cfClearance'), - 'GROK_USER_AGENT': t('modal.provider.field.userAgent'), - 'GEMINI_BASE_URL': 'Gemini Base URL', - 'KIRO_BASE_URL': t('modal.provider.field.baseUrl'), - 'KIRO_REFRESH_URL': t('modal.provider.field.refreshUrl'), - 'KIRO_REFRESH_IDC_URL': t('modal.provider.field.refreshIdcUrl'), - 'QWEN_BASE_URL': 'Qwen Base URL', - 'QWEN_OAUTH_BASE_URL': t('modal.provider.field.oauthBaseUrl'), - 'ANTIGRAVITY_BASE_URL_DAILY': t('modal.provider.field.dailyBaseUrl'), - 'ANTIGRAVITY_BASE_URL_AUTOPUSH': t('modal.provider.field.autopushBaseUrl'), - 'IFLOW_BASE_URL': t('modal.provider.field.iflowBaseUrl'), - 'CODEX_BASE_URL': t('modal.provider.field.codexBaseUrl'), - 'GROK_BASE_URL': t('modal.provider.field.grokBaseUrl'), - 'FORWARD_API_KEY': 'Forward API Key', - 'FORWARD_BASE_URL': 'Forward Base URL', - 'FORWARD_HEADER_NAME': t('modal.provider.field.headerName'), - 'FORWARD_HEADER_VALUE_PREFIX': t('modal.provider.field.headerPrefix'), - 'USE_SYSTEM_PROXY_FORWARD': t('modal.provider.field.useSystemProxy') - }; - - return labelMap[key] || key; -} - -/** - * 获取提供商类型的字段配置 - * @param {string} providerType - 提供商类型 - * @returns {Array} 字段配置数组 - */ -function getProviderTypeFields(providerType) { - const fieldConfigs = { - 'openai-custom': [ - { - id: 'OPENAI_API_KEY', - label: t('modal.provider.field.apiKey'), - type: 'password', - placeholder: 'sk-...' - }, - { - id: 'OPENAI_BASE_URL', - label: 'OpenAI Base URL', - type: 'text', - placeholder: 'https://api.openai.com/v1' - } - ], - 'openaiResponses-custom': [ - { - id: 'OPENAI_API_KEY', - label: t('modal.provider.field.apiKey'), - type: 'password', - placeholder: 'sk-...' - }, - { - id: 'OPENAI_BASE_URL', - label: 'OpenAI Base URL', - type: 'text', - placeholder: 'https://api.openai.com/v1' - } - ], - 'claude-custom': [ - { - id: 'CLAUDE_API_KEY', - label: 'Claude API Key', - type: 'password', - placeholder: 'sk-ant-...' - }, - { - id: 'CLAUDE_BASE_URL', - label: 'Claude Base URL', - type: 'text', - placeholder: 'https://api.anthropic.com' - } - ], - 'gemini-cli-oauth': [ - { - id: 'PROJECT_ID', - label: t('modal.provider.field.projectId'), - type: 'text', - placeholder: t('modal.provider.field.projectId.placeholder') - }, - { - id: 'GEMINI_OAUTH_CREDS_FILE_PATH', - label: t('modal.provider.field.oauthPath'), - type: 'text', - placeholder: t('modal.provider.field.oauthPath.gemini.placeholder') - }, - { - id: 'GEMINI_BASE_URL', - label: `Gemini Base URL ${t('config.optional')}`, - type: 'text', - placeholder: 'https://cloudcode-pa.googleapis.com' - } - ], - 'claude-kiro-oauth': [ - { - id: 'KIRO_OAUTH_CREDS_FILE_PATH', - label: t('modal.provider.field.oauthPath'), - type: 'text', - placeholder: t('modal.provider.field.oauthPath.kiro.placeholder') - }, - { - id: 'KIRO_BASE_URL', - label: `${t('modal.provider.field.baseUrl')} ${t('config.optional')}`, - type: 'text', - placeholder: 'https://codewhisperer.{{region}}.amazonaws.com/generateAssistantResponse' - }, - { - id: 'KIRO_REFRESH_URL', - label: `${t('modal.provider.field.refreshUrl')} ${t('config.optional')}`, - type: 'text', - placeholder: 'https://prod.{{region}}.auth.desktop.kiro.dev/refreshToken' - }, - { - id: 'KIRO_REFRESH_IDC_URL', - label: `${t('modal.provider.field.refreshIdcUrl')} ${t('config.optional')}`, - type: 'text', - placeholder: 'https://oidc.{{region}}.amazonaws.com/token' - } - ], - 'openai-qwen-oauth': [ - { - id: 'QWEN_OAUTH_CREDS_FILE_PATH', - label: t('modal.provider.field.oauthPath'), - type: 'text', - placeholder: t('modal.provider.field.oauthPath.qwen.placeholder') - }, - { - id: 'QWEN_BASE_URL', - label: `Qwen Base URL ${t('config.optional')}`, - type: 'text', - placeholder: 'https://portal.qwen.ai/v1' - }, - { - id: 'QWEN_OAUTH_BASE_URL', - label: `${t('modal.provider.field.oauthBaseUrl')} ${t('config.optional')}`, - type: 'text', - placeholder: 'https://chat.qwen.ai' - } - ], - 'gemini-antigravity': [ - { - id: 'PROJECT_ID', - label: `${t('modal.provider.field.projectId')} ${t('config.optional')}`, - type: 'text', - placeholder: t('modal.provider.field.projectId.optional.placeholder') - }, - { - id: 'ANTIGRAVITY_OAUTH_CREDS_FILE_PATH', - label: t('modal.provider.field.oauthPath'), - type: 'text', - placeholder: t('modal.provider.field.oauthPath.antigravity.placeholder') - }, - { - id: 'ANTIGRAVITY_BASE_URL_DAILY', - label: `${t('modal.provider.field.dailyBaseUrl')} ${t('config.optional')}`, - type: 'text', - placeholder: 'https://daily-cloudcode-pa.sandbox.googleapis.com' - }, - { - id: 'ANTIGRAVITY_BASE_URL_AUTOPUSH', - label: `${t('modal.provider.field.autopushBaseUrl')} ${t('config.optional')}`, - type: 'text', - placeholder: 'https://autopush-cloudcode-pa.sandbox.googleapis.com' - } - ], - 'openai-iflow': [ - { - id: 'IFLOW_OAUTH_CREDS_FILE_PATH', - label: t('modal.provider.field.oauthPath'), - type: 'text', - placeholder: t('modal.provider.field.oauthPath.iflow.placeholder') - }, - { - id: 'IFLOW_BASE_URL', - label: `iFlow Base URL ${t('config.optional')}`, - type: 'text', - placeholder: 'https://iflow.cn/api' - } - ], - 'openai-codex-oauth': [ - { - id: 'CODEX_OAUTH_CREDS_FILE_PATH', - label: t('modal.provider.field.oauthPath'), - type: 'text', - placeholder: t('modal.provider.field.oauthPath.codex.placeholder') - }, - { - id: 'CODEX_EMAIL', - label: `${t('modal.provider.field.email')} ${t('config.optional')}`, - type: 'email', - placeholder: t('modal.provider.field.email.placeholder') - }, - { - id: 'CODEX_BASE_URL', - label: `${t('modal.provider.field.codexBaseUrl')} ${t('config.optional')}`, - type: 'text', - placeholder: 'https://api.openai.com/v1/codex' - } - ], - 'grok-custom': [ - { - id: 'GROK_COOKIE_TOKEN', - label: t('modal.provider.field.ssoToken'), - type: 'password', - placeholder: 'sso cookie token' - }, - { - id: 'GROK_CF_CLEARANCE', - label: `${t('modal.provider.field.cfClearance')} ${t('config.optional')}`, - type: 'text', - placeholder: 'cf_clearance cookie value' - }, - { - id: 'GROK_USER_AGENT', - label: `${t('modal.provider.field.userAgent')} ${t('config.optional')}`, - type: 'text', - placeholder: 'Mozilla/5.0 ...' - }, - { - id: 'GROK_BASE_URL', - label: `${t('modal.provider.field.grokBaseUrl')} ${t('config.optional')}`, - type: 'text', - placeholder: 'https://grok.com' - } - ], - 'forward-api': [ - { - id: 'FORWARD_API_KEY', - label: t('modal.provider.field.apiKey'), - type: 'password', - placeholder: t('modal.provider.field.apiKey.placeholder') - }, - { - id: 'FORWARD_BASE_URL', - label: t('modal.provider.field.baseUrl'), - type: 'text', - placeholder: 'https://api.example.com' - }, - { - id: 'FORWARD_HEADER_NAME', - label: `${t('modal.provider.field.headerName')} ${t('config.optional')}`, - type: 'text', - placeholder: 'Authorization' - }, - { - id: 'FORWARD_HEADER_VALUE_PREFIX', - label: `${t('modal.provider.field.headerPrefix')} ${t('config.optional')}`, - type: 'text', - placeholder: 'Bearer ' - } - ] - }; - - return fieldConfigs[providerType] || []; -} - -/** - * 调试函数:获取当前提供商统计信息 - * @param {Object} providerStats - 提供商统计对象 - * @returns {Object} 扩展的统计信息 - */ -function getProviderStats(providerStats) { - return { - ...providerStats, - // 添加计算得出的统计信息 - successRate: providerStats.totalRequests > 0 ? - ((providerStats.totalRequests - providerStats.totalErrors) / providerStats.totalRequests * 100).toFixed(2) + '%' : '0%', - avgUsagePerProvider: providerStats.activeProviders > 0 ? - Math.round(providerStats.totalRequests / providerStats.activeProviders) : 0, - healthRatio: providerStats.totalAccounts > 0 ? - (providerStats.healthyProviders / providerStats.totalAccounts * 100).toFixed(2) + '%' : '0%' - }; -} - -/** - * 通用 API 请求函数 - * @param {string} url - API 端点 URL - * @param {Object} options - fetch 选项 - * @returns {Promise} 响应数据 - */ -async function apiRequest(url, options = {}) { - // 如果 URL 以 /api 开头,去掉它(因为 apiClient.request 会自动添加) - const endpoint = url.startsWith('/api') ? url.slice(4) : url; - return apiClient.request(endpoint, options); -} - -// 导出所有工具函数 -export { - formatUptime, - escapeHtml, - showToast, - getFieldLabel, - getProviderTypeFields, - getProviderConfigs, - getProviderStats, - apiRequest -}; \ No newline at end of file diff --git a/static/components/header.css b/static/components/header.css deleted file mode 100644 index e06b2130f016280c6929daad093a8af0aa343545..0000000000000000000000000000000000000000 --- a/static/components/header.css +++ /dev/null @@ -1,161 +0,0 @@ -/* Header - 玻璃拟态效果 */ -.header { - background: var(--bg-glass); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); - border-bottom: 1px solid var(--border-color); - position: sticky; - top: 0; - z-index: 100; - transition: var(--transition); -} - -.header-content { - max-width: 1600px; - margin: 0 auto; - padding: 0.75rem 2rem; - display: flex; - justify-content: space-between; - align-items: center; -} - -.header h1 { - font-size: 1.25rem; - font-weight: 700; - color: var(--text-primary); - display: flex; - align-items: center; - letter-spacing: -0.025em; -} - -.header h1 i { - margin-right: 0.5rem; -} - -.header-controls { - display: flex; - gap: 1rem; - align-items: center; -} - -.status-badge { - display: inline-flex; - align-items: center; - gap: 0.5rem; - padding: 0.5rem 1rem; - background: var(--bg-tertiary); - border-radius: var(--radius-full); - font-size: 0.75rem; - font-weight: 600; - letter-spacing: 0.05em; - text-transform: uppercase; -} - -.status-badge i { - color: var(--success-color); - animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; - font-size: 0.6rem; -} - -.status-badge.error i { - color: var(--danger-color); -} - -.logout-btn { - padding: 0.5rem 1rem; - background: transparent; - color: var(--text-secondary); - border: 1px solid var(--border-color); - border-radius: var(--radius-lg); - cursor: pointer; - font-size: 0.875rem; - font-weight: 600; - transition: var(--transition); - display: inline-flex; - align-items: center; - gap: 0.5rem; -} - -.logout-btn:hover { - background: var(--bg-tertiary); - color: var(--danger-color); - border-color: var(--danger-color); -} - -.logout-btn:active { - transform: scale(0.98); -} - -.logout-btn i { - font-size: 14px; -} - -.github-link { - display: inline-flex; - align-items: center; - justify-content: center; - width: 40px; - height: 40px; - padding: 0; - background: transparent; - color: var(--text-secondary); - border: 1px solid var(--border-color); - border-radius: 50%; - cursor: pointer; - font-size: 1.125rem; - transition: var(--transition); - text-decoration: none; - position: relative; - overflow: hidden; -} - -.github-link:hover { - background: var(--bg-tertiary); - color: var(--primary-color); - border-color: var(--primary-color); - transform: translateY(-2px); - box-shadow: var(--shadow-md); -} - -.github-link:active { - transform: translateY(0); -} - -.github-link i { - transition: transform 0.3s ease; -} - -/* KIRO 购买链接 */ -.kiro-buy-link { - display: inline-flex; - align-items: center; - gap: 0.5rem; - padding: 0.5rem 1rem; - background: linear-gradient(135deg, var(--warning-color) 0%, #f97316 100%); - color: var(--white); - text-decoration: none; - border-radius: 9999px; - font-size: 0.875rem; - font-weight: 600; - transition: var(--transition); - box-shadow: 0 2px 8px var(--warning-30); -} - -.kiro-buy-link:hover { - transform: translateY(-2px); - box-shadow: 0 4px 12px var(--warning-40); - background: linear-gradient(135deg, #f97316 0%, var(--warning-color) 100%); -} - -.kiro-buy-link:active { - transform: translateY(0); -} - -.kiro-buy-link i { - font-size: 0.875rem; -} - -/* 暗黑主题适配 */ -[data-theme="dark"] .header { - background: var(--bg-glass); -} diff --git a/static/components/header.html b/static/components/header.html deleted file mode 100644 index 40de0f10aceac9ac0f6965b13648fabbdcb685bd..0000000000000000000000000000000000000000 --- a/static/components/header.html +++ /dev/null @@ -1,31 +0,0 @@ - - -
    -
    -

    AIClient2API 管理控制台

    - -
    - - 多渠道账号购买 - - - 连接中... - - - - - - - -
    -
    -
    \ No newline at end of file diff --git a/static/components/section-config.css b/static/components/section-config.css deleted file mode 100644 index 6cd16ca4b1dbe4c9fee89ae286719093f1be5a16..0000000000000000000000000000000000000000 --- a/static/components/section-config.css +++ /dev/null @@ -1,465 +0,0 @@ -/* 表单样式 */ -.config-panel { - background: var(--bg-primary); - padding: 2rem; - border-radius: var(--radius-xl); - box-shadow: var(--shadow-sm); - border: 1px solid var(--border-color); -} - -.config-form { - max-width: 800px; - margin: 0 auto; -} - -.form-group { - margin-bottom: 1.5rem; -} - -.form-row { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 1.5rem; -} - -.form-group label { - display: block; - margin-bottom: 0.5rem; - font-weight: 600; - color: var(--text-primary); - font-size: 0.9rem; -} - -.optional-tag, .form-group label .optional-mark { - font-size: 0.75rem; - color: var(--text-tertiary); - font-weight: 400; - margin-left: 0.5rem; - background: var(--bg-tertiary); - padding: 0.125rem 0.375rem; - border-radius: var(--radius-sm); -} - -.form-control::placeholder { - color: var(--text-tertiary); -} - -textarea.form-control { - resize: vertical; - font-family: inherit; -} - -/* 密码输入框样式 */ -.password-input-group { - position: relative; -} - -.password-input-wrapper { - display: flex; - align-items: center; - gap: 0.5rem; -} - -.input-with-toggle { - position: relative; - flex: 1; - display: flex; - align-items: center; -} - -.generate-key-btn { - flex-shrink: 0; - height: 38px; - display: flex; - align-items: center; - gap: 0.4rem; - white-space: nowrap; -} - -.password-input-wrapper .form-control { - padding-right: 3rem; -} - -.password-input-wrapper input[type="password"], -.password-input-wrapper input[type="text"] { - flex: 1; - padding-right: 3rem; -} - -.password-toggle { - position: absolute; - right: 0.75rem; - background: none; - border: none; - cursor: pointer; - padding: 0.25rem; - color: var(--text-secondary); - transition: var(--transition); - z-index: 1; - width: auto; - flex-shrink: 0; -} - -.password-toggle:hover { - color: var(--primary-color); -} - -.password-toggle i { - font-size: 1rem; - width: 1rem; - text-align: center; -} - -/* 授权刷新切换开关 */ -.oauth-refresh-toggle { - display: flex; - align-items: center; - gap: 0.75rem; - margin-top: 0.5rem; -} - -.toggle-switch { - position: relative; - display: inline-block; - width: 48px; - height: 24px; -} - -.toggle-switch input { - opacity: 0; - width: 0; - height: 0; -} - -.toggle-slider { - position: absolute; - cursor: pointer; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: var(--bg-tertiary); - border: 1px solid var(--border-color); - transition: var(--transition); - border-radius: 24px; -} - -.toggle-slider:before { - position: absolute; - content: ""; - height: 18px; - width: 18px; - left: 3px; - bottom: 2px; - background-color: white; - transition: var(--transition); - border-radius: 50%; - box-shadow: 0 1px 3px var(--neutral-shadow-30); -} - -input:checked + .toggle-slider { - background-color: var(--primary-color); - border-color: var(--primary-color); -} - -input:checked + .toggle-slider:before { - transform: translateX(24px); -} - -.toggle-label { - font-weight: 500; - color: var(--text-primary); - font-size: 0.875rem; -} - -/* 系统提示区域 */ -.system-prompt-section { - margin-top: 2rem; - padding-top: 1.5rem; - border-top: 1px solid var(--border-color); -} - -.form-actions { - display: flex; - gap: 1rem; - margin-top: 2rem; - justify-content: flex-end; -} - -/* 重启提示模态框样式 */ -.restart-required-modal .restart-modal-content { - max-width: 550px; - border: 2px solid var(--primary-color); - box-shadow: 0 25px 80px var(--primary-30); -} - -.restart-required-modal .restart-modal-header { - background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); - color: white; - border-bottom: none; -} - -.restart-required-modal .restart-modal-header h3 { - color: white; -} - -.restart-required-modal .restart-modal-header h3 i { - color: white; -} - -.restart-icon-container { - text-align: center; - margin-bottom: 1.5rem; -} - -.restart-icon-container i { - font-size: 3rem; - color: var(--primary-color); - animation: spin 2s linear infinite; -} - -@keyframes spin { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } -} - -.restart-notice { - background: linear-gradient(135deg, var(--success-bg) 0%, var(--success-bg-light) 100%); - border: 1px solid var(--secondary-color); - border-radius: 0.5rem; - padding: 1rem; - margin-bottom: 1rem; - border-left: 4px solid var(--primary-color); -} - -.restart-notice p { - margin: 0; - color: var(--success-text); - font-weight: 500; - display: flex; - align-items: center; - gap: 0.5rem; -} - -.restart-instructions { - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 0.5rem; - padding: 1rem; -} - -.restart-instructions p { - margin: 0; - color: var(--text-primary); - white-space: pre-line; - line-height: 1.6; - font-size: 0.875rem; -} - -.restart-confirm-btn { - background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); - color: white; - border: none; - padding: 0.75rem 1.5rem; - border-radius: 0.375rem; - font-size: 0.875rem; - font-weight: 500; - cursor: pointer; - transition: var(--transition); - box-shadow: 0 2px 8px var(--primary-30); - display: inline-flex; - align-items: center; - gap: 0.5rem; -} - -.restart-confirm-btn:hover { - background: linear-gradient(135deg, var(--btn-primary-hover) 0%, var(--primary-color) 100%); - transform: translateY(-2px); - box-shadow: 0 4px 12px var(--primary-40); -} - -/* 提供商标签选择器样式 */ -.provider-tags { - display: flex; - flex-wrap: wrap; - gap: 0.75rem; - padding: 1rem; - background: var(--bg-tertiary); - border-radius: 12px; - border: 1px solid var(--border-color); -} - -.provider-tag { - display: inline-flex; - align-items: center; - gap: 0.5rem; - padding: 0.625rem 1rem; - background: var(--bg-primary); - border: 2px solid var(--border-color); - border-radius: 50px; - cursor: pointer; - transition: all 0.2s ease; - font-size: 0.875rem; - font-weight: 500; - color: var(--text-secondary); - user-select: none; -} - -.provider-tag:hover { - background: var(--bg-secondary); - border-color: var(--primary-color); - color: var(--primary-color); - transform: translateY(-1px); - box-shadow: 0 4px 12px var(--neutral-shadow-10); -} - -.provider-tag.selected { - background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); - border-color: transparent; - color: white; - box-shadow: 0 4px 12px var(--primary-30); -} - -.provider-tag.selected:hover { - background: linear-gradient(135deg, var(--btn-primary-hover) 0%, var(--primary-color) 100%); - transform: translateY(-1px); - box-shadow: 0 6px 16px var(--primary-40); - color: white; -} - -.provider-tag i { - font-size: 0.875rem; - opacity: 0.8; -} - -.provider-tag.selected i { - opacity: 1; -} - -.provider-tag span { - white-space: nowrap; -} - - -/* 高级配置区域 */ -.advanced-config-section { - border: 1px solid var(--border-color); - border-radius: 0.5rem; - padding: 1.5rem; - margin-top: 1.5rem; - background: var(--bg-secondary); -} - -.advanced-config-section h3 { - color: var(--text-primary); - font-size: 1.25rem; - font-weight: 600; - margin-bottom: 1.5rem; - padding-bottom: 0.5rem; - border-bottom: 1px solid var(--border-color); - display: flex; - align-items: center; - gap: 0.5rem; -} - -.advanced-config-section h3 i { - color: var(--primary-color); -} - -.pool-section .form-text { - margin-top: 0.5rem; - color: var(--text-secondary); - font-size: 0.75rem; - font-style: italic; -} - -.config-row { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 1rem; - margin-bottom: 1.5rem; -} - -.config-row:last-child { - margin-bottom: 0; -} - - -/* 组合配置区块样式 */ -.config-group-section { - border: 1px solid var(--border-color); - border-radius: var(--radius-lg); - padding: 1.5rem; - margin-bottom: 2rem; - background: var(--bg-primary); - transition: var(--transition); -} - -.config-group-section:hover { - box-shadow: var(--shadow-md); - border-color: var(--primary-color); -} - -.config-group-section h3 { - color: var(--text-primary); - font-size: 1.1rem; - font-weight: 600; - margin-bottom: 1.5rem; - padding-bottom: 0.75rem; - border-bottom: 2px solid var(--bg-tertiary); - display: flex; - align-items: center; - gap: 0.75rem; -} - -.config-group-section h3 i { - color: var(--primary-color); - width: 1.25rem; - text-align: center; -} - -.config-group-section hr { - border: none; - border-top: 1px solid var(--border-color); - margin: 1.5rem 0; - opacity: 0.5; -} - -/* 响应式调整 */ -@media (max-width: 768px) { - .form-row, .config-row { - grid-template-columns: 1fr; - gap: 0; - } - - .config-panel { - padding: 1rem; - } - - .config-group-section { - padding: 1rem; - } -} - -/* 暗黑主题适配 */ -[data-theme="dark"] .config-panel { - background: var(--bg-primary); -} - -[data-theme="dark"] .restart-required-modal .restart-modal-content { - border-color: var(--primary-color); -} - -[data-theme="dark"] .restart-notice { - background: linear-gradient(135deg, var(--success-bg) 0%, var(--success-bg-light) 100%); - border-color: var(--secondary-color); -} - -[data-theme="dark"] .restart-notice p { - color: var(--success-text); -} - -[data-theme="dark"] .restart-instructions { - background: var(--bg-secondary); - border-color: var(--border-color); -} diff --git a/static/components/section-config.html b/static/components/section-config.html deleted file mode 100644 index 37e0fe18e7f9c7aaf7d672604a4445fc474f8f25..0000000000000000000000000000000000000000 --- a/static/components/section-config.html +++ /dev/null @@ -1,359 +0,0 @@ - - -
    -

    配置管理

    -
    -
    - -
    -

    基础设置

    -
    - -
    -
    - - -
    - -
    -
    -
    -
    - - -
    -
    - - -
    -
    -
    - -
    - - - - - - - - - - -
    - 点击选择启动时初始化的模型提供商 (必须至少选择一个) -
    -
    - - -
    -

    代理设置

    -
    - - - 支持 HTTP、HTTPS 和 SOCKS5 代理,留空则不使用代理 -
    -
    - -
    - - - - - - - - - - -
    - 点击选择需要通过代理访问的提供商,未选中的提供商将直接连接 -
    -
    -
    -
    - - -
    -
    - - -
    -
    -
    - - - TLS Sidecar 专用上游代理,留空则不使用代理 -
    -
    - -
    - -
    - 点击选择需要通过 TLS Sidecar 访问的提供商 -
    - 启用后选中的提供商请求将通过 Go uTLS sidecar 转发,完美模拟 Chrome TLS/H2 指纹绕过 Cloudflare(需重启服务) -
    - - -
    -

    服务治理

    -
    -
    - - -
    -
    - - -
    -
    -
    -
    - - -
    -
    - - -
    -
    -
    -
    - - -
    -
    - - -
    -
    -
    - - -
    -
    - - -
    -
    - - -
    -

    OAuth & 令牌

    -
    -
    - - -
    -
    - - -
    -
    -
    - - - 管理后台登录后的 Token 有效期,默认 3600 秒 (1小时) -
    -
    - - - -
    -

    日志设置

    -
    -
    - - -
    -
    - - -
    -
    -
    -
    - - -
    -
    - - -
    -
    -
    -
    - - -
    -
    - - -
    -
    -
    -
    - - -
    -
    - - -
    -
    -
    -
    -
    - - -
    -
    - - -
    -
    -
    - - -
    -

    系统与高级

    -
    - - -
    -
    -
    - - -
    -
    - - -
    -
    -
    - - -
    -
    - -
    -
    - - -
    -
    - 修改后需要重新登录 -
    -
    - -
    - - -
    -
    -
    -
    diff --git a/static/components/section-dashboard.css b/static/components/section-dashboard.css deleted file mode 100644 index 8edc7491802f501cb4c512c6dadbc48493f874cd..0000000000000000000000000000000000000000 --- a/static/components/section-dashboard.css +++ /dev/null @@ -1,774 +0,0 @@ -/* 统计卡片 */ -.stats-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: 1.5rem; - margin-bottom: 2rem; -} - -.stat-card { - background: var(--bg-primary); - padding: 1.5rem; - border-radius: var(--radius-xl); - border: 1px solid var(--border-color); - box-shadow: var(--shadow-sm); - display: flex; - align-items: center; - gap: 1.25rem; - transition: var(--transition); - position: relative; - overflow: hidden; -} - -.stat-card:hover { - transform: translateY(-4px); - box-shadow: var(--shadow-lg); - border-color: var(--primary-30); -} - -.stat-icon { - width: 56px; - height: 56px; - border-radius: var(--radius-lg); - display: flex; - align-items: center; - justify-content: center; - font-size: 1.5rem; - color: var(--primary-color); - background: var(--primary-10); - flex-shrink: 0; -} - -.stat-info h3 { - font-size: 2rem; - font-weight: 700; - margin-bottom: 0.25rem; -} - -.stat-info p { - color: var(--text-secondary); - font-size: 0.875rem; -} - -/* System Info Panel in Dashboard */ -.system-info-panel { - background: var(--bg-primary); - padding: 1.5rem; - border-radius: 0.5rem; - box-shadow: var(--shadow-md); - margin-top: 1.5rem; -} - -.system-info-panel h3 { - font-size: 1.25rem; - font-weight: 600; - color: var(--text-primary); - margin: 0; -} - -.system-info-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 1.5rem; -} - -.update-controls { - display: flex; - gap: 0.75rem; - align-items: center; -} - -.update-badge { - display: inline-flex; - align-items: center; - gap: 4px; - background: var(--warning-bg); - color: var(--warning-text); - padding: 2px 8px; - border-radius: 9999px; - font-size: 11px; - font-weight: 600; - margin-left: 8px; - border: 1px solid var(--warning-bg-light); - animation: bounce-in 0.5s ease-out; -} - -@keyframes bounce-in { - 0% { transform: scale(0.8); opacity: 0; } - 70% { transform: scale(1.1); } - 100% { transform: scale(1); opacity: 1; } -} - -.info-grid { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 1rem; -} - -.info-item { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.info-item .info-label { - display: flex; - align-items: center; - gap: 0.5rem; - color: var(--text-secondary); - font-size: 0.875rem; - font-weight: 500; -} - -.info-item .info-label i { - color: var(--primary-color); - width: 16px; - text-align: center; -} - -.info-item .info-value { - color: var(--text-primary); - font-size: 1rem; - font-weight: 600; -} - -.version-display-wrapper { - display: flex; - align-items: center; - padding-left: 1.5rem; - flex-wrap: wrap; - gap: 0.5rem; -} - -.status-healthy { - background: var(--success-bg); - color: var(--success-text); -} - -.status-unhealthy { - background: var(--danger-bg); - color: var(--danger-text); -} - -/* Dashboard Top Row Layout */ -.dashboard-top-row { - display: flex; - gap: 1.5rem; - margin-bottom: 2rem; - align-items: stretch; - justify-content: flex-start; -} - -.dashboard-top-row .stats-grid { - flex: 1; - margin-bottom: 0; - display: flex; - flex-direction: column; -} - -.dashboard-top-row .stats-grid .stat-card { - flex: 1; - height: 100%; -} - -.dashboard-top-row .dashboard-contact { - flex: 1; - margin-top: 0; - padding-top: 0; - border-top: none; -} - -.dashboard-top-row .dashboard-contact .contact-grid { - margin-top: 0; - height: 100%; - grid-template-columns: repeat(2, 1fr); - gap: 1rem; -} - -.dashboard-top-row .dashboard-contact .contact-card { - padding: 1.25rem; - height: 100%; - display: flex; - flex-direction: column; - justify-content: center; -} - -.dashboard-top-row .dashboard-contact .qr-container { - margin: 0.75rem 0; -} - -.dashboard-top-row .dashboard-contact .qr-code { - width: 100px; - height: 100px; -} - -.dashboard-top-row .dashboard-contact .contact-card h3 { - font-size: 1rem; -} - -.dashboard-top-row .dashboard-contact .qr-description { - font-size: 0.75rem; -} - -/* Contact and Sponsor Section */ -.contact-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); - gap: 2rem; - margin-top: 1.5rem; -} - -.contact-card { - background: var(--bg-primary); - padding: 2rem; - border-radius: 0.5rem; - box-shadow: var(--shadow-md); - text-align: center; - transition: var(--transition); - border: 1px solid var(--border-color); -} - -.contact-card:hover { - transform: translateY(-2px); - box-shadow: var(--shadow-lg); - border-color: var(--primary-color); -} - -.contact-card h3 { - font-size: 1.25rem; - font-weight: 600; - margin-bottom: 1rem; - color: var(--text-primary); - display: flex; - align-items: center; - justify-content: center; - gap: 0.5rem; -} - -.contact-card h3 i { - color: var(--primary-color); -} - -.qr-container { - margin: 1.5rem 0; - display: flex; - justify-content: center; -} - -.qr-code { - width: 200px; - height: 200px; - object-fit: contain; - border: 4px solid var(--border-color); - border-radius: 0.5rem; - padding: 0.5rem; - background: white; - transition: var(--transition); -} - -.qr-code:hover { - border-color: var(--primary-color); - transform: scale(1.05); -} - -.clickable-qr { - cursor: zoom-in; -} - -/* Image Zoom Overlay */ -.image-zoom-overlay { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: var(--neutral-shadow-85); - display: none; - justify-content: center; - align-items: center; - z-index: 2000; - cursor: zoom-out; - opacity: 0; - transition: opacity 0.3s ease; -} - -.image-zoom-overlay.show { - display: flex; - opacity: 1; -} - -.image-zoom-overlay img { - max-width: 90%; - max-height: 90%; - border-radius: 0.5rem; - box-shadow: 0 0 20px var(--neutral-shadow-50); - transform: scale(0.9); - transition: transform 0.3s ease; -} - -.image-zoom-overlay.show img { - transform: scale(1); -} - -.qr-description { - font-size: 0.875rem; - color: var(--text-secondary); - margin: 0; - line-height: 1.5; -} - -/* Contact section styling for dashboard */ -.contact-section { - margin-top: 2rem; - padding-top: 1.5rem; - border-top: 1px solid var(--border-color); -} - -.contact-section h3 { - font-size: 1.25rem; - font-weight: 600; - margin-bottom: 1.5rem; - color: var(--text-primary); - display: flex; - align-items: center; - gap: 0.5rem; -} - -.contact-section h3 i { - color: var(--primary-color); -} - -/* 响应式调整 */ -@media (max-width: 1024px) { - .dashboard-top-row { - flex-direction: column; - } - - .dashboard-top-row .stats-grid { - flex: none; - } - - .dashboard-top-row .dashboard-contact .qr-code { - width: 160px; - height: 160px; - } -} - -/* ======================================== - 可用模型列表样式 - ======================================== */ - -.models-section { - margin-top: 2rem; - padding-top: 1.5rem; - border-top: 1px solid var(--border-color); -} - -.models-section-title { - font-size: 1.1rem; - font-weight: 600; - margin-bottom: 1.25rem; - color: var(--text-primary); - display: flex; - align-items: center; - gap: 0.5rem; -} - -.models-section-title i { - color: var(--primary-color); -} - -/* 模型描述区域 */ -.models-description { - margin-bottom: 1.5rem; -} - -/* 模型容器 */ -.models-container { - background: var(--bg-secondary); - padding: 1.25rem; - border-radius: var(--radius-lg); - border: 1px solid var(--border-color); -} - -.models-list { - display: flex; - flex-direction: column; - gap: 1.25rem; -} - -/* 加载状态 */ -.models-loading { - display: flex; - align-items: center; - justify-content: center; - gap: 0.75rem; - padding: 2.5rem; - color: var(--text-secondary); - font-size: 1rem; -} - -.models-loading i { - font-size: 1.25rem; - color: var(--primary-color); -} - -/* 提供商模型组 */ -.provider-models-group { - border: 1px solid var(--border-color); - border-radius: var(--radius-lg); - overflow: hidden; - transition: var(--transition); - background: var(--bg-primary); -} - -.provider-models-group:hover { - border-color: var(--primary-color); - box-shadow: var(--shadow-md); -} - -/* 提供商标题 */ -.provider-models-header { - display: flex; - justify-content: space-between; - align-items: center; - padding: 0.875rem 1.25rem; - background: linear-gradient(135deg, var(--bg-tertiary) 0%, var(--bg-secondary) 100%); - border-bottom: 1px solid var(--border-color); - cursor: pointer; - transition: var(--transition); -} - -.provider-models-header:hover { - background: linear-gradient(135deg, var(--primary-10) 0%, var(--bg-tertiary) 100%); -} - -.provider-models-title { - display: flex; - align-items: center; - gap: 0.75rem; -} - -.provider-models-title i { - font-size: 1.125rem; - color: var(--primary-color); -} - -.provider-models-title h3 { - margin: 0; - font-size: 1rem; - font-weight: 600; - color: var(--text-primary); -} - -.provider-models-count { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 1.5rem; - height: 1.5rem; - padding: 0 0.4rem; - background: var(--primary-color); - color: white; - border-radius: 9999px; - font-size: 0.7rem; - font-weight: 600; -} - -.provider-models-toggle { - color: var(--text-secondary); - transition: var(--transition); -} - -.provider-models-header.collapsed .provider-models-toggle { - transform: rotate(-90deg); -} - -/* 模型列表内容 */ -.provider-models-content { - padding: 0.875rem 1.25rem; - display: grid; - grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); - gap: 0.625rem; -} - -.provider-models-content.collapsed { - display: none; -} - -/* 单个模型项 */ -.model-item { - display: flex; - align-items: center; - gap: 0.625rem; - padding: 0.625rem 0.875rem; - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: var(--radius-md); - cursor: pointer; - transition: var(--transition); - position: relative; - overflow: hidden; -} - -.model-item:hover { - border-color: var(--primary-color); - background: var(--primary-10); - transform: translateY(-2px); - box-shadow: var(--shadow-sm); -} - -.model-item:active { - transform: translateY(0); -} - -.model-item-icon { - display: flex; - align-items: center; - justify-content: center; - width: 1.75rem; - height: 1.75rem; - background: var(--primary-10); - border-radius: var(--radius-sm); - color: var(--primary-color); - flex-shrink: 0; - font-size: 0.75rem; -} - -.model-item-name { - flex: 1; - font-size: 0.8rem; - font-weight: 500; - color: var(--text-primary); - word-break: break-all; -} - -.model-item-copy { - display: flex; - align-items: center; - justify-content: center; - width: 1.25rem; - height: 1.25rem; - color: var(--text-tertiary); - opacity: 0; - transition: var(--transition); - font-size: 0.75rem; -} - -.model-item:hover .model-item-copy { - opacity: 1; - color: var(--primary-color); -} - -/* 复制成功动画 */ -.model-item.copied { - border-color: var(--success-color); - background: var(--success-10); -} - -.model-item.copied .model-item-copy { - opacity: 1; - color: var(--success-color); -} - -.model-item.copied::after { - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: var(--success-color); - opacity: 0.1; - animation: copyFlash 0.3s ease-out; -} - -@keyframes copyFlash { - 0% { - opacity: 0.3; - } - 100% { - opacity: 0; - } -} - -/* 空状态 */ -.models-empty { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 2.5rem; - color: var(--text-secondary); - text-align: center; -} - -.models-empty i { - font-size: 2.5rem; - margin-bottom: 0.75rem; - opacity: 0.5; -} - -.models-empty p { - margin: 0; - font-size: 0.9rem; -} - -/* 高亮说明样式 */ -.models-description .highlight-note { - display: inline-flex; - align-items: center; - gap: 0.5rem; - padding: 0.875rem 1rem; - background: linear-gradient(135deg, var(--info-bg) 0%, var(--info-bg-light, var(--info-bg)) 100%); - border: 1px solid var(--info-border); - border-radius: 0.5rem; - color: var(--info-text); - font-weight: 500; - width: 100%; - box-sizing: border-box; - font-size: 0.875rem; -} - -.models-description .highlight-note i { - color: var(--info-color, var(--info-text)); - font-size: 1.125rem; - flex-shrink: 0; -} - -.models-description .highlight-note span { - flex: 1; - text-align: center; -} - -@media (max-width: 768px) { - .provider-models-content { - grid-template-columns: 1fr; - } - - .provider-models-header { - padding: 0.75rem 1rem; - } - - .provider-models-title h3 { - font-size: 0.9rem; - } - - .model-item { - padding: 0.5rem 0.75rem; - } -} - -/* 暗黑主题适配 */ -[data-theme="dark"] .stat-card { - background: var(--bg-primary); -} - -[data-theme="dark"] .provider-models-group { - background: var(--bg-primary); - border-color: var(--border-color); -} - -[data-theme="dark"] .provider-models-header { - background: linear-gradient(135deg, var(--bg-tertiary) 0%, var(--bg-secondary) 100%); -} - -[data-theme="dark"] .provider-models-header:hover { - background: linear-gradient(135deg, var(--primary-10) 0%, var(--bg-tertiary) 100%); -} - -[data-theme="dark"] .model-item { - background: var(--bg-secondary); - border-color: var(--border-color); -} - -[data-theme="dark"] .model-item:hover { - background: var(--primary-10); - border-color: var(--primary-color); -} - -[data-theme="dark"] .models-description .highlight-note { - background: linear-gradient(135deg, var(--info-bg) 0%, var(--info-bg-light, var(--info-bg)) 100%); - border-color: var(--info-border); - color: var(--info-text); -} - -[data-theme="dark"] .models-container { - background: var(--bg-secondary); -} - -@media (max-width: 1024px) { - .dashboard-top-row { - flex-direction: column; - } - - .dashboard-top-row .stats-grid { - flex: none; - } - - .dashboard-top-row .dashboard-contact .qr-code { - width: 160px; - height: 160px; - } -} - -@media (max-width: 768px) { - .stats-grid { - grid-template-columns: 1fr; - } - .contact-grid { - grid-template-columns: 1fr; - gap: 1.5rem; - } - .contact-card { - padding: 1.5rem; - } - .qr-code { - width: 180px; - height: 180px; - } -} - -@media (max-width: 480px) { - .qr-code { - width: 150px; - height: 150px; - } - .contact-card { - padding: 1rem; - } -} - -/* 暗黑主题适配 */ -[data-theme="dark"] .stat-card { - background: var(--bg-primary); -} - -[data-theme="dark"] .status-healthy { - background: var(--success-bg); - color: var(--success-text); -} - -[data-theme="dark"] .status-unhealthy { - background: var(--danger-bg); - color: var(--danger-text); -} - -[data-theme="dark"] .update-badge { - background: var(--warning-bg); - color: var(--warning-text); - border-color: var(--warning-border); -} - -[data-theme="dark"] .contact-card { - background: var(--bg-primary); -} - -[data-theme="dark"] .qr-code { - background: white; -} - -[data-theme="dark"] .image-zoom-overlay { - background: var(--neutral-shadow-95); -} diff --git a/static/components/section-dashboard.html b/static/components/section-dashboard.html deleted file mode 100644 index 83a1b93517dafd516b84a7cfee766e9b967c58e8..0000000000000000000000000000000000000000 --- a/static/components/section-dashboard.html +++ /dev/null @@ -1,168 +0,0 @@ - - -
    -

    系统概览

    -
    -
    -
    -
    - -
    -
    -

    --

    -

    运行时间

    -
    -
    -
    - - -
    -
    -
    -

    扫码进群,注明来意

    -
    - 微信二维码 -
    -

    添加微信获取更多技术支持和交流

    -
    - -
    -
    -
    - - -
    -
    -

    系统信息

    -
    - - -
    -
    -
    -
    - - 版本号 - -
    - -- - -
    -
    -
    - - Node.js版本 - -
    - -- -
    -
    -
    - - 服务器时间 - -
    - -- -
    -
    -
    - - 操作系统 - -
    - -- -
    -
    -
    - - 内存使用 - -
    - -- -
    -
    -
    - - CPU 使用 - -
    - -- -
    -
    -
    - - 运行模式 - -
    - -- -
    -
    -
    - - 进程 PID - -
    - -- -
    -
    -
    -
    - - -
    -

    路径路由调用示例

    -

    通过不同路径路由访问不同的AI模型提供商,支持灵活的模型切换

    - -
    - -
    - - 加载中... -
    -
    - -
    -

    使用提示

    -
      -
    • 即时切换: 通过修改URL路径即可切换不同的AI模型提供商
    • -
    • 客户端配置: 在Cherry-Studio、NextChat、Cline等客户端中设置API端点为对应路径
    • -
    • 跨协议调用: 支持OpenAI协议调用Claude模型,或Claude协议调用OpenAI模型
    • -
    -
    - - -
    -

    可用模型列表

    -
    -
    - - 点击模型名称可直接复制到剪贴板 -
    -
    - - -
    -
    - -
    - - 加载中... -
    -
    -
    -
    -
    - -
    \ No newline at end of file diff --git a/static/components/section-guide.css b/static/components/section-guide.css deleted file mode 100644 index e1b977e247d2cc739a6080a8c21ea7b5a4501946..0000000000000000000000000000000000000000 --- a/static/components/section-guide.css +++ /dev/null @@ -1,592 +0,0 @@ -/* Guide Section Styles */ - -/* 操作流程图样式 */ -.process-flow { - display: flex; - flex-wrap: wrap; - align-items: flex-start; - justify-content: center; - gap: 0.5rem; - padding: 1.5rem 0; -} - -.flow-step { - display: flex; - flex-direction: column; - align-items: center; - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 12px; - padding: 1.25rem; - width: 200px; - min-height: 200px; - transition: all 0.3s ease; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); -} - -.flow-step:hover { - transform: translateY(-4px); - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1); - border-color: var(--primary-color); -} - -.step-number { - width: 40px; - height: 40px; - background: linear-gradient(135deg, var(--primary-color), var(--primary-hover)); - color: white; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - font-size: 1.25rem; - font-weight: 700; - margin-bottom: 1rem; - box-shadow: 0 4px 12px rgba(var(--primary-rgb, 59, 130, 246), 0.3); -} - -.step-content { - text-align: center; - flex: 1; -} - -.step-content h4 { - font-size: 0.95rem; - font-weight: 600; - color: var(--text-primary); - margin: 0 0 0.5rem 0; -} - -.step-content p { - font-size: 0.8rem; - color: var(--text-secondary); - margin: 0 0 0.75rem 0; - line-height: 1.4; -} - -.step-content ul { - list-style: none; - padding: 0; - margin: 0; - text-align: left; -} - -.step-content ul li { - font-size: 0.75rem; - color: var(--text-secondary); - padding: 0.25rem 0; - padding-left: 1rem; - position: relative; -} - -.step-content ul li::before { - content: "•"; - position: absolute; - left: 0; - color: var(--primary-color); - font-weight: bold; -} - -.step-content ul li code { - font-size: 0.7rem; - background: var(--bg-tertiary); - padding: 0.1rem 0.3rem; - border-radius: 3px; -} - -/* 并行分支样式 */ -.flow-step-branch { - width: 280px; -} - -.branch-options { - display: flex; - flex-direction: column; - gap: 0.75rem; - margin-top: 0.5rem; -} - -.branch-option { - background: var(--bg-tertiary); - padding: 0.75rem; - border-radius: 8px; - border: 1px solid var(--border-color); -} - -.branch-label { - font-size: 0.8rem; - font-weight: 600; - color: var(--primary-color); - margin-bottom: 0.5rem; -} - -.branch-divider { - text-align: center; - font-size: 0.75rem; - color: var(--text-muted); - font-weight: 500; - position: relative; -} - -.branch-divider::before, -.branch-divider::after { - content: ""; - position: absolute; - top: 50%; - width: 40%; - height: 1px; - background: var(--border-color); -} - -.branch-divider::before { - left: 0; -} - -.branch-divider::after { - right: 0; -} - -.branch-option ul { - margin: 0; -} - -.branch-option ul li { - font-size: 0.7rem; - padding: 0.15rem 0; -} - -.flow-arrow { - display: flex; - align-items: center; - justify-content: center; - font-size: 1.5rem; - color: var(--primary-color); - font-weight: bold; - padding: 0 0.25rem; - margin-top: 80px; -} - -/* 响应式流程图 */ -@media (max-width: 1200px) { - .process-flow { - gap: 0.75rem; - } - - .flow-step { - width: 180px; - min-height: 180px; - padding: 1rem; - } - - .flow-arrow { - font-size: 1.25rem; - margin-top: 70px; - } -} - -@media (max-width: 992px) { - .process-flow { - flex-direction: column; - align-items: center; - } - - .flow-step { - width: 100%; - max-width: 400px; - min-height: auto; - flex-direction: row; - gap: 1rem; - } - - .step-number { - margin-bottom: 0; - flex-shrink: 0; - } - - .step-content { - text-align: left; - } - - .flow-arrow { - transform: rotate(90deg); - margin-top: 0; - padding: 0.5rem 0; - } -} - -/* Guide Panel */ -.guide-panel { - background: var(--bg-primary); - padding: 1.5rem; - border-radius: var(--radius-lg); - box-shadow: var(--shadow-md); - margin-bottom: 1.5rem; - border: 1px solid var(--border-color); -} - -.guide-panel h3 { - font-size: 1.25rem; - font-weight: 600; - color: var(--text-primary); - margin: 0 0 1rem 0; - display: flex; - align-items: center; - gap: 0.5rem; -} - -.guide-panel h3 i { - color: var(--primary-color); -} - -.guide-content { - color: var(--text-secondary); - line-height: 1.6; -} - -.guide-content > p { - margin-bottom: 1.5rem; -} - -/* Feature Grid */ -.feature-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); - gap: 1rem; - margin-top: 1rem; -} - -.feature-card { - background: var(--bg-secondary); - padding: 1.25rem; - border-radius: var(--radius-md); - border: 1px solid var(--border-color); - transition: var(--transition); -} - -.feature-card:hover { - transform: translateY(-2px); - box-shadow: var(--shadow-md); - border-color: var(--primary-30); -} - -.feature-icon { - width: 48px; - height: 48px; - border-radius: var(--radius-md); - display: flex; - align-items: center; - justify-content: center; - font-size: 1.25rem; - color: var(--primary-color); - background: var(--primary-10); - margin-bottom: 0.75rem; -} - -.feature-card h4 { - font-size: 1rem; - font-weight: 600; - color: var(--text-primary); - margin: 0 0 0.5rem 0; -} - -.feature-card p { - font-size: 0.875rem; - color: var(--text-secondary); - margin: 0; -} - -/* Guide Provider List - 使用 guide- 前缀避免与 section-providers.css 冲突 */ -.guide-provider-list { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); - gap: 1rem; -} - -.guide-provider-item { - background: var(--bg-secondary); - padding: 1rem; - border-radius: var(--radius-md); - border: 1px solid var(--border-color); - transition: var(--transition); -} - -.guide-provider-item:hover { - border-color: var(--primary-30); - box-shadow: var(--shadow-sm); -} - -.guide-provider-header { - display: flex; - align-items: center; - gap: 0.5rem; - margin-bottom: 0.5rem; - flex-wrap: wrap; -} - -.guide-provider-icon { - font-size: 1.25rem; -} - -.guide-provider-icon.gemini { color: #4285f4; } -.guide-provider-icon.antigravity { color: #ea4335; } -.guide-provider-icon.kiro { color: #9b59b6; } -.guide-provider-icon.qwen { color: #ff6a00; } -.guide-provider-icon.claude { color: #d97706; } -.guide-provider-icon.openai { color: #10a37f; } -.guide-provider-icon.iflow { color: #3b82f6; } - -.guide-provider-name { - font-weight: 600; - color: var(--text-primary); -} - -.guide-provider-badge { - font-size: 0.7rem; - padding: 0.15rem 0.5rem; - border-radius: 9999px; - font-weight: 500; -} - -.guide-provider-badge.oauth { - background: var(--primary-10); - color: var(--primary-color); -} - -.guide-provider-badge.experimental { - background: var(--warning-bg); - color: var(--warning-text); -} - -.guide-provider-badge.free { - background: var(--success-bg); - color: var(--success-text); -} - -.guide-provider-badge.official { - background: var(--info-bg); - color: var(--info-text); -} - -.guide-provider-desc { - font-size: 0.875rem; - color: var(--text-secondary); - margin: 0; -} - -/* Client Config List */ -.client-config-list { - display: flex; - flex-direction: column; - gap: 1.5rem; -} - -.client-config-item { - background: var(--bg-secondary); - padding: 1.25rem; - border-radius: var(--radius-md); - border: 1px solid var(--border-color); -} - -.client-config-item h4 { - font-size: 1rem; - font-weight: 600; - color: var(--text-primary); - margin: 0 0 1rem 0; - display: flex; - align-items: center; - gap: 0.5rem; -} - -.client-config-item h4 i { - color: var(--primary-color); -} - -.config-steps ol { - margin: 0; - padding-left: 1.5rem; -} - -.config-steps ol li { - margin-bottom: 0.5rem; - color: var(--text-secondary); -} - -.config-steps ol li:last-child { - margin-bottom: 0; -} - -.config-steps code { - background: var(--bg-tertiary); - padding: 0.15rem 0.4rem; - border-radius: var(--radius-sm); - font-size: 0.85rem; - color: var(--primary-color); -} - -.config-steps pre { - background: var(--bg-tertiary); - padding: 1rem; - border-radius: var(--radius-md); - overflow-x: auto; - margin: 0; -} - -.config-steps pre code { - background: none; - padding: 0; - font-size: 0.8rem; - color: var(--text-primary); - white-space: pre; -} - -/* Guide Note */ -.guide-note { - display: flex; - align-items: flex-start; - gap: 0.75rem; - background: var(--info-bg); - padding: 1rem; - border-radius: var(--radius-md); - margin-top: 1rem; - border: 1px solid var(--info-border); -} - -.guide-note i { - color: var(--info-text); - font-size: 1rem; - margin-top: 0.1rem; -} - -.guide-note span { - color: var(--info-text); - font-size: 0.875rem; - line-height: 1.5; -} - -/* API Example */ -.api-example { - background: var(--bg-secondary); - padding: 1rem; - border-radius: var(--radius-md); - margin-bottom: 1rem; - border: 1px solid var(--border-color); -} - -.api-example h4 { - font-size: 0.9rem; - font-weight: 600; - color: var(--text-primary); - margin: 0 0 0.75rem 0; -} - -.api-example pre { - background: var(--bg-tertiary); - padding: 1rem; - border-radius: var(--radius-md); - overflow-x: auto; - margin: 0; -} - -.api-example pre code { - font-size: 0.8rem; - color: var(--text-primary); - white-space: pre; -} - -/* Model Prefix List */ -.model-prefix-list { - background: var(--bg-secondary); - padding: 1rem; - border-radius: var(--radius-md); - border: 1px solid var(--border-color); -} - -.model-prefix-list h4 { - font-size: 0.9rem; - font-weight: 600; - color: var(--text-primary); - margin: 0 0 0.75rem 0; -} - -.model-prefix-list ul { - margin: 0; - padding-left: 1.5rem; -} - -.model-prefix-list ul li { - margin-bottom: 0.5rem; - color: var(--text-secondary); -} - -.model-prefix-list ul li:last-child { - margin-bottom: 0; -} - -.model-prefix-list code { - background: var(--bg-tertiary); - padding: 0.15rem 0.4rem; - border-radius: var(--radius-sm); - font-size: 0.85rem; - color: var(--primary-color); - font-weight: 600; -} - -/* FAQ List */ -.faq-list { - display: flex; - flex-direction: column; - gap: 1rem; -} - -.faq-item { - background: var(--bg-secondary); - padding: 1rem; - border-radius: var(--radius-md); - border: 1px solid var(--border-color); -} - -.faq-question { - font-weight: 600; - color: var(--text-primary); - margin-bottom: 0.5rem; -} - -.faq-answer { - color: var(--text-secondary); - font-size: 0.9rem; - line-height: 1.6; -} - -/* Responsive */ -@media (max-width: 768px) { - .feature-grid { - grid-template-columns: 1fr; - } - - .guide-provider-list { - grid-template-columns: 1fr; - } - - .guide-panel { - padding: 1rem; - } -} - -/* Dark Theme */ -[data-theme="dark"] .guide-panel { - background: var(--bg-primary); -} - -[data-theme="dark"] .feature-card, -[data-theme="dark"] .guide-provider-item, -[data-theme="dark"] .client-config-item, -[data-theme="dark"] .api-example, -[data-theme="dark"] .model-prefix-list, -[data-theme="dark"] .faq-item { - background: var(--bg-secondary); -} - -[data-theme="dark"] .config-steps pre, -[data-theme="dark"] .api-example pre { - background: var(--bg-tertiary); -} diff --git a/static/components/section-guide.html b/static/components/section-guide.html deleted file mode 100644 index b7c39197f9162992dae1ecf8d70b35a5649f042d..0000000000000000000000000000000000000000 --- a/static/components/section-guide.html +++ /dev/null @@ -1,210 +0,0 @@ - - -
    -

    使用指南

    - - -
    -

    项目简介

    -
    -

    AIClient2API 是一个突破客户端限制的 API 代理服务,将 Gemini、Antigravity、Qwen Code、Kiro 等原本仅限客户端内使用的免费大模型,转换为可供任何应用调用的标准 OpenAI 兼容接口。

    -
    -
    -
    -

    统一接入

    -

    通过标准 OpenAI 兼容协议,一次配置即可接入多种大模型

    -
    -
    -
    -

    突破限制

    -

    利用 OAuth 授权机制,有效突破免费 API 速率和配额限制

    -
    -
    -
    -

    协议转换

    -

    支持 OpenAI、Claude、Gemini 三大协议间的智能转换

    -
    -
    -
    -

    账号池管理

    -

    支持多账号轮询、自动故障转移和配置降级

    -
    -
    -
    -
    - - -
    -

    操作流程图

    -
    -
    -
    -
    1
    -
    -

    配置管理

    -

    在「配置管理」页面设置基本参数

    -
      -
    • 设置 API Key
    • -
    • 选择启动时初始化的模型提供商
    • -
    • 配置高级选项
    • -
    -
    -
    - -
    - -
    -
    2
    -
    -

    生成授权

    -

    在「提供商池管理」页面生成 OAuth 授权

    -
    -
    -
    方式一:OAuth 授权
    -
      -
    • 点击「生成授权」按钮
    • -
    • 在弹窗中完成 OAuth 登录
    • -
    • 凭据自动保存
    • -
    -
    -
    -
    -
    方式二:手动上传
    -
      -
    • 新增提供商节点
    • -
    • 上传已有的授权文件
    • -
    • 手动关联凭据路径
    • -
    -
    -
    -
    -
    方式三:对接提供商 API
    -
      -
    • 在「配置管理」设置 API Key 和端点
    • -
    • 系统自动识别并对接
    • -
    • 无需手动上传凭据
    • -
    -
    -
    -
    -
    - -
    - -
    -
    3
    -
    -

    管理凭据

    -

    在「凭据文件管理」页面查看和管理凭据

    -
      -
    • 查看已生成的凭据文件
    • -
    • 自动关联到提供商池
    • -
    • 删除无效凭据
    • -
    -
    -
    - -
    - -
    -
    4
    -
    -

    开始使用

    -

    在「仪表盘」查看路由示例并开始调用 API

    -
      -
    • 查看路由调用示例
    • -
    • 复制 API 端点地址
    • -
    • 在客户端中配置使用
    • -
    -
    -
    -
    -
    -
    - - -
    -

    客户端配置指南

    -
    -

    以下是常见 AI 客户端的配置方法,将 API 端点设置为本服务地址即可使用:

    - -
    -
    -

    Cherry Studio

    -
    -
      -
    1. 打开设置 → 模型服务商
    2. -
    3. 添加自定义服务商
    4. -
    5. 设置 API 地址为: http://localhost:3000/{provider}/v1
    6. -
    7. 填入 API Key(配置文件中的 REQUIRED_API_KEY)
    8. -
    -
    -
    - -
    -

    Cline / Continue

    -
    -
      -
    1. 打开 VS Code 设置
    2. -
    3. 搜索 Cline 或 Continue 配置
    4. -
    5. 设置 API Base URL 为: http://localhost:3000/{provider}/v1
    6. -
    7. 填入 API Key 和模型名称
    8. -
    -
    -
    - -
    -

    通用 cURL 调用

    -
    -
    curl http://localhost:3000/{provider}/v1/chat/completions \
    -  -H "Content-Type: application/json" \
    -  -H "Authorization: Bearer YOUR_API_KEY" \
    -  -d '{
    -    "model": "模型名称",
    -    "messages": [{"role": "user", "content": "Hello!"}],
    -    "max_tokens": 1000
    -  }'
    -
    -
    -
    - -
    - - 提示:将 {provider} 替换为实际的提供商路径,如 gemini-cli-oauth、claude-kiro-oauth 等。可在仪表盘的路由示例中查看完整路径。 -
    -
    -
    - - -
    -

    常见问题

    -
    -
    -
    -
    Q: 请求返回 404 错误怎么办?
    -
    A: 检查接口路径是否正确。某些客户端会自动在 Base URL 后追加路径,导致路径重复。请查看控制台中的实际请求 URL,移除多余的路径部分。
    -
    -
    -
    Q: 请求返回 429 错误怎么办?
    -
    A: 429 表示请求频率过高。建议配置多个账号到提供商池,启用轮询机制;或配置 Fallback 链实现跨类型降级。
    -
    -
    -
    Q: OAuth 授权失败怎么办?
    -
    A: 确保 OAuth 回调端口可访问(Gemini: 8085, Antigravity: 8086, Kiro: 19876-19880)。Docker 用户需确保已正确映射这些端口。
    -
    -
    -
    Q: 流式响应中断怎么办?
    -
    A: 检查网络稳定性,增加客户端请求超时时间。如使用代理,确保代理支持长连接。
    -
    -
    -
    Q: 请求返回 "No available and healthy providers" 错误怎么办?
    -
    A: 这表示对应类型的提供商都不可用。请在"提供商池"页面检查提供商健康状态,确认 OAuth 凭据未过期,或配置 Fallback 链实现自动切换到备用提供商。
    -
    -
    -
    Q: 请求返回 403 Forbidden 错误怎么办?
    -
    A: 403 表示访问被拒绝。首先检查"提供商池"页面中节点状态,如果节点健康检查正常,可以忽略此报错。其他可能原因包括:账号权限不足、API Key 权限受限、地区访问限制、凭据已失效等。
    -
    -
    -
    -
    -
    \ No newline at end of file diff --git a/static/components/section-logs.css b/static/components/section-logs.css deleted file mode 100644 index aa338255e26382fbbdd6148656dc08da580dd538..0000000000000000000000000000000000000000 --- a/static/components/section-logs.css +++ /dev/null @@ -1,89 +0,0 @@ -/* 日志 */ -.logs-controls { - display: flex; - gap: 1rem; - margin-bottom: 1rem; -} - -.logs-container { - background: var(--code-bg); - color: var(--code-text); - padding: 1.5rem; - border-radius: 0.5rem; - height: 800px; - overflow-y: auto; - font-family: 'Courier New', monospace; - font-size: 0.875rem; - box-shadow: var(--shadow-md); -} - -.log-entry { - margin-bottom: 0.5rem; - padding: 0.25rem 0; -} - -.log-time { - color: var(--log-time); -} - -.log-level-info { - color: var(--log-info); -} - -.log-level-error { - color: var(--log-error); -} - -.log-level-warn { - color: var(--log-warn); -} - -/* 系统信息卡片样式 */ -.system-info { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); - gap: 1.5rem; -} - -.info-card { - background: var(--bg-primary); - padding: 1.5rem; - border-radius: 0.5rem; - box-shadow: var(--shadow-md); -} - -.info-card h3 { - font-size: 1.125rem; - font-weight: 600; - margin-bottom: 1rem; - color: var(--text-primary); -} - -.info-row { - display: flex; - justify-content: space-between; - align-items: center; - padding: 0.5rem 0; - border-bottom: 1px solid var(--border-color); -} - -.info-row:last-child { - border-bottom: none; -} - -.info-label { - color: var(--text-secondary); - font-size: 0.875rem; -} - -.info-value { - color: var(--text-primary); - font-weight: 500; - font-size: 0.875rem; -} - -/* 暗黑主题适配 */ -[data-theme="dark"] .logs-container { - background: var(--code-bg); - color: var(--code-text); -} diff --git a/static/components/section-logs.html b/static/components/section-logs.html deleted file mode 100644 index b8bb791eed71993caa028300f206ecbc4f532d03..0000000000000000000000000000000000000000 --- a/static/components/section-logs.html +++ /dev/null @@ -1,19 +0,0 @@ - - -
    -

    实时日志

    -
    - - - -
    -
    - -
    -
    \ No newline at end of file diff --git a/static/components/section-plugins.css b/static/components/section-plugins.css deleted file mode 100644 index 7924e4a05020f1b30db82b88e6a689755e5b3651..0000000000000000000000000000000000000000 --- a/static/components/section-plugins.css +++ /dev/null @@ -1,151 +0,0 @@ -/* 插件管理样式 */ -.plugins-panel { - background: var(--bg-primary); - padding: 2rem; - border-radius: 0.5rem; - box-shadow: var(--shadow-md); -} - -.plugins-description { - margin-bottom: 2rem; -} - -.plugins-stats { - margin-bottom: 2rem; -} - -.plugins-controls { - display: flex; - justify-content: flex-end; - margin-bottom: 1.5rem; -} - -.plugins-list-container { - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 0.5rem; - overflow: hidden; -} - -.plugins-list { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); - gap: 1.5rem; - padding: 1.5rem; -} - -.plugin-card { - background: var(--bg-primary); - border: 1px solid var(--border-color); - border-radius: 0.5rem; - padding: 1.5rem; - transition: var(--transition); - display: flex; - flex-direction: column; - gap: 1rem; -} - -.plugin-card:hover { - transform: translateY(-2px); - box-shadow: var(--shadow-md); - border-color: var(--primary-color); -} - -.plugin-header { - display: flex; - justify-content: space-between; - align-items: flex-start; -} - -.plugin-title h3 { - margin: 0; - font-size: 1.125rem; - font-weight: 600; - color: var(--text-primary); -} - -.plugin-version { - font-size: 0.75rem; - color: var(--text-secondary); - background: var(--bg-tertiary); - padding: 0.125rem 0.5rem; - border-radius: 9999px; - margin-top: 0.25rem; - display: inline-block; -} - -.plugin-description { - font-size: 0.875rem; - color: var(--text-secondary); - line-height: 1.5; - flex: 1; -} - -.plugin-badges { - display: flex; - gap: 0.5rem; - flex-wrap: wrap; -} - -.plugin-badge { - font-size: 0.75rem; - padding: 0.25rem 0.5rem; - border-radius: 0.25rem; - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.plugin-badge.middleware { background: var(--info-bg); color: var(--info-text); } -.plugin-badge.routes { background: var(--success-bg); color: var(--success-text); } -.plugin-badge.hooks { background: var(--warning-bg); color: var(--warning-text); } - -.plugin-status { - display: flex; - align-items: center; - gap: 0.5rem; - font-size: 0.875rem; - font-weight: 500; - padding-top: 1rem; - border-top: 1px solid var(--border-color); -} - -.plugin-card.enabled .plugin-status { - color: var(--success-color); -} - -.plugin-card.disabled .plugin-status { - color: var(--text-secondary); -} - -.plugin-card.disabled { - opacity: 0.8; - background: var(--bg-secondary); -} - -.plugins-loading, -.plugins-empty { - text-align: center; - padding: 3rem; - color: var(--text-secondary); -} - -.plugins-empty { - display: none; - flex-direction: column; - align-items: center; - gap: 1rem; -} - -.plugins-empty i { - font-size: 3rem; - opacity: 0.5; -} - -/* 暗黑主题适配 */ -[data-theme="dark"] .plugins-list-container { background: var(--bg-secondary); } -[data-theme="dark"] .plugin-card { background: var(--bg-primary); } -[data-theme="dark"] .plugin-card.disabled { background: var(--bg-tertiary); } -[data-theme="dark"] .plugin-badge.middleware { background: var(--info-bg); color: var(--info-text); } -[data-theme="dark"] .plugin-badge.routes { background: var(--success-bg); color: var(--success-text); } -[data-theme="dark"] .plugin-badge.hooks { background: var(--warning-bg); color: var(--warning-text); } diff --git a/static/components/section-plugins.html b/static/components/section-plugins.html deleted file mode 100644 index e9a042356fe73e4e2bfaf78abba99bac522f6cbf..0000000000000000000000000000000000000000 --- a/static/components/section-plugins.html +++ /dev/null @@ -1,67 +0,0 @@ - - -
    -

    插件管理

    -
    -
    -
    - - 插件系统允许您扩展系统功能,启用或禁用插件需要重启服务才能生效 -
    -
    - - -
    -
    -
    - -
    -
    -

    0

    -

    总插件数

    -
    -
    -
    -
    - -
    -
    -

    0

    -

    已启用

    -
    -
    -
    -
    - -
    -
    -

    0

    -

    已禁用

    -
    -
    -
    - - -
    - -
    - - - - - -
    -
    - -
    -
    - -

    暂无已安装的插件

    -
    -
    -
    -
    \ No newline at end of file diff --git a/static/components/section-providers.css b/static/components/section-providers.css deleted file mode 100644 index c14907d1c3caebd4a6642b235e2e0dfa3a820c07..0000000000000000000000000000000000000000 --- a/static/components/section-providers.css +++ /dev/null @@ -1,1458 +0,0 @@ -/* 统计卡片 */ -.stats-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: 1.5rem; - margin-bottom: 2rem; -} - -.stat-card { - background: var(--bg-primary); - padding: 1.5rem; - border-radius: var(--radius-xl); - border: 1px solid var(--border-color); - box-shadow: var(--shadow-sm); - display: flex; - align-items: center; - gap: 1.25rem; - transition: var(--transition); - position: relative; - overflow: hidden; -} - -.stat-card:hover { - transform: translateY(-4px); - box-shadow: var(--shadow-lg); - border-color: var(--primary-30); -} - -.stat-icon { - width: 56px; - height: 56px; - border-radius: var(--radius-lg); - display: flex; - align-items: center; - justify-content: center; - font-size: 1.5rem; - color: var(--primary-color); - background: var(--primary-10); - flex-shrink: 0; -} - -.stat-info h3 { - font-size: 2rem; - font-weight: 700; - margin-bottom: 0.25rem; -} - -.stat-info p { - color: var(--text-secondary); - font-size: 0.875rem; -} - -/* 提供商列表 */ -.providers-container { - background: var(--bg-primary); - padding: 1.5rem; - border-radius: var(--radius-xl); - box-shadow: var(--shadow-sm); - border: 1px solid var(--border-color); -} - -.providers-list { - display: flex; - flex-direction: column; - gap: 1rem; -} - -/* 模态框中的提供商列表 */ -.provider-list { - display: flex; - flex-direction: column; - gap: 1rem; -} - -.provider-item { - border: 1px solid var(--border-color); - border-radius: var(--radius-lg); - padding: 1.5rem; - transition: var(--transition); - background: var(--bg-secondary); -} - -.provider-item:hover { - border-color: var(--primary-color); - box-shadow: var(--shadow-md); - background: var(--bg-primary); -} - -.provider-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 1rem; -} - -.provider-name { - font-size: 1.125rem; - font-weight: 600; - color: var(--text-primary); -} - -.provider-header-right { - display: flex; - align-items: center; - gap: 0.75rem; -} - -.provider-status { - display: inline-flex; - align-items: center; - gap: 0.5rem; - padding: 0.25rem 0.75rem; - border-radius: 9999px; - font-size: 0.75rem; - font-weight: 500; -} - -/* Path Routing Examples Panel */ -.routing-examples-panel { - background: var(--bg-primary); - padding: 1.5rem; - border-radius: 0.5rem; - box-shadow: var(--shadow-md); - margin-top: 1.5rem; - margin-bottom: 1.5rem; -} - -.routing-examples-panel h3 { - font-size: 1.25rem; - font-weight: 600; - margin-bottom: 0.5rem; - color: var(--text-primary); - display: flex; - align-items: center; - gap: 0.5rem; -} - -.routing-examples-panel h3 i { - color: var(--primary-color); -} - -.routing-description { - color: var(--text-secondary); - font-size: 0.875rem; - margin-bottom: 1.5rem; - line-height: 1.5; -} - -.routing-examples-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); - gap: 1.5rem; - margin-bottom: 2rem; -} - -.routing-example-card { - border: 1px solid var(--border-color); - border-radius: 0.5rem; - overflow: hidden; - transition: var(--transition); - background: var(--bg-secondary); -} - -.routing-example-card:hover { - border-color: var(--primary-color); - box-shadow: var(--shadow-md); - transform: translateY(-2px); -} - -.routing-card-header { - background: var(--bg-primary); - padding: 1rem 1.5rem; - display: flex; - align-items: center; - gap: 0.75rem; - border-bottom: 1px solid var(--border-color); -} - -.routing-card-header i { - font-size: 1.25rem; - color: var(--primary-color); -} - -.routing-card-header h4 { - font-size: 1rem; - font-weight: 600; - color: var(--text-primary); - margin: 0; - flex: 1; -} - -.provider-badge { - padding: 0.25rem 0.5rem; - border-radius: 9999px; - font-size: 0.75rem; - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.provider-badge.official { background: var(--info-bg); color: var(--info-text); } -.provider-badge.oauth { background: var(--success-bg); color: var(--success-text); } -.provider-badge.responses { background: var(--warning-bg); color: var(--warning-text); } - -.routing-card-content { - padding: 1.5rem; -} - -/* 协议标签样式 */ -.protocol-tabs { - display: flex; - margin-bottom: 1rem; - border-bottom: 1px solid var(--border-color); -} - -.protocol-tab { - background: none; - border: none; - padding: 0.75rem 1rem; - cursor: pointer; - font-size: 0.875rem; - font-weight: 500; - color: var(--text-secondary); - border-bottom: 2px solid transparent; - transition: var(--transition); - position: relative; -} - -.protocol-tab:hover { - color: var(--primary-color); - background: var(--bg-tertiary); -} - -.protocol-tab.active { - color: var(--primary-color); - border-bottom-color: var(--primary-color); - background: var(--bg-secondary); -} - -/* 协议内容区域 */ -.protocol-content { - display: none; - animation: fadeIn 0.3s ease; -} - -.provider-stats { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); - gap: 1rem; - margin-top: 1rem; -} - -.provider-stat { - display: flex; - flex-direction: column; -} - -.provider-stat-label { - font-size: 0.75rem; - color: var(--text-secondary); - margin-bottom: 0.25rem; -} - -.provider-stat-value { - font-size: 1.125rem; - font-weight: 600; - color: var(--text-primary); -} - -.protocol-content.active { - display: block; -} - -.endpoint-info { - margin-bottom: 1rem; -} - -.endpoint-info label { - display: block; - font-size: 0.875rem; - font-weight: 500; - color: var(--text-secondary); - margin-bottom: 0.5rem; -} - -.endpoint-path { - display: inline-block; - background: var(--bg-primary); - padding: 0.5rem 0.75rem; - border-radius: 0.375rem; - font-family: 'Courier New', monospace; - font-size: 0.875rem; - color: var(--text-primary); - border: 1px solid var(--border-color); - position: relative; - padding-right: 2.5rem; -} - -.copy-btn { - position: absolute; - right: 0.5rem; - top: 50%; - transform: translateY(-50%); - background: none; - border: none; - color: var(--text-secondary); - cursor: pointer; - padding: 0.25rem; - border-radius: 0.25rem; - transition: var(--transition); -} - -.copy-btn:hover { - background: var(--bg-tertiary); - color: var(--primary-color); -} - -.usage-example { - margin-top: 1rem; -} - -.usage-example label { - display: block; - font-size: 0.875rem; - font-weight: 500; - color: var(--text-secondary); - margin-bottom: 0.5rem; -} - -.usage-example pre { - background: var(--code-bg); - color: var(--code-text); - padding: 1rem; - border-radius: 0.375rem; - overflow-x: auto; - font-size: 0.75rem; - line-height: 1.4; - margin: 0; -} - -.usage-example code { - font-family: 'Courier New', monospace; - white-space: pre-wrap; -} - -.routing-tips { - background: var(--bg-secondary); - padding: 1.5rem; - border-radius: 0.5rem; - border-left: 4px solid var(--primary-color); -} - -.routing-tips h4 { - font-size: 1rem; - font-weight: 600; - color: var(--text-primary); - margin-bottom: 1rem; - display: flex; - align-items: center; - gap: 0.5rem; -} - -.routing-tips h4 i { - color: var(--warning-color); -} - -.routing-tips ul { - margin: 0; - padding-left: 1.5rem; -} - -.routing-tips li { - margin-bottom: 0.75rem; - color: var(--text-secondary); - font-size: 0.875rem; - line-height: 1.5; -} - -.routing-tips code { - background: var(--bg-primary); - padding: 0.125rem 0.25rem; - border-radius: 0.25rem; - font-family: 'Courier New', monospace; - font-size: 0.75rem; - color: var(--primary-color); -} - -/* 提供商类型显示 */ -.provider-type-text { - font-size: 16px; - font-weight: 600; - color: var(--secondary-color); - cursor: pointer; - padding: 4px 8px; - border-radius: 4px; - transition: all 0.3s ease; -} - -.provider-type-text:hover { - background: var(--bg-tertiary); - color: var(--primary-color); -} - -/* 模态框样式 */ -.provider-modal { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: rgba(0, 0, 0, 0.4); - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); - display: flex; - justify-content: center; - align-items: center; - z-index: 1000; - animation: fadeIn 0.2s cubic-bezier(0.4, 0, 0.2, 1); -} - -.provider-modal-content { - background: var(--bg-primary); - border-radius: var(--radius-xl); - width: 95%; - max-width: 1200px; - max-height: 85vh; - overflow: hidden; - box-shadow: var(--shadow-xl); - animation: slideIn 0.3s cubic-bezier(0.16, 1, 0.3, 1); - border: 1px solid var(--border-color); -} - -.provider-modal-header { - padding: 1.5rem; - border-bottom: 1px solid var(--border-color); - display: flex; - justify-content: space-between; - align-items: center; - background: var(--bg-secondary); -} - -.provider-modal-header h3 { - margin: 0; - color: var(--neutral-700); - font-size: 20px; - font-weight: 600; -} - -.modal-close { - background: none; - border: none; - font-size: 1.5rem; - cursor: pointer; - color: var(--neutral-500); - padding: 0.5rem; - border-radius: 50%; - transition: var(--transition); - width: 2rem; - height: 2rem; - display: flex; - align-items: center; - justify-content: center; - line-height: 1; -} - -.modal-close:hover { - background: var(--neutral-200); - color: var(--neutral-600); - transform: rotate(90deg); -} - -.modal-cancel { - padding: 0.75rem 1.5rem; - background: var(--bg-tertiary); - color: var(--text-primary); - border: 1px solid var(--border-color); - border-radius: 0.375rem; - font-size: 0.875rem; - font-weight: 500; - cursor: pointer; - transition: var(--transition); -} - -.modal-cancel:hover { - background: var(--neutral-200); - border-color: var(--neutral-500); - transform: translateY(-1px); -} - -/* 授权信息样式 */ -.auth-info { - display: flex; - flex-direction: column; - gap: 1.5rem; -} - -.auth-info p { - margin: 0; - font-size: 0.875rem; - color: var(--text-secondary); -} - -.auth-info strong { - color: var(--text-primary); - font-weight: 600; -} - -.auth-url-container { - position: relative; - display: flex; - align-items: center; -} - -.auth-url-container .copy-btn { - position: absolute; - right: 0.5rem; - top: 50%; - transform: translateY(-50%); - background: var(--primary-color); - color: white; - border: none; - padding: 0.5rem; - border-radius: 0.25rem; - cursor: pointer; - transition: var(--transition); -} - -.auth-url-container .copy-btn:hover { - background: var(--btn-primary-hover); -} - -.provider-modal-body { - padding: 24px; - max-height: calc(85vh - 80px); - overflow-y: auto; -} - -.provider-summary { - display: flex; - gap: 24px; - align-items: center; - margin-bottom: 24px; - padding: 20px; - background: linear-gradient(135deg, var(--neutral-100) 0%, var(--bg-primary) 100%); - border-radius: 12px; - border: 1px solid var(--neutral-200); -} - -.provider-summary-item { - display: flex; - flex-direction: column; - align-items: center; - padding: 12px; -} - -.provider-summary-item .label { - font-size: 13px; - color: var(--neutral-500); - margin-bottom: 8px; - font-weight: 500; -} - -.provider-summary-item .value { - font-size: 24px; - font-weight: bold; - color: var(--neutral-700); -} - -.provider-summary-actions { - margin-left: auto; - display: flex; - align-items: center; - gap: 0.75rem; -} - -.provider-actions { - margin-bottom: 24px; -} - -.provider-item-detail { - border: 1px solid var(--neutral-200); - border-radius: 12px; - margin-bottom: 16px; - overflow: hidden; - box-shadow: 0 2px 8px var(--neutral-shadow-md); - transition: all 0.3s ease; - position: relative; -} - -.provider-item-detail:hover { - box-shadow: 0 4px 16px var(--neutral-shadow-lg); - transform: translateY(-1px); -} - -.provider-item-header { - padding: 20px; - background: linear-gradient(135deg, var(--neutral-100) 0%, var(--bg-primary) 100%); - display: flex; - justify-content: space-between; - align-items: center; - cursor: pointer; - transition: all 0.3s ease; -} - -.provider-item-header:hover { - background: linear-gradient(135deg, var(--neutral-200) 0%, var(--neutral-100) 100%); -} - -.provider-info { - flex: 1; -} - -.provider-name { - font-weight: 600; - margin-bottom: 8px; - color: var(--neutral-700); - font-size: 15px; -} - -.provider-meta { - font-size: 13px; - color: var(--neutral-500); - line-height: 1.4; -} - -.provider-health-meta { - font-size: 12px; - color: var(--neutral-800); - margin-top: 4px; - line-height: 1.4; -} - -.provider-health-meta i { - margin-right: 4px; - opacity: 0.7; -} - -.provider-error-info { - margin-top: 8px; - padding: 8px 12px; - background: linear-gradient(135deg, var(--danger-bg-alt) 0%, var(--danger-bg-medium) 100%); - border: 1px solid var(--danger-border-light); - border-radius: 6px; - font-size: 12px; - display: flex; - align-items: flex-start; - gap: 8px; -} - -.provider-error-info i { - color: var(--danger-icon); - flex-shrink: 0; - margin-top: 2px; -} - -.provider-error-info .error-label { - color: var(--danger-label); - font-weight: 600; - white-space: nowrap; -} - -.provider-error-info .error-message { - color: var(--danger-text-dark); - word-break: break-word; - max-height: 60px; - overflow-y: auto; -} - -.provider-actions-group { - display: flex; - gap: 8px; - align-items: center; -} - -.btn-edit { - background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); - color: var(--white); - box-shadow: 0 2px 8px var(--primary-30); -} - -.btn-edit:hover { - transform: translateY(-1px); - box-shadow: 0 4px 12px var(--primary-40); -} - -.btn-delete { - background: linear-gradient(135deg, var(--danger-alt) 0%, var(--danger-secondary) 100%); - color: var(--white); - box-shadow: 0 2px 8px var(--danger-30); -} - -.btn-delete:hover { - transform: translateY(-1px); - box-shadow: 0 4px 12px var(--danger-40); -} - -.btn-quick-link { - background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); - color: white; - border: none; - border-radius: 4px; - padding: 4px 8px; - font-size: 11px; - cursor: pointer; - margin-left: 8px; - display: inline-flex; - align-items: center; - gap: 4px; - transition: all 0.2s ease; - box-shadow: 0 2px 6px var(--indigo-30); -} - -.btn-quick-link:hover { - transform: translateY(-1px); - box-shadow: var(--shadow-md); - background: linear-gradient(135deg, var(--btn-primary-hover) 0%, var(--primary-color) 100%); -} - -.btn-batch-link { - background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); - color: white; - border: none; - border-radius: 6px; - padding: 6px 12px; - font-size: 12px; - cursor: pointer; - margin-left: 12px; - display: inline-flex; - align-items: center; - gap: 6px; - transition: all 0.2s ease; - box-shadow: 0 2px 8px var(--indigo-30); - font-weight: 500; -} - -.btn-batch-link:hover { - transform: translateY(-1px); - box-shadow: var(--shadow-md); - background: linear-gradient(135deg, var(--btn-primary-hover) 0%, var(--primary-color) 100%); -} - -.btn-save { - background: linear-gradient(135deg, var(--btn-success) 0%, var(--btn-success-secondary) 100%); - color: var(--white); - box-shadow: 0 2px 8px var(--danger-30); -} - -.btn-cancel { - background: linear-gradient(135deg, var(--neutral-500) 0%, var(--neutral-600) 100%); - color: var(--white); - box-shadow: 0 2px 8px var(--primary-30); -} - -.provider-item-content { - padding: 20px; - display: none; - border-top: 1px solid var(--neutral-200); - background: var(--bg-primary); -} - -.provider-item-content.expanded { - display: block; -} - -.config-item { - display: flex; - flex-direction: column; -} - -.config-item label { - font-size: 13px; - color: var(--neutral-600); - margin-bottom: 8px; - font-weight: 500; -} - -.config-item input, .config-item textarea, .config-item select { - padding: 12px; - border: 2px solid var(--neutral-200); - border-radius: 8px; - font-size: 13px; - transition: all 0.3s ease; -} - -.config-item input:focus, .config-item textarea:focus, .config-item select:focus { - outline: none; - border-color: var(--primary-color); - box-shadow: 0 0 0 3px var(--primary-10); -} - -.config-item input[readonly], .config-item select[disabled] { - background: var(--neutral-100); - color: var(--neutral-500); -} - -/* 模态框中的文件上传输入框样式 */ -.config-item .file-input-group { - position: relative; - display: flex; - align-items: center; - gap: 0.5rem; -} - -.config-item .file-input-group input { - flex: 1; - padding-right: 0.75rem; - box-sizing: border-box; -} - -/* 模态框中的密码输入框样式 */ -.config-item .password-input-wrapper { - position: relative; - width: 100%; -} - -.config-item .password-input-wrapper input { - width: 100%; - padding-right: 2.5rem; - box-sizing: border-box; -} - -.config-item .password-toggle { - position: absolute; - right: 0.5rem; - padding: 0.25rem; - background: none; - border: none; - cursor: pointer; - color: var(--text-secondary); - transition: var(--transition); - width: auto; - height: auto; - line-height: 1; -} - -.add-provider-form { - background: linear-gradient(135deg, var(--neutral-100) 0%, var(--bg-primary) 100%); - padding: 24px; - border-radius: 12px; - margin-bottom: 24px; - border: 1px solid var(--neutral-200); -} - -.add-provider-form h4 { - margin: 0 0 20px 0; - color: var(--neutral-700); - font-size: 18px; - font-weight: 600; -} - -.form-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); - gap: 20px; - margin-bottom: 20px; -} - -.form-grid.full-width { - grid-column: 1 / -1; -} - -/* 无提供商提示 */ -.no-providers { - text-align: center; - padding: 2rem; - color: var(--text-secondary); -} - -/* 健康状态高亮样式 */ -.provider-item-detail.unhealthy { - border: 2px solid var(--warning-color); - background: linear-gradient(135deg, var(--warning-bg) 0%, var(--bg-primary) 100%); - box-shadow: 0 4px 12px var(--warning-15); - animation: pulseWarning 2s infinite; -} - -.provider-item-detail.unhealthy:hover { - border-color: var(--warning-color); - box-shadow: 0 6px 20px var(--warning-25); - transform: translateY(-2px); -} - -.provider-item-detail.unhealthy .provider-item-header { - background: linear-gradient(135deg, var(--warning-bg) 0%, var(--bg-primary) 100%); - border-bottom: 1px solid var(--warning-20); -} - -.provider-item-detail.healthy { - border: 1px solid var(--neutral-200); - background: linear-gradient(135deg, var(--neutral-100) 0%, var(--bg-primary) 100%); -} - -.provider-item-detail.healthy:hover { - border-color: var(--primary-color); - box-shadow: 0 4px 16px var(--neutral-shadow-lg); -} - -.health-status { - display: inline-flex; - align-items: center; - gap: 0.5rem; - font-weight: 500; - padding: 0.25rem 0.5rem; - border-radius: 6px; - transition: var(--transition); -} - -.provider-item-detail.unhealthy .health-status { - color: var(--warning-text); - background: var(--warning-15); -} - -.provider-item-detail.healthy .health-status { - color: var(--success-text); - background: var(--success-10); -} - -@keyframes pulseWarning { - 0%, 100% { box-shadow: 0 4px 12px var(--warning-15); } - 50% { box-shadow: 0 4px 12px var(--warning-30); } -} - -.provider-item-detail.unhealthy::before { - content: ''; - position: absolute; - left: 0; - top: 0; - bottom: 0; - width: 4px; - background: linear-gradient(135deg, var(--warning-color) 0%, var(--warning-color) 100%); - border-radius: 0 2px 2px 0; - z-index: 1; -} - -/* 禁用提供商状态样式 */ -.provider-item-detail.disabled { - opacity: 0.6; - background: linear-gradient(135deg, var(--neutral-100) 0%, var(--bg-primary) 100%); - border: 1px solid var(--neutral-300); - position: relative; -} - -.provider-item-detail.disabled::before { - content: ''; - position: absolute; - left: 0; - top: 0; - bottom: 0; - width: 4px; - background: linear-gradient(135deg, var(--neutral-500) 0%, var(--neutral-600) 100%); - border-radius: 0 2px 2px 0; - z-index: 1; -} - -.provider-item-detail.disabled:hover { - opacity: 0.8; - border-color: var(--neutral-400); - box-shadow: 0 2px 8px var(--neutral-shadow-lg); - transform: none; -} - -.provider-item-detail.disabled .provider-item-header { - background: linear-gradient(135deg, var(--neutral-200) 0%, var(--bg-primary) 100%); -} - -.provider-item-detail.disabled .provider-name { - color: var(--neutral-500); - text-decoration: line-through; -} - -.disabled-status { - display: inline-flex; - align-items: center; - gap: 0.5rem; - font-weight: 500; - padding: 0.25rem 0.5rem; - border-radius: 6px; - transition: var(--transition); -} - -.provider-item-detail.disabled .disabled-status { - color: var(--neutral-500); - background: var(--primary-10); -} - -.provider-item-detail:not(.disabled) .disabled-status { - color: var(--primary-color); - background: var(--success-10); -} - -/* 禁用/启用按钮特殊样式 */ -.btn-warning { - background: linear-gradient(135deg, var(--warning-color) 0%, var(--warning-text-dark) 100%); - color: var(--white); - box-shadow: 0 2px 8px var(--warning-30); -} - -.btn-warning:hover { - transform: translateY(-1px); - box-shadow: 0 4px 12px var(--warning-40); -} - -/* 健康检测按钮样式 */ -.btn-info { - background: linear-gradient(135deg, var(--info-color) 0%, var(--info-color-dark) 100%); - color: var(--white); -} - -.btn-info:hover { - transform: translateY(-1px); - box-shadow: 0 4px 12px var(--info-hover); -} - -/* 删除不健康节点按钮样式 */ -.provider-summary-actions .btn-danger { - background: linear-gradient(135deg, var(--danger-alt) 0%, var(--danger-secondary) 100%); - color: var(--white); - border: none; - box-shadow: 0 2px 8px var(--danger-30); -} - -.provider-summary-actions .btn-danger:hover { - transform: translateY(-1px); - box-shadow: 0 4px 12px var(--danger-40); - background: linear-gradient(135deg, var(--danger-color) 0%, var(--danger-alt) 100%); -} - -/* 刷新不健康UUID按钮样式 */ -.provider-summary-actions .btn-secondary { - background: linear-gradient(135deg, var(--neutral-500) 0%, var(--neutral-600) 100%); - color: var(--white); - border: none; - box-shadow: 0 2px 8px var(--neutral-shadow-lg); -} - -.provider-summary-actions .btn-secondary:hover { - transform: translateY(-1px); - box-shadow: 0 4px 12px var(--neutral-shadow-lg); - background: linear-gradient(135deg, var(--neutral-600) 0%, var(--neutral-700) 100%); -} - -/* 不支持的模型选择器样式 */ -.not-supported-models-section { - grid-column: 1 / -1; - margin-top: 16px; -} - -.not-supported-models-section label { - display: flex; - align-items: center; - gap: 8px; - font-weight: 600; - color: var(--neutral-700); - margin-bottom: 12px; -} - -.not-supported-models-section .help-text { - font-size: 12px; - font-weight: normal; - color: var(--neutral-500); - margin-left: 4px; -} - -.not-supported-models-container { - background: var(--neutral-100); - border: 1px solid var(--neutral-300); - border-radius: 8px; - padding: 16px; - min-height: 100px; -} - -.models-loading { - display: flex; - align-items: center; - justify-content: center; - gap: 8px; - color: var(--neutral-500); - padding: 20px; -} - -.models-checkbox-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); - gap: 10px; -} - -.model-checkbox-label { - display: flex; - align-items: center; - gap: 6px; - padding: 6px 10px; - background: var(--white); - border: 1px solid var(--neutral-300); - border-radius: 6px; - cursor: pointer; - transition: all 0.2s ease; - min-width: 0; -} - -.model-checkbox-label:hover { - background: var(--neutral-200); - border-color: var(--neutral-400); -} - -.model-checkbox-label .model-name { - font-size: 12px; - color: var(--neutral-600); - user-select: none; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.model-checkbox-label input[type="checkbox"]:checked + .model-name { - color: var(--danger-alt); - font-weight: 500; -} - -/* 授权按钮样式 */ -.generate-auth-btn { - display: inline-flex; - align-items: center; - gap: 0.5rem; - padding: 0.25rem 0.75rem; - background: var(--info-bg-light); - color: var(--info-text-dark); - border: none; - border-radius: 9999px; - font-size: 0.75rem; - font-weight: 500; - cursor: pointer; - transition: var(--transition); -} - -.generate-auth-btn:hover { - background: var(--info-hover); - color: var(--info-text-darker); - transform: translateY(-1px); -} - -/* 授权模态框样式 */ -.modal-overlay { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: var(--overlay-bg); - backdrop-filter: blur(4px); - display: none; - justify-content: center; - align-items: center; - z-index: 1000; - animation: fadeIn 0.3s ease; -} - -.modal-content { - background: var(--bg-primary); - border-radius: 0.75rem; - width: 90%; - max-width: 600px; - max-height: 85vh; - overflow: hidden; - box-shadow: 0 25px 80px var(--neutral-shadow-40); - display: flex; - flex-direction: column; - animation: modalSlideIn 0.3s ease; -} - -.modal-header { - padding: 1.5rem 2rem; - border-bottom: 1px solid var(--border-color); - display: flex; - justify-content: space-between; - align-items: center; - background: linear-gradient(135deg, var(--info-bg-alt) 0%, var(--white) 100%); -} - -.modal-header h3 { - margin: 0; - font-size: 1.25rem; - font-weight: 600; - color: var(--text-primary); - display: flex; - align-items: center; - gap: 0.5rem; -} - -.modal-body { - padding: 2rem; - flex: 1; - overflow-y: auto; -} - -.modal-footer { - padding: 1.5rem 2rem; - border-top: 1px solid var(--border-color); - display: flex; - justify-content: flex-end; - gap: 1rem; - background: linear-gradient(135deg, var(--bg-secondary) 0%, var(--white) 100%); -} - -.open-auth-btn { - padding: 0.75rem 1.5rem; - background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); - color: white; - border: none; - border-radius: 0.375rem; - font-size: 0.875rem; - font-weight: 500; - cursor: pointer; - transition: var(--transition); - box-shadow: 0 2px 8px var(--primary-30); - display: inline-flex; - align-items: center; - gap: 0.5rem; -} - -.open-auth-btn:hover { - background: linear-gradient(135deg, var(--btn-primary-hover) 0%, var(--primary-color) 100%); - transform: translateY(-2px); - box-shadow: 0 4px 12px var(--primary-40); -} - -.auth-instructions { - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 0.5rem; - padding: 1.5rem; - border-left: 4px solid var(--primary-color); -} - -.auth-instructions h4 { margin: 0 0 1rem 0; font-size: 1rem; font-weight: 600; } -.auth-instructions ol { margin: 0 0 1rem 1.5rem; padding: 0; } -.auth-instructions li { margin-bottom: 0.5rem; font-size: 0.875rem; line-height: 1.5; } - -.auth-url-input { - flex: 1; - padding: 0.75rem; - padding-right: 3rem; - border: 1px solid var(--border-color); - border-radius: 0.375rem; - font-size: 0.875rem; - font-family: 'Courier New', monospace; - background: var(--bg-tertiary); - color: var(--text-primary); -} - -.auth-url-input:focus { - outline: none; - border-color: var(--primary-color); - box-shadow: 0 0 0 3px var(--primary-10); -} - -/* 高亮说明样式 */ -.highlight-note { - display: inline-flex; - align-items: center; - gap: 0.5rem; - padding: 1rem; - background: linear-gradient(135deg, var(--warning-bg) 0%, var(--warning-bg-light) 100%); - border: 1px solid var(--warning-border); - border-radius: 0.5rem; - margin-bottom: 1.5rem; - color: var(--warning-text); - font-weight: 500; - width: 100%; - box-sizing: border-box; -} - -.highlight-note i { - color: var(--warning-color); - font-size: 1.25rem; - flex-shrink: 0; -} - -.highlight-note span { - flex: 1; - text-align: center; -} - - -.form-group { - margin-bottom: 1.5rem; -} - -.form-row { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 1.5rem; -} - -.form-group label { - display: block; - margin-bottom: 0.5rem; - font-weight: 600; - color: var(--text-primary); - font-size: 0.9rem; -} - -.optional-tag, .form-group label .optional-mark { - font-size: 0.75rem; - color: var(--text-tertiary); - font-weight: 400; - margin-left: 0.5rem; - background: var(--bg-tertiary); - padding: 0.125rem 0.375rem; - border-radius: var(--radius-sm); -} - -.form-control { - width: 100%; - padding: 0.75rem 1rem; - border: 1px solid var(--border-color); - border-radius: var(--radius-lg); - font-size: 0.9rem; - transition: var(--transition); - background: var(--bg-secondary); - color: var(--text-primary); -} - -.form-control:focus { - outline: none; - border-color: var(--primary-color); - background: var(--bg-primary); - box-shadow: 0 0 0 4px var(--primary-10); -} - -.form-control::placeholder { - color: var(--text-tertiary); -} - - -/* 复选框样式 */ -.form-group input[type="checkbox"] { - width: 1rem; - height: 1rem; - accent-color: var(--primary-color); - cursor: pointer; -} - -.form-group { - display: flex; - flex-direction: column; -} - -.form-group label { - font-size: 13px; - color: var(--neutral-600); - margin-bottom: 8px; - font-weight: 500; -} - -.form-group input, .form-group select { - padding: 12px; - border: 2px solid var(--neutral-200); - border-radius: 8px; - font-size: 14px; - transition: all 0.3s ease; -} - -.form-group input:focus, .form-group select:focus { - outline: none; - border-color: var(--primary-color); - box-shadow: 0 0 0 3px var(--primary-10); -} - -/* 文件上传输入框样式 */ -.file-input-group { - position: relative; - display: flex; - align-items: center; - gap: 0.5rem; -} - -.file-input-group .form-control { - flex: 1; - padding-right: 0.75rem; -} - -.file-input-group .btn-outline { - height: 38px; - width: 38px; - padding: 0; - display: flex; - align-items: center; - justify-content: center; - border-radius: 6px; - flex-shrink: 0; - background: var(--bg-tertiary); - color: var(--text-secondary); - border: 1px solid var(--border-color); -} - -.file-input-group .btn-outline:hover { - background: var(--bg-secondary); - color: var(--primary-color); - border-color: var(--primary-color); -} - -/* 模态框中的文件上传输入框样式 */ -.config-item .file-input-group { - position: relative; - display: flex; - align-items: center; - gap: 0.5rem; -} - -.config-item .file-input-group input { - flex: 1; - padding-right: 0.75rem; - box-sizing: border-box; -} - -/* 响应式调整 */ -@media (max-width: 768px) { - .routing-examples-grid { grid-template-columns: 1fr; } - .provider-modal-content { width: 98%; max-height: 95vh; } - .provider-summary { flex-direction: column; align-items: flex-start; gap: 16px; } - .provider-summary-actions { margin-left: 0; } - .form-grid { grid-template-columns: 1fr; } - .provider-item-detail.disabled { opacity: 0.5; } - .disabled-status { font-size: 0.75rem; padding: 0.2rem 0.4rem; } - .provider-actions-group { flex-wrap: wrap; gap: 0.5rem; } - .modal-content { width: 95%; max-height: 90vh; } - .stats-grid { grid-template-columns: 1fr; } -} - -/* 暗黑主题适配 */ -[data-theme="dark"] .provider-modal-content { background: var(--bg-primary); } -[data-theme="dark"] .provider-modal-header { background: linear-gradient(135deg, var(--bg-secondary) 0%, var(--bg-tertiary) 100%); } -[data-theme="dark"] .provider-summary { background: linear-gradient(135deg, var(--bg-secondary) 0%, var(--bg-primary) 100%); border-color: var(--border-color); } -[data-theme="dark"] .add-provider-form { background: linear-gradient(135deg, var(--bg-secondary) 0%, var(--bg-primary) 100%); border-color: var(--border-color); } -[data-theme="dark"] .provider-item-detail { border-color: var(--border-color); } -[data-theme="dark"] .provider-item-header { background: linear-gradient(135deg, var(--bg-secondary) 0%, var(--bg-primary) 100%); } -[data-theme="dark"] .provider-item-header:hover { background: linear-gradient(135deg, var(--bg-tertiary) 0%, var(--bg-secondary) 100%); } -[data-theme="dark"] .provider-item-detail.unhealthy { border-color: var(--warning-color); background: linear-gradient(135deg, var(--warning-bg) 0%, var(--bg-primary) 100%); } -[data-theme="dark"] .provider-error-info { background: linear-gradient(135deg, var(--danger-bg) 0%, var(--danger-bg-medium) 100%); border-color: var(--danger-border); } -[data-theme="dark"] .routing-tips { background: var(--bg-secondary); border-left-color: var(--primary-color); } -[data-theme="dark"] .endpoint-path { background: var(--bg-tertiary); border-color: var(--border-color); color: var(--text-primary); } -[data-theme="dark"] .modal-content { background: var(--bg-primary); } -[data-theme="dark"] .modal-header, [data-theme="dark"] .modal-footer { background: linear-gradient(135deg, var(--bg-secondary) 0%, var(--bg-primary) 100%); } -[data-theme="dark"] .auth-instructions { background: var(--bg-secondary); border-color: var(--border-color); border-left-color: var(--primary-color); } -[data-theme="dark"] .auth-url-input { background: var(--bg-tertiary); border-color: var(--border-color); color: var(--text-primary); } -[data-theme="dark"] .model-checkbox-label { background: var(--bg-primary); border-color: var(--border-color); } -[data-theme="dark"] .not-supported-models-container { background: var(--bg-secondary); border-color: var(--border-color); } -[data-theme="dark"] .stat-card { transition: background-color 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease; } -/* 高亮说明样式 - 暗黑主题 */ -[data-theme="dark"] .highlight-note { - background: linear-gradient(135deg, var(--warning-bg) 0%, var(--warning-bg-light) 100%); - border-color: var(--warning-border); - color: var(--warning-text); -} - -[data-theme="dark"] .highlight-note i { - color: var(--warning-color); -} \ No newline at end of file diff --git a/static/components/section-providers.html b/static/components/section-providers.html deleted file mode 100644 index 93b481af5881ec244a6daf9607791a50e9644f5f..0000000000000000000000000000000000000000 --- a/static/components/section-providers.html +++ /dev/null @@ -1,46 +0,0 @@ - - -
    -

    提供商池管理

    -
    -
    - - 使用默认路径配置需添加一个空节点 -
    -
    - -
    -
    -
    - -
    -
    -

    0

    -

    活动连接

    -
    -
    -
    -
    - -
    -
    -

    0

    -

    活跃提供商

    -
    -
    -
    -
    - -
    -
    -

    0

    -

    健康提供商

    -
    -
    -
    -
    -
    - -
    -
    -
    \ No newline at end of file diff --git a/static/components/section-tutorial.css b/static/components/section-tutorial.css deleted file mode 100644 index b0c28c6d56e0ff71a029b487c6f6b1427f93b9da..0000000000000000000000000000000000000000 --- a/static/components/section-tutorial.css +++ /dev/null @@ -1,476 +0,0 @@ -/* Tutorial Section Styles */ - -/* 操作流程图样式 */ -.process-flow { - display: flex; - flex-wrap: wrap; - align-items: flex-start; - justify-content: center; - gap: 0.5rem; - padding: 1.5rem 0; -} - -.flow-step { - display: flex; - flex-direction: column; - align-items: center; - background: var(--card-bg); - border: 1px solid var(--border-color); - border-radius: 12px; - padding: 1.25rem; - width: 180px; - min-height: 200px; - transition: all 0.3s ease; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); -} - -.flow-step:hover { - transform: translateY(-4px); - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1); - border-color: var(--primary-color); -} - -.step-number { - width: 40px; - height: 40px; - background: linear-gradient(135deg, var(--primary-color), var(--primary-hover)); - color: white; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - font-size: 1.25rem; - font-weight: 700; - margin-bottom: 1rem; - box-shadow: 0 4px 12px rgba(var(--primary-rgb, 59, 130, 246), 0.3); -} - -.step-content { - text-align: center; - flex: 1; -} - -.step-content h4 { - font-size: 0.95rem; - font-weight: 600; - color: var(--text-color); - margin: 0 0 0.5rem 0; -} - -.step-content p { - font-size: 0.8rem; - color: var(--text-secondary); - margin: 0 0 0.75rem 0; - line-height: 1.4; -} - -.step-content ul { - list-style: none; - padding: 0; - margin: 0; - text-align: left; -} - -.step-content ul li { - font-size: 0.75rem; - color: var(--text-secondary); - padding: 0.25rem 0; - padding-left: 1rem; - position: relative; -} - -.step-content ul li::before { - content: "•"; - position: absolute; - left: 0; - color: var(--primary-color); - font-weight: bold; -} - -.step-content ul li code { - font-size: 0.7rem; - background: var(--code-bg); - padding: 0.1rem 0.3rem; - border-radius: 3px; -} - -.flow-arrow { - display: flex; - align-items: center; - justify-content: center; - font-size: 1.5rem; - color: var(--primary-color); - font-weight: bold; - padding: 0 0.25rem; - margin-top: 80px; -} - -/* 响应式流程图 */ -@media (max-width: 1200px) { - .process-flow { - gap: 0.75rem; - } - - .flow-step { - width: 160px; - min-height: 180px; - padding: 1rem; - } - - .flow-arrow { - font-size: 1.25rem; - margin-top: 70px; - } -} - -@media (max-width: 992px) { - .process-flow { - flex-direction: column; - align-items: center; - } - - .flow-step { - width: 100%; - max-width: 400px; - min-height: auto; - flex-direction: row; - gap: 1rem; - } - - .step-number { - margin-bottom: 0; - flex-shrink: 0; - } - - .step-content { - text-align: left; - } - - .flow-arrow { - transform: rotate(90deg); - margin-top: 0; - padding: 0.5rem 0; - } -} - -/* Tutorial Panel */ -.tutorial-panel { - background: var(--bg-primary); - padding: 1.5rem; - border-radius: var(--radius-lg); - box-shadow: var(--shadow-md); - margin-bottom: 1.5rem; - border: 1px solid var(--border-color); -} - -.tutorial-panel h3 { - font-size: 1.25rem; - font-weight: 600; - color: var(--text-primary); - margin: 0 0 1rem 0; - display: flex; - align-items: center; - gap: 0.5rem; -} - -.tutorial-panel h3 i { - color: var(--primary-color); -} - -.tutorial-content { - color: var(--text-secondary); - line-height: 1.6; -} - -.tutorial-content > p { - margin-bottom: 1rem; -} - -.tutorial-content code { - background: var(--bg-tertiary); - padding: 0.15rem 0.4rem; - border-radius: var(--radius-sm); - font-size: 0.85rem; - color: var(--primary-color); -} - -/* Config File List */ -.config-file-list { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: 1rem; - margin-top: 1rem; -} - -.config-file-item { - background: var(--bg-secondary); - padding: 1rem; - border-radius: var(--radius-md); - border: 1px solid var(--border-color); - transition: var(--transition); -} - -.config-file-item:hover { - border-color: var(--primary-30); - box-shadow: var(--shadow-sm); -} - -.file-header { - display: flex; - align-items: center; - gap: 0.5rem; - margin-bottom: 0.5rem; -} - -.file-header i { - color: var(--primary-color); -} - -.file-name { - font-weight: 600; - color: var(--text-primary); - font-family: monospace; -} - -.file-badge { - font-size: 0.65rem; - padding: 0.1rem 0.4rem; - border-radius: 9999px; - font-weight: 500; - margin-left: auto; -} - -.file-badge.required { - background: var(--danger-bg); - color: var(--danger-text); -} - -.file-badge.optional { - background: var(--info-bg); - color: var(--info-text); -} - -.file-desc { - font-size: 0.875rem; - color: var(--text-secondary); - margin: 0; -} - -/* Config Section */ -.config-section { - margin-bottom: 1.5rem; -} - -.config-section:last-child { - margin-bottom: 0; -} - -.config-section h4 { - font-size: 1rem; - font-weight: 600; - color: var(--text-primary); - margin: 0 0 0.75rem 0; - padding-bottom: 0.5rem; - border-bottom: 1px solid var(--border-color); -} - -/* Config Table */ -.config-table { - overflow-x: auto; -} - -.config-table table { - width: 100%; - border-collapse: collapse; - font-size: 0.875rem; -} - -.config-table th, -.config-table td { - padding: 0.75rem; - text-align: left; - border-bottom: 1px solid var(--border-color); -} - -.config-table th { - background: var(--bg-secondary); - font-weight: 600; - color: var(--text-primary); -} - -.config-table td { - color: var(--text-secondary); -} - -.config-table td code { - background: var(--bg-tertiary); - padding: 0.15rem 0.4rem; - border-radius: var(--radius-sm); - font-size: 0.8rem; - color: var(--primary-color); - white-space: nowrap; -} - -.config-table tr:hover td { - background: var(--bg-secondary); -} - -/* Config Example */ -.config-example { - background: var(--bg-secondary); - padding: 1rem; - border-radius: var(--radius-md); - margin-top: 1rem; - border: 1px solid var(--border-color); -} - -.config-example h4 { - font-size: 0.9rem; - font-weight: 600; - color: var(--text-primary); - margin: 0 0 0.75rem 0; -} - -.config-example pre { - background: var(--bg-tertiary); - padding: 1rem; - border-radius: var(--radius-md); - overflow-x: auto; - margin: 0; -} - -.config-example pre code { - background: none; - padding: 0; - font-size: 0.8rem; - color: var(--text-primary); - white-space: pre; -} - -.config-section pre { - background: var(--bg-tertiary); - padding: 1rem; - border-radius: var(--radius-md); - overflow-x: auto; - margin: 0.75rem 0 0 0; -} - -.config-section pre code { - background: none; - padding: 0; - font-size: 0.8rem; - color: var(--text-primary); - white-space: pre; -} - -/* Tutorial Note */ -.tutorial-note { - display: flex; - align-items: flex-start; - gap: 0.75rem; - background: var(--info-bg); - padding: 1rem; - border-radius: var(--radius-md); - margin-top: 1rem; - border: 1px solid var(--info-border); -} - -.tutorial-note i { - color: var(--info-text); - font-size: 1rem; - margin-top: 0.1rem; -} - -.tutorial-note span { - color: var(--info-text); - font-size: 0.875rem; - line-height: 1.5; -} - -/* OAuth Path List */ -.oauth-path-list { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); - gap: 1rem; - margin-top: 1rem; -} - -.oauth-path-item { - background: var(--bg-secondary); - padding: 1rem; - border-radius: var(--radius-md); - border: 1px solid var(--border-color); -} - -.path-header { - display: flex; - align-items: center; - gap: 0.5rem; - margin-bottom: 0.5rem; -} - -.path-header i { - font-size: 1.25rem; -} - -.path-header i.fa-gem { color: #4285f4; } -.path-header i.fa-rocket { color: #ea4335; } -.path-header i.fa-robot { color: #9b59b6; } -.path-header i.fa-code { color: #ff6a00; } - -.path-provider { - font-weight: 600; - color: var(--text-primary); -} - -.path-value { - display: block; - background: var(--bg-tertiary); - padding: 0.5rem 0.75rem; - border-radius: var(--radius-sm); - font-size: 0.8rem; - color: var(--text-primary); - font-family: monospace; - word-break: break-all; -} - -/* Responsive */ -@media (max-width: 768px) { - .config-file-list { - grid-template-columns: 1fr; - } - - .oauth-path-list { - grid-template-columns: 1fr; - } - - .tutorial-panel { - padding: 1rem; - } - - .config-table th, - .config-table td { - padding: 0.5rem; - font-size: 0.8rem; - } -} - -/* Dark Theme */ -[data-theme="dark"] .tutorial-panel { - background: var(--bg-primary); -} - -[data-theme="dark"] .config-file-item, -[data-theme="dark"] .config-example, -[data-theme="dark"] .oauth-path-item { - background: var(--bg-secondary); -} - -[data-theme="dark"] .config-table th { - background: var(--bg-secondary); -} - -[data-theme="dark"] .config-example pre, -[data-theme="dark"] .config-section pre { - background: var(--bg-tertiary); -} \ No newline at end of file diff --git a/static/components/section-tutorial.html b/static/components/section-tutorial.html deleted file mode 100644 index e616e933ad888f6262478573123539f2f2848154..0000000000000000000000000000000000000000 --- a/static/components/section-tutorial.html +++ /dev/null @@ -1,364 +0,0 @@ - - -
    -

    配置教程

    - - -
    -

    配置文件说明

    -
    -

    所有配置文件都存放在 configs/ 目录下。主要配置文件包括:

    - -
    -
    -
    - - config.json - 必需 -
    -

    主配置文件,包含 API Key、端口、模型提供商等核心设置 (保存配置管理后自动新建)

    -
    -
    -
    - - provider_pools.json - 必需 -
    -

    提供商池配置,用于多账号轮询和故障转移 (保存节点后自动新建)

    -
    -
    -
    - - plugins.json - 可选 -
    -

    插件配置,用于启用或禁用系统插件

    -
    -
    -
    - - pwd - 可选 -
    -

    后台登录密码文件,默认密码为 admin123

    -
    -
    -
    -
    - - -
    -

    主配置详解 (config.json)

    -
    - -
    -

    基础设置

    -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    参数类型默认值说明
    REQUIRED_API_KEYstring-访问本服务所需的 API Key
    SERVER_PORTnumber3000服务监听端口
    HOSTstring0.0.0.0服务监听地址
    MODEL_PROVIDERstring-默认模型提供商
    -
    -
    - - -
    -

    代理设置

    -
    - - - - - - - - - - - - - - - - - - - - -
    参数类型说明
    PROXY_URLstring代理地址,支持 HTTP、HTTPS、SOCKS5
    PROXY_ENABLED_PROVIDERSarray启用代理的提供商列表
    -
    -
    - - -
    -

    服务治理

    -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    参数类型默认值说明
    REQUEST_MAX_RETRIESnumber3最大重试次数
    REQUEST_BASE_DELAYnumber1000重试基础延迟(毫秒)
    CREDENTIAL_SWITCH_MAX_RETRIESnumber5坏凭证切换最大重试次数
    MAX_ERROR_COUNTnumber10提供商最大错误次数,超过后标记为不健康
    WARMUP_TARGETnumber0系统启动时自动刷新的节点数量
    REFRESH_CONCURRENCY_PER_PROVIDERnumber1提供商内刷新并发数
    -
    -
    - - -
    -

    日志设置

    -
    - - - - - - - - - - - - - - - - - - - - - - - - - -
    参数类型说明
    LOG_ENABLEDboolean启用日志
    LOG_OUTPUT_MODEstring日志输出模式 (all/console/file/none)
    PROMPT_LOG_MODEstring提示词日志模式:none(关闭)、console(控制台)、file(文件)
    -
    -
    - -
    -

    配置示例

    -
    {
    -    "REQUIRED_API_KEY": "your-api-key",
    -    "SERVER_PORT": 3000,
    -    "HOST": "0.0.0.0",
    -    "MODEL_PROVIDER": "gemini-cli-oauth,claude-kiro-oauth",
    -    "PROXY_URL": "http://127.0.0.1:7890",
    -    "PROXY_ENABLED_PROVIDERS": ["gemini-cli-oauth", "claude-kiro-oauth"],
    -    "REQUEST_MAX_RETRIES": 3,
    -    "MAX_ERROR_COUNT": 10,
    -    "WARMUP_TARGET": 5,
    -    "LOG_ENABLED": true,
    -    "LOG_OUTPUT_MODE": "all"
    -}
    -
    -
    -
    - - -
    -

    提供商池配置 (provider_pools.json)

    -
    -

    提供商池用于配置多个账号,实现负载均衡和故障转移。每个提供商类型可以配置多个账号节点。

    - -
    -

    节点配置参数

    -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    参数类型说明
    uuidstring节点唯一标识,自动生成
    namestring节点自定义名称
    oauthCredsFilePathstringOAuth 凭据文件路径
    checkHealthboolean是否启用健康检查
    checkModelstring健康检查使用的模型
    notSupportedModelsarray该节点不支持的模型列表
    disabledboolean是否禁用该节点
    -
    -
    - -
    -

    配置示例

    -
    {
    -    "gemini-cli-oauth": [
    -        {
    -            "uuid": "gemini-account-1",
    -            "name": "Gemini 账号 1",
    -            "oauthCredsFilePath": "configs/gemini/oauth_creds_1.json",
    -            "checkHealth": true,
    -            "checkModel": "gemini-3-flash-preview"
    -        }
    -    ],
    -    "claude-kiro-oauth": [
    -        {
    -            "uuid": "kiro-account-1",
    -            "name": "Kiro 账号 1",
    -            "oauthCredsFilePath": "configs/kiro/kiro-auth-token.json",
    -            "checkHealth": true
    -        }
    -    ]
    -}
    -
    -
    -
    - - -
    -

    Fallback 降级配置

    -
    -

    当某一提供商类型的所有账号都不可用时,可以自动切换到配置的备用提供商。

    - -
    -

    跨类型 Fallback 链

    -

    在 config.json 中配置 providerFallbackChain,指定每个提供商类型的备用类型:

    -
    {
    -    "providerFallbackChain": {
    -        "gemini-cli-oauth": ["gemini-antigravity"],
    -        "gemini-antigravity": ["gemini-cli-oauth"],
    -        "claude-kiro-oauth": ["claude-custom"],
    -        "claude-custom": ["claude-kiro-oauth"]
    -    }
    -}
    -
    - -
    -

    跨协议模型映射

    -

    当主提供商不可用时,可以将特定模型映射到其他协议的提供商:

    -
    {
    -    "modelFallbackMapping": {
    -        "gemini-claude-opus-4-5-thinking": {
    -            "targetProviderType": "claude-kiro-oauth",
    -            "targetModel": "claude-opus-4-5"
    -        }
    -    }
    -}
    -
    -
    -
    - - -
    -

    OAuth 授权路径

    -
    -

    各提供商的 OAuth 凭据文件默认存储位置(建议保持在 configs/ 目录下以便统一管理):

    - -
    - -
    - -
    - - 推荐通过 Web UI 控制台的"提供商池管理"页面点击"生成授权"按钮进行可视化授权,系统会自动保存凭据文件。 -
    -
    -
    -
    diff --git a/static/components/section-upload-config.css b/static/components/section-upload-config.css deleted file mode 100644 index e6f3217b8ec053cf3c25ad1b5db53c6c519c2492..0000000000000000000000000000000000000000 --- a/static/components/section-upload-config.css +++ /dev/null @@ -1,841 +0,0 @@ -/* 配置管理页面样式 */ -.upload-config-panel { - background: var(--bg-primary); - padding: 2rem; - border-radius: 0.5rem; - box-shadow: var(--shadow-md); -} - -.config-search-panel { - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 0.5rem; - padding: 1.5rem; - margin-bottom: 2rem; -} - -.search-controls { - display: grid; - grid-template-columns: 2fr 1fr auto; - gap: 1rem; - align-items: end; -} - -.search-input-group { - position: relative; - display: flex; - align-items: center; -} - -.search-input-group .form-control { - flex: 1; - padding-right: 3rem; -} - -.search-input-group .btn { - position: absolute; - right: 0.5rem; - padding: 0.5rem 0.75rem; - background: var(--primary-color); - color: white; - border: none; - border-radius: 0.375rem; - cursor: pointer; - transition: var(--transition); -} - -.search-input-group .btn:hover { - background: var(--btn-primary-hover); - transform: translateY(-1px); -} - -.config-list-container { - background: var(--bg-primary); - border: 1px solid var(--border-color); - border-radius: 0.5rem; - overflow: hidden; -} - -.config-list-header { - background: var(--bg-tertiary); - padding: 1rem 1.5rem; - border-bottom: 1px solid var(--border-color); - display: flex; - justify-content: space-between; - align-items: center; -} - -.config-list-header h3 { - margin: 0; - font-size: 1.125rem; - font-weight: 600; - color: var(--text-primary); -} - -.config-stats { - display: flex; - gap: 1rem; - align-items: center; -} - -.config-stats span { - font-size: 0.875rem; - font-weight: 500; - padding: 0.25rem 0.5rem; - border-radius: 0.25rem; -} - -.status-used { background: var(--success-bg); color: var(--success-text); } -.status-unused { background: var(--warning-bg); color: var(--warning-text); } -.status-invalid { background: var(--danger-bg); color: var(--danger-text); } - -.config-list { - overflow-y: auto; -} - -/* 无配置文件提示样式 */ -.no-configs { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 4rem 2rem; - text-align: center; - background: linear-gradient(135deg, var(--bg-secondary) 0%, var(--bg-primary) 100%); - border-radius: 0.5rem; - margin: 1rem; -} - -.no-configs p { - font-size: 1rem; - color: var(--text-secondary); - margin: 0; - padding: 1rem 2rem; - background: var(--bg-tertiary); - border: 1px dashed var(--border-color); - border-radius: 0.5rem; - display: inline-flex; - align-items: center; - gap: 0.5rem; -} - -.no-configs p::before { - content: '\f07c'; - font-family: 'Font Awesome 6 Free'; - font-weight: 400; - font-size: 1.25rem; - color: var(--text-tertiary); -} - -.config-item-manager { - padding: 0; - border-bottom: 1px solid var(--border-color); - transition: var(--transition); - cursor: pointer; - background: var(--bg-primary); -} - -.config-item-manager:hover { - background: var(--bg-secondary); -} - -.config-item-main-row { - display: flex; - padding: 1.25rem 1.5rem; - align-items: center; - gap: 1.5rem; -} - -.config-item-left { - display: flex; - align-items: center; - gap: 1rem; - flex: 1.5; - min-width: 0; -} - -.config-item-icon-wrapper { - width: 40px; - height: 40px; - border-radius: 10px; - display: flex; - align-items: center; - justify-content: center; - font-size: 1.25rem; - flex-shrink: 0; -} - -.config-item-icon-wrapper.oauth { background: rgba(59, 130, 246, 0.1); color: #3b82f6; } -.config-item-icon-wrapper.api-key { background: rgba(16, 185, 129, 0.1); color: #10b981; } -.config-item-icon-wrapper.provider-pool { background: rgba(139, 92, 246, 0.1); color: #8b5cf6; } -.config-item-icon-wrapper.system-prompt { background: rgba(245, 158, 11, 0.1); color: #f59e0b; } -.config-item-icon-wrapper.plugins { background: rgba(236, 72, 153, 0.1); color: #ec4899; } -.config-item-icon-wrapper.usage { background: rgba(59, 130, 246, 0.1); color: #3b82f6; } -.config-item-icon-wrapper.config { background: rgba(107, 114, 128, 0.1); color: #6b7280; } -.config-item-icon-wrapper.database { background: rgba(16, 185, 129, 0.1); color: #10b981; } -.config-item-icon-wrapper.other { background: rgba(107, 114, 128, 0.1); color: #6b7280; } - -.config-item-title-area { - display: flex; - flex-direction: column; - gap: 0.25rem; - min-width: 0; -} - -.config-item-name-line { - display: flex; - align-items: center; - gap: 0.5rem; - flex-wrap: wrap; -} - -.config-item-display-name { - font-size: 1.05rem; - font-weight: 600; - color: var(--text-primary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} - -.provider-type-tag { - font-size: 0.7rem; - font-weight: 600; - padding: 0.15rem 0.5rem; - border-radius: 20px; - display: inline-flex; - align-items: center; - gap: 0.25rem; - text-transform: uppercase; -} - -.tag-kiro-oauth { background: rgba(236, 72, 153, 0.1); color: #ec4899; border: 1px solid rgba(236, 72, 153, 0.2); } -.tag-gemini-oauth { background: rgba(59, 130, 246, 0.1); color: #3b82f6; border: 1px solid rgba(59, 130, 246, 0.2); } -.tag-qwen-oauth { background: rgba(16, 185, 129, 0.1); color: #10b981; border: 1px solid rgba(16, 185, 129, 0.2); } -.tag-antigravity { background: rgba(139, 92, 246, 0.1); color: #8b5cf6; border: 1px solid rgba(139, 92, 246, 0.2); } -.tag-codex-oauth { background: rgba(245, 158, 11, 0.1); color: #f59e0b; border: 1px solid rgba(245, 158, 11, 0.2); } -.tag-iflow-oauth { background: rgba(20, 184, 166, 0.1); color: #14b8a6; border: 1px solid rgba(20, 184, 166, 0.2); } - -.config-item-path-line { - font-size: 0.75rem; - color: var(--text-tertiary); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - display: flex; - align-items: center; - gap: 0.35rem; -} - -.config-item-middle { - flex: 1.2; - display: flex; - align-items: center; -} - -.config-meta-info { - display: flex; - flex-direction: column; - gap: 0.35rem; -} - -.meta-item { - font-size: 0.8rem; - color: var(--text-secondary); - display: flex; - align-items: center; - gap: 0.5rem; -} - -.meta-item i { - width: 14px; - color: var(--text-tertiary); -} - -.config-item-right { - display: flex; - align-items: center; - gap: 1.5rem; - flex: 1.3; - justify-content: flex-end; -} - -.config-status-col { - display: flex; - flex-direction: column; - align-items: flex-end; - gap: 0.5rem; - width: 100%; -} - -.config-status-indicator { - display: inline-flex; - align-items: center; - gap: 0.35rem; - padding: 0.35rem 0.75rem; - border-radius: 30px; - font-size: 0.75rem; - font-weight: 600; -} - -.config-status-indicator.used { - background: rgba(16, 185, 129, 0.1); - color: #10b981; -} - -.config-status-indicator.unused { - background: rgba(245, 158, 11, 0.1); - color: #f59e0b; -} - -.linked-nodes-tags { - display: flex; - flex-wrap: wrap; - gap: 0.35rem; - justify-content: flex-end; - margin-top: 0.25rem; - width: 100%; -} - -.node-tag { - font-size: 0.65rem; - color: #3b82f6; - background: rgba(59, 130, 246, 0.08); - padding: 0.15rem 0.45rem; - border-radius: 4px; - border: 1px solid rgba(59, 130, 246, 0.15); - white-space: nowrap; - max-width: 250px; - overflow: hidden; - text-overflow: ellipsis; - display: inline-flex; - align-items: center; - gap: 0.25rem; - font-weight: 500; -} - -.node-tag.status-healthy { - color: #10b981; - background: rgba(16, 185, 129, 0.08); - border-color: rgba(16, 185, 129, 0.15); -} - -.node-tag.status-healthy i { - color: #10b981; -} - -.node-tag.status-unhealthy { - color: #ef4444; - background: rgba(239, 68, 68, 0.08); - border-color: rgba(239, 68, 68, 0.15); -} - -.node-tag.status-unhealthy i { - color: #ef4444; -} - -.node-tag.status-disabled { - color: #6b7280; - background: rgba(107, 114, 128, 0.08); - border-color: rgba(107, 114, 128, 0.15); -} - -.node-tag.status-disabled i { - color: #6b7280; -} - -.node-tag i { - font-size: 0.6rem; - color: #3b82f6; -} - -.btn-quick-link-main { - background: var(--primary-color); - color: white; - border: none; - padding: 0.35rem 0.75rem; - border-radius: 6px; - font-size: 0.75rem; - font-weight: 500; - cursor: pointer; - transition: all 0.2s ease; - display: flex; - align-items: center; - gap: 0.35rem; -} - -.btn-quick-link-main:hover { - background: var(--btn-primary-hover); - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2); -} - -.config-item-chevron { - color: var(--text-tertiary); - transition: transform 0.3s ease; -} - -.config-item-manager.expanded .config-item-chevron { - transform: rotate(90deg); -} - -.config-item-details { - margin: 0; - padding: 1.5rem; - border-top: 1px solid var(--border-color); - background: var(--bg-tertiary); - display: none; -} - -.config-item-manager.expanded .config-item-details { - display: block; -} - -.config-item-actions { - display: flex; - gap: 0.75rem; - margin-top: 1.25rem; - padding-top: 1.25rem; - border-top: 1px dashed var(--border-color); -} - -@media (max-width: 992px) { - .config-item-main-row { - flex-direction: column; - align-items: flex-start; - gap: 1rem; - } - - .config-item-middle { - width: 100%; - } - - .config-meta-info { - flex-direction: row; - gap: 1.5rem; - } - - .config-item-right { - width: 100%; - justify-content: space-between; - } - - .config-status-col { - flex-direction: row; - align-items: center; - } -} - -.config-details-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 1rem; - margin-bottom: 1rem; -} - -.config-detail-item { - display: flex; - flex-direction: column; - gap: 0.25rem; -} - -.config-detail-label { - font-size: 0.75rem; - color: var(--text-secondary); - font-weight: 500; - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.config-detail-item.path-item { - grid-column: 1 / -1; -} - -.config-detail-item.path-item .config-detail-value { - cursor: pointer; - transition: var(--transition); - border: 1px solid transparent; - padding: 2px 4px; - margin-left: -4px; - border-radius: 4px; -} - -.config-detail-item.path-item .config-detail-value:hover { - color: var(--primary-color); -} - -.config-detail-value { - font-size: 0.875rem; - color: var(--text-primary); - word-break: break-all; - overflow-wrap: break-word; -} - -.config-detail-value.status-text-used { color: #10b981; font-weight: 600; } -.config-detail-value.status-text-unused { color: #f59e0b; font-weight: 600; } - -.config-item-actions { - display: flex; - gap: 0.5rem; - margin-top: 0.75rem; - padding-top: 0.75rem; - border-top: 1px solid var(--border-color); -} - -.btn-view { background: var(--primary-color); color: white; } -.btn-view:hover { background: var(--btn-primary-hover); } -.btn-download { background: #10b981; color: white; } -.btn-download:hover { background: #059669; } -.btn-delete-small { background: var(--danger-color); color: white; } - -.config-item-manager.expanded { - background: var(--bg-secondary); -} - -/* 配置查看模态框样式 */ -.config-view-modal { - position: fixed; - top: 0; left: 0; width: 100%; height: 100%; - background: var(--overlay-bg); - backdrop-filter: blur(4px); - display: flex; justify-content: center; align-items: center; - z-index: 1000; opacity: 0; visibility: hidden; - transition: all 0.3s ease; -} - -.config-view-modal.show { opacity: 1; visibility: visible; } - -.config-modal-content { - background: var(--bg-primary); - border-radius: 0.5rem; - width: 90%; max-width: 800px; max-height: 80vh; - overflow: hidden; - box-shadow: 0 20px 60px var(--neutral-shadow-30); - display: flex; flex-direction: column; - animation: modalSlideIn 0.3s ease; -} - -.config-modal-header { - padding: 1.5rem; - border-bottom: 1px solid var(--border-color); - display: flex; justify-content: space-between; align-items: center; - background: var(--bg-secondary); -} - -.config-modal-body { - padding: 1.5rem; flex: 1; overflow-y: auto; -} - -.config-file-info { - display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 1rem; margin-bottom: 1.5rem; padding: 1rem; - background: var(--bg-secondary); border-radius: 0.5rem; -} - -.config-content-display { - background: var(--code-bg); color: var(--code-text); - padding: 1rem; border-radius: 0.5rem; - font-family: 'Courier New', monospace; font-size: 0.875rem; - line-height: 1.5; max-height: 400px; overflow-y: auto; - white-space: pre-wrap; word-wrap: break-word; -} - -.config-modal-footer { - padding: 1.5rem; border-top: 1px solid var(--border-color); - display: flex; justify-content: flex-end; gap: 1rem; - background: var(--bg-secondary); -} - -/* 关联信息显示样式 */ -.config-usage-info { - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 0.5rem; - margin: 1rem 0; - padding: 1rem; - border-left: 4px solid var(--primary-color); -} - -.usage-info-header { - display: flex; align-items: center; gap: 0.5rem; - margin-bottom: 1rem; padding-bottom: 0.75rem; - border-bottom: 1px solid var(--border-color); -} - -.usage-details-list { - display: flex; flex-direction: column; gap: 0.75rem; -} - -.usage-detail-item { - display: flex; - align-items: flex-start; - gap: 1rem; - padding: 1rem; - background: var(--bg-primary); - border: 1px solid var(--border-color); - border-radius: 0.5rem; - transition: var(--transition); -} - -.usage-detail-item:hover { - border-color: var(--primary-color); - box-shadow: var(--shadow-sm); -} - -.usage-detail-item i { - margin-top: 0.25rem; - color: var(--primary-color); - font-size: 1.1rem; -} - -.usage-detail-content { - flex: 1; - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.usage-detail-top { - display: flex; - align-items: center; - gap: 1rem; - flex-wrap: wrap; -} - -.node-status-tag { - font-size: 0.65rem; - font-weight: 600; - padding: 0.1rem 0.4rem; - border-radius: 4px; - text-transform: uppercase; -} - -.node-status-tag.healthy { - background: rgba(16, 185, 129, 0.1); - color: #10b981; - border: 1px solid rgba(16, 185, 129, 0.2); -} - -.node-status-tag.unhealthy { - background: rgba(239, 68, 68, 0.1); - color: #ef4444; - border: 1px solid rgba(239, 68, 68, 0.2); -} - -.node-status-tag.disabled { - background: rgba(107, 114, 128, 0.1); - color: #6b7280; - border: 1px solid rgba(107, 114, 128, 0.2); -} - -.usage-detail-subtitle { - font-size: 0.75rem; - color: var(--text-tertiary); - font-family: 'Courier New', monospace; - background: var(--bg-secondary); - padding: 0.25rem 0.5rem; - border-radius: 4px; - display: inline-block; - word-break: break-all; -} - -.usage-detail-type { - font-size: 0.7rem; - font-weight: 700; - background: var(--primary-color); - color: white; - padding: 0.15rem 0.5rem; - border-radius: 4px; - text-transform: uppercase; - letter-spacing: 0.02em; -} - -.usage-detail-location { - font-size: 0.9rem; - color: var(--text-primary); - font-weight: 500; - word-break: break-all; -} - -/* 删除确认模态框样式 */ -.delete-confirm-modal { - position: fixed; top: 0; left: 0; width: 100%; height: 100%; - background: var(--overlay-bg); backdrop-filter: blur(4px); - display: flex; justify-content: center; align-items: center; - z-index: 1000; opacity: 0; visibility: hidden; - transition: all 0.3s ease; -} - -.delete-confirm-modal.show { opacity: 1; visibility: visible; } - -.delete-modal-content { - background: var(--bg-primary); border-radius: 0.75rem; - width: 90%; max-width: 600px; max-height: 85vh; - overflow: hidden; box-shadow: 0 25px 80px var(--neutral-shadow-40); - display: flex; flex-direction: column; - animation: modalSlideIn 0.3s ease; border: 2px solid transparent; -} - -.delete-confirm-modal.used .delete-modal-content { border-color: var(--danger-color); box-shadow: 0 25px 80px var(--danger-30); } -.delete-confirm-modal.unused .delete-modal-content { border-color: var(--warning-color); box-shadow: 0 25px 80px var(--warning-20); } - -.delete-modal-header { - padding: 1.5rem 2rem; border-bottom: 1px solid var(--border-color); - display: flex; justify-content: space-between; align-items: center; - background: var(--bg-secondary); -} - -.delete-modal-body { - padding: 2rem; flex: 1; overflow-y: auto; max-height: calc(85vh - 160px); -} - -.delete-warning { - display: flex; align-items: flex-start; gap: 1rem; - padding: 1.5rem; border-radius: 0.5rem; margin-bottom: 1.5rem; border: 2px solid; -} - -.delete-warning.warning-used { background: linear-gradient(135deg, var(--danger-bg-alt) 0%, var(--bg-primary) 100%); border-color: var(--danger-border-light); color: var(--danger-color); } -.delete-warning.warning-unused { background: linear-gradient(135deg, var(--warning-bg-alt) 0%, var(--bg-primary) 100%); border-color: var(--warning-border-light); color: var(--warning-text-dark); } - -.config-info { - background: var(--bg-secondary); border: 1px solid var(--border-color); - border-radius: 0.5rem; padding: 1.5rem; margin-bottom: 1.5rem; -} - -.config-info-item { - display: flex; justify-content: space-between; align-items: center; - padding: 0.5rem 0; border-bottom: 1px solid var(--border-color); -} - -.usage-alert { - display: flex; align-items: flex-start; gap: 1rem; padding: 1.5rem; - background: linear-gradient(135deg, var(--danger-bg) 0%, var(--danger-bg-alt) 100%); - border: 1px solid var(--danger-border-light); border-radius: 0.5rem; margin-top: 1rem; -} - -.delete-modal-footer { - padding: 1.5rem 2rem; border-top: 1px solid var(--border-color); - display: flex; justify-content: flex-end; gap: 1rem; background: var(--bg-secondary); -} - -.btn-confirm-delete { position: relative; overflow: hidden; } -.delete-confirm-modal.used .btn-confirm-delete { background: linear-gradient(135deg, var(--warning-color) 0%, var(--warning-text-dark) 100%); color: var(--white); box-shadow: 0 4px 15px var(--warning-40); animation: pulseDanger 2s infinite; } - -@keyframes pulseDanger { - 0%, 100% { box-shadow: 0 4px 15px var(--danger-40); } - 50% { box-shadow: 0 4px 15px var(--danger-70); } -} - -/* 响应式设计 */ -@media (max-width: 768px) { - .search-controls { grid-template-columns: 1fr; gap: 1rem; } - .config-list-header { flex-direction: column; align-items: flex-start; gap: 0.5rem; } - .config-item-header { flex-direction: column; align-items: flex-start; gap: 0.5rem; } - .config-item-path { max-width: 100%; margin: 0; } - .config-modal-content { width: 95%; max-height: 90vh; } - .config-file-info { grid-template-columns: 1fr; } - .delete-modal-content { width: 95%; max-height: 90vh; } - .config-info-item { flex-direction: column; align-items: flex-start; gap: 0.25rem; } - .delete-modal-footer { flex-direction: column; } -} - -/* 删除未绑定按钮样式 */ -.btn-delete-unbound { - background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); - color: white; - border: none; - border-radius: 6px; - padding: 6px 12px; - font-size: 12px; - cursor: pointer; - margin-left: 8px; - display: inline-flex; - align-items: center; - gap: 6px; - transition: all 0.2s ease; - box-shadow: 0 2px 8px var(--danger-30); - font-weight: 500; -} - -.btn-delete-unbound:hover { - transform: translateY(-1px); - box-shadow: var(--shadow-md); - background: linear-gradient(135deg, #dc2626 0%, #ef4444 100%); -} - -.btn-delete-unbound:disabled { - opacity: 0.6; - cursor: not-allowed; - transform: none; -} - -/* 配置列表头部的刷新按钮样式 */ -.btn-refresh { - background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%); - color: white; - border: none; - border-radius: 6px; - padding: 6px 12px; - font-size: 12px; - cursor: pointer; - margin-left: 8px; - display: inline-flex; - align-items: center; - gap: 6px; - transition: all 0.2s ease; - box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3); - font-weight: 500; -} - -.btn-refresh:hover { - transform: translateY(-1px); - box-shadow: var(--shadow-md); - background: linear-gradient(135deg, #2563eb 0%, #3b82f6 100%); -} - -.btn-refresh:disabled { - opacity: 0.6; - cursor: not-allowed; - transform: none; -} - -/* 配置列表头部的打包下载按钮样式 */ -.btn-download { - background: linear-gradient(135deg, #10b981 0%, #059669 100%); - color: white; - border: none; - border-radius: 6px; - padding: 6px 12px; - font-size: 12px; - cursor: pointer; - margin-left: 8px; - display: inline-flex; - align-items: center; - gap: 6px; - transition: all 0.2s ease; - box-shadow: 0 2px 8px rgba(16, 185, 129, 0.3); - font-weight: 500; -} - -.btn-download:hover { - transform: translateY(-1px); - box-shadow: var(--shadow-md); - background: linear-gradient(135deg, #059669 0%, #10b981 100%); -} - -.btn-download:disabled { - opacity: 0.6; - cursor: not-allowed; - transform: none; -} - -/* 暗黑主题适配 */ -[data-theme="dark"] .status-used { background: var(--success-bg); color: var(--success-text); } -[data-theme="dark"] .status-unused { background: var(--warning-bg); color: var(--warning-text); } -[data-theme="dark"] .status-invalid { background: var(--danger-bg); color: var(--danger-text); } -[data-theme="dark"] .config-modal-content { background: var(--bg-primary); } -[data-theme="dark"] .config-modal-header, [data-theme="dark"] .config-modal-footer { background: linear-gradient(135deg, var(--bg-secondary) 0%, var(--bg-primary) 100%); } -[data-theme="dark"] .delete-modal-content { background: var(--bg-primary); } -[data-theme="dark"] .delete-confirm-modal.used .delete-modal-header { background: linear-gradient(135deg, var(--danger-bg) 0%, var(--bg-primary) 100%); } -[data-theme="dark"] .delete-confirm-modal.unused .delete-modal-header { background: linear-gradient(135deg, var(--warning-bg) 0%, var(--bg-primary) 100%); } -[data-theme="dark"] .config-info { background: var(--bg-secondary); border-color: var(--border-color); } diff --git a/static/components/section-upload-config.html b/static/components/section-upload-config.html deleted file mode 100644 index 95a34b2723935b15b8003f70ad9a4b38e18c757c..0000000000000000000000000000000000000000 --- a/static/components/section-upload-config.html +++ /dev/null @@ -1,63 +0,0 @@ - - -
    -

    凭据文件管理

    -
    - -
    -
    -
    - -
    - - -
    -
    -
    - - -
    -
    - - -
    -
    -
    - - -
    -
    -

    配置文件列表

    -
    - 共 0 个配置文件 - 已关联: 0 - 未关联: 0 - - - - -
    -
    -
    - -
    -
    -
    -
    \ No newline at end of file diff --git a/static/components/section-usage.css b/static/components/section-usage.css deleted file mode 100644 index 8ae272f9cd2ecc7f79e86201162619cf0952082b..0000000000000000000000000000000000000000 --- a/static/components/section-usage.css +++ /dev/null @@ -1,480 +0,0 @@ -/* 用量查询页面样式 */ -.usage-panel { - background: var(--bg-primary); - border-radius: 0.5rem; - box-shadow: var(--shadow-md); - padding: 1.5rem; -} - -.usage-controls { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 1.5rem; - padding-bottom: 1rem; - border-bottom: 1px solid var(--border-color); -} - -.usage-last-update { - font-size: 0.875rem; - color: var(--text-secondary); - margin-left: auto; -} - -.server-time-display { - font-size: 0.875rem; - color: var(--text-secondary); - margin-left: 1.5rem; - padding-left: 1.5rem; - border-left: 1px solid var(--border-color); -} - -.server-time-display i { - color: var(--primary-color); - margin-right: 0.25rem; -} - -.usage-info-banner { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.75rem 1rem; - background: var(--bg-secondary); - border: 1px solid var(--border-color); - border-radius: 0.5rem; - margin-bottom: 1.5rem; - font-size: 0.875rem; - color: var(--text-secondary); -} - -.usage-info-banner i { - color: var(--primary-color); -} - -.supported-providers-tags { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; -} - -.loading-inline { - display: inline-flex; - align-items: center; - gap: 0.25rem; - color: var(--text-tertiary); -} - -.usage-loading, .usage-error, .usage-empty { - text-align: center; - padding: 3rem; - color: var(--text-secondary); -} - -.usage-error { - color: var(--danger-color); - background: var(--danger-bg-light); - border-radius: 0.5rem; - border: 1px solid var(--danger-bg); -} - -/* 提供商分组样式 */ -.usage-provider-group { - background: var(--bg-primary); - border: 1px solid var(--border-color); - border-radius: 0.5rem; - margin-bottom: 1rem; - overflow: hidden; -} - -.usage-group-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 0.75rem 1rem; - background: var(--bg-secondary); - user-select: none; - transition: var(--transition); -} - -.usage-group-header:hover { - background: var(--bg-tertiary); -} - -.usage-group-title { - display: flex; - align-items: center; - gap: 0.75rem; - flex: 1; - cursor: pointer; -} - -.usage-group-actions { - display: flex; - align-items: center; - gap: 0.5rem; - margin-left: 1rem; -} - -.btn-toggle-cards { - display: inline-flex; - align-items: center; - justify-content: center; - width: 32px; height: 32px; padding: 0; - background: var(--bg-primary); - color: var(--text-secondary); - border: 1px solid var(--border-color); - border-radius: 0.375rem; - cursor: pointer; - font-size: 0.875rem; - transition: var(--transition); -} - -.btn-toggle-cards:hover { - background: var(--primary-color); - color: white; - border-color: var(--primary-color); - transform: translateY(-1px); - box-shadow: var(--shadow-sm); -} - -.usage-group-title .toggle-icon { - font-size: 0.75rem; - color: var(--text-secondary); - transition: transform 0.3s ease; - width: 1rem; -} - -.usage-provider-group:not(.collapsed) .toggle-icon { - transform: rotate(90deg); -} - -.usage-group-title .provider-icon { - font-size: 1.25rem; - color: var(--primary-color); -} - -.usage-group-title .provider-name { - font-weight: 600; - font-size: 1rem; - color: var(--text-primary); -} - -.usage-group-title .instance-count { - font-size: 0.875rem; - color: var(--text-secondary); - background: var(--bg-tertiary); - padding: 0.25rem 0.5rem; - border-radius: 9999px; -} - -.usage-group-title .success-count { - font-size: 0.75rem; - color: var(--text-secondary); - margin-left: auto; -} - -.usage-group-title .success-count.all-success { - color: var(--success-color); -} - -.usage-group-content { - padding: 1rem; - display: block; -} - -.usage-provider-group.collapsed .usage-group-content { - display: none; -} - -/* 用量卡片网格 */ -.usage-cards-grid { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 1rem; -} - -.stat-success { color: var(--success-color); } -.stat-error { color: var(--danger-color); } -.stat-total { color: var(--text-secondary); } - -/* 实例卡片 */ -.usage-instance-card { - background: var(--bg-primary); - border: 1px solid var(--border-color); - border-radius: 0.5rem; - overflow: hidden; - transition: var(--transition); - display: flex; - flex-direction: column; -} - -.usage-instance-card:hover { - box-shadow: var(--shadow-md); - transform: translateY(-2px); -} - -.usage-instance-card.error { border-color: var(--danger-border); } - -.usage-instance-header { - padding: 0.75rem 1rem; - border-bottom: 1px solid var(--border-color); - background: linear-gradient(to right, var(--bg-tertiary), var(--bg-secondary)); -} - -.instance-header-top { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 0.375rem; -} - -.instance-provider-type { - display: flex; - align-items: center; - gap: 0.375rem; - font-size: 0.7rem; - color: var(--primary-color); - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.instance-name-text { - font-size: 0.875rem; - font-weight: 600; - color: var(--text-primary); - display: block; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.instance-user-info { - display: flex; - align-items: center; - gap: 0.5rem; - flex-wrap: wrap; - padding-top: 0.375rem; - border-top: 1px dashed var(--border-color); - margin-top: 0.375rem; -} - -.user-email { - font-size: 0.7rem; color: var(--text-secondary); - display: flex; align-items: center; gap: 0.25rem; - max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; -} - -.user-subscription { - font-size: 0.65rem; padding: 0.125rem 0.375rem; - background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%); - color: white; border-radius: 9999px; font-weight: 500; -} - -.instance-status-badges { - display: flex; - align-items: center; - gap: 0.5rem; -} - -.btn-download-config { - display: inline-flex; - align-items: center; - justify-content: center; - width: 24px; - height: 24px; - padding: 0; - background: transparent; - color: var(--text-secondary); - border: 1px solid var(--border-color); - border-radius: 4px; - cursor: pointer; - font-size: 0.75rem; - transition: var(--transition); -} - -.btn-download-config:hover { - background: var(--primary-color); - color: white; - border-color: var(--primary-color); - transform: translateY(-1px); - box-shadow: var(--shadow-sm); -} - -.badge { - font-size: 0.75rem; padding: 0.125rem 0.5rem; border-radius: 9999px; font-weight: 500; -} - -.badge-healthy { background: var(--success-bg); color: var(--success-text); } -.badge-unhealthy { background: var(--danger-bg); color: var(--danger-text); } -.badge-disabled { background: var(--bg-tertiary); color: var(--text-secondary); } - -.usage-instance-content { - padding: 0.75rem; flex: 1; -} - -.usage-error-message { - display: flex; align-items: center; gap: 0.5rem; color: var(--danger-color); - padding: 0.75rem; background: var(--danger-bg-light); border-radius: 0.375rem; font-size: 0.75rem; -} - -.usage-details { - display: flex; flex-direction: column; gap: 0.75rem; -} - -.usage-section { - background: var(--bg-secondary); padding: 0.75rem; border-radius: 0.375rem; border: 1px solid var(--border-color); -} - -.usage-section h4 { - font-size: 0.75rem; font-weight: 600; color: var(--text-secondary); - margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.375rem; - text-transform: uppercase; letter-spacing: 0.05em; -} - -.usage-section .info-grid { - display: flex; flex-direction: column; gap: 0.375rem; -} - -.usage-section .info-item { - display: flex; justify-content: space-between; align-items: center; - padding-bottom: 0.375rem; border-bottom: 1px solid var(--border-color); -} - -.usage-section .label { font-size: 0.75rem; color: var(--text-secondary); } -.usage-section .value { font-size: 0.75rem; font-weight: 600; color: var(--text-primary); text-align: right; max-width: 60%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } - -.total-usage { background: linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 100%); border: 1px solid var(--border-color); } -.total-usage-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem; } -.total-label { font-size: 0.8rem; font-weight: 600; color: var(--text-primary); display: flex; align-items: center; gap: 0.375rem; } -.total-value { font-size: 0.75rem; font-weight: 600; color: var(--text-secondary); font-family: monospace; } - -.total-footer { - display: flex; - justify-content: space-between; - align-items: center; - margin-top: 0.5rem; -} - -.total-percent { - font-size: 0.75rem; - font-weight: 600; - color: var(--text-secondary); -} - -.total-reset-info { - font-size: 0.65rem; - color: var(--text-tertiary); - font-style: italic; - display: flex; - align-items: center; - gap: 0.25rem; -} - -.reset-info-compact { background: var(--bg-secondary); padding: 0.5rem 0.75rem; border-radius: 0.375rem; border: 1px solid var(--border-color); } -.reset-info-row { display: flex; justify-content: space-between; align-items: center; padding: 0.25rem 0; } -.reset-info-row:first-child { border-bottom: 1px solid var(--border-color); padding-bottom: 0.375rem; margin-bottom: 0.25rem; } -.reset-label { font-size: 0.7rem; color: var(--text-secondary); display: flex; align-items: center; gap: 0.25rem; } -.reset-value { font-size: 0.7rem; font-weight: 600; color: var(--text-primary); } - -.usage-breakdown-compact { background: var(--bg-secondary); padding: 0.5rem; border-radius: 0.375rem; border: 1px solid var(--border-color); } -.breakdown-item-compact { padding: 0.5rem; background: var(--bg-primary); border-radius: 0.25rem; margin-bottom: 0.5rem; border: 1px solid var(--border-color); } - -.breakdown-header-compact { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.375rem; font-size: 0.7rem; } -.breakdown-header-compact .breakdown-usage { color: var(--text-secondary); font-family: monospace; } - -.progress-bar-small { height: 0.25rem; background: var(--bg-tertiary); border-radius: 9999px; overflow: hidden; margin-bottom: 0.375rem; } -.progress-bar-small .progress-fill { height: 100%; border-radius: 9999px; transition: width 0.3s ease; } -.progress-bar-small.normal .progress-fill { background: var(--success-color); } -.progress-bar-small.warning .progress-fill { background: var(--warning-color); } -.progress-bar-small.danger .progress-fill { background: var(--danger-color); } - -.extra-usage-info { display: flex; flex-wrap: wrap; align-items: center; gap: 0.375rem; font-size: 0.6rem; padding: 0.375rem; border-radius: 0.25rem; margin-top: 0.375rem; } -.extra-usage-info.free-trial { background: var(--info-bg-lighter); color: var(--info-text); border: 1px solid var(--info-bg); } -.extra-usage-info.bonus { background: var(--warning-bg-alt); color: var(--warning-text); border: 1px solid var(--warning-bg); } - -.usage-card-collapsed-summary { - display: flex; flex-direction: column; padding: 0.5rem 0.75rem; cursor: pointer; user-select: none; - transition: var(--transition); background: linear-gradient(to right, var(--bg-tertiary), var(--bg-secondary)); - border-bottom: 1px solid var(--border-color); gap: 0.375rem; -} - -.collapsed-summary-row { display: flex; align-items: center; gap: 0.5rem; } -.collapsed-name { font-size: 0.8rem; font-weight: 600; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; min-width: 0; } -.collapsed-progress-bar { width: 80px; height: 0.375rem; background: var(--bg-tertiary); border-radius: 9999px; overflow: hidden; flex-shrink: 0; } -.collapsed-progress-bar .progress-fill { - height: 100%; - border-radius: 9999px; - transition: width 0.3s ease; -} - -.collapsed-progress-bar.normal .progress-fill { background: var(--success-color); } -.collapsed-progress-bar.warning .progress-fill { background: var(--warning-color); } -.collapsed-progress-bar.danger .progress-fill { background: var(--danger-color); } -.collapsed-percent { font-size: 0.7rem; font-weight: 600; color: var(--text-secondary); min-width: 36px; } -.collapsed-usage-text { font-size: 0.65rem; color: var(--text-tertiary); width: 100%; margin-top: 0.125rem; text-align: center; } - -.usage-toggle-icon { font-size: 0.6rem; color: var(--text-secondary); transition: transform 0.3s ease; flex-shrink: 0; } -.usage-instance-card:not(.collapsed) .usage-toggle-icon { transform: rotate(90deg); } - -.usage-instance-card.collapsed .usage-card-expanded-content { display: none; } -.usage-instance-card.collapsed .usage-card-collapsed-summary { border-bottom: none; } - -/* 分页控件样式 */ -.pagination-container { - display: flex; align-items: center; justify-content: space-between; - padding: 12px 16px; background: var(--bg-tertiary); border-radius: 8px; - margin: 12px 0; flex-wrap: wrap; gap: 12px; -} - -.page-btn { - min-width: 36px; height: 36px; padding: 0 10px; border: 1px solid var(--border-color); - background: var(--bg-primary); color: var(--text-primary); border-radius: 6px; - cursor: pointer; font-size: 0.875rem; font-weight: 500; transition: var(--transition); - display: inline-flex; align-items: center; justify-content: center; -} - -.page-btn:hover:not(:disabled), .page-btn.active { background: var(--primary-color); color: white; border-color: var(--primary-color); } - -.page-jump-input { - width: 60px; height: 36px; padding: 0 8px; border: 1px solid var(--border-color); - border-radius: 6px; text-align: center; font-size: 0.875rem; - background: var(--bg-primary); color: var(--text-primary); -} - -/* 响应式调整 */ -@media (max-width: 1400px) { .usage-cards-grid { grid-template-columns: repeat(3, 1fr); } } -@media (max-width: 1024px) { .usage-cards-grid { grid-template-columns: repeat(2, 1fr); } } -@media (max-width: 768px) { - .usage-cards-grid { grid-template-columns: 1fr; } - .pagination-container { flex-direction: column; align-items: stretch; gap: 10px; } - .usage-instance-header { padding: 0.5rem 0.75rem; } - .usage-instance-content { padding: 0.5rem; } -} - -/* 暗黑主题适配 */ -[data-theme="dark"] .usage-error-message { background: var(--danger-bg); color: var(--danger-text); } -[data-theme="dark"] .usage-section { background: var(--bg-secondary); border-color: var(--border-color); } -[data-theme="dark"] .total-usage { background: linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 100%); border-color: var(--primary-color); } -[data-theme="dark"] .reset-info-compact { background: var(--bg-secondary); border-color: var(--border-color); } -[data-theme="dark"] .usage-breakdown-compact { background: var(--bg-secondary); border-color: var(--border-color); } -[data-theme="dark"] .extra-usage-info.free-trial { background: var(--info-bg); color: var(--info-text); border-color: var(--info-border); } -[data-theme="dark"] .extra-usage-info.bonus { background: var(--warning-bg); color: var(--warning-text); border-color: var(--warning-border); } -[data-theme="dark"] .pagination-container { background: var(--bg-tertiary); } -[data-theme="dark"] .page-btn { background: var(--bg-primary); border-color: var(--border-color); color: var(--text-primary); } -[data-theme="dark"] .page-jump-input { background: var(--bg-primary); border-color: var(--border-color); color: var(--text-primary); } - -.codex-reset-info { - font-size: 0.65rem; - color: var(--text-tertiary); - margin-top: 0.25rem; - display: flex; - align-items: center; - gap: 0.25rem; -} - -.codex-secondary-usage { - margin-top: 0.75rem; - padding-top: 0.75rem; - border-top: 1px dashed var(--border-color); -} diff --git a/static/components/section-usage.html b/static/components/section-usage.html deleted file mode 100644 index 63b7843b1d1e3ba0a0324d5b1ffea5e52771fa96..0000000000000000000000000000000000000000 --- a/static/components/section-usage.html +++ /dev/null @@ -1,41 +0,0 @@ - - -
    -

    用量查询

    -
    -
    - - 上次更新: -- - - 服务端时间: -- - -
    - -
    - - 支持用量查询的提供商: - - - -
    - - - - - -
    - -
    - -

    点击"刷新用量"按钮获取授权文件用量信息

    -
    -
    -
    -
    \ No newline at end of file diff --git a/static/components/sidebar.css b/static/components/sidebar.css deleted file mode 100644 index c622ce15b4aa2e2dcd6eb5862e02bbb5c1a66c6d..0000000000000000000000000000000000000000 --- a/static/components/sidebar.css +++ /dev/null @@ -1,67 +0,0 @@ -/* 侧边栏 */ -.sidebar { - width: 260px; - background: var(--bg-glass); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); - border: 1px solid var(--border-color); - border-radius: var(--radius-xl); - padding: 1rem; - display: flex; - flex-direction: column; - height: fit-content; - position: sticky; - top: 5rem; -} - -.sidebar-nav { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.nav-item { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.75rem 1rem; - color: var(--text-secondary); - text-decoration: none; - transition: var(--transition); - font-weight: 500; - cursor: pointer; - border-radius: var(--radius-lg); - user-select: none; -} - -.nav-item:hover { - background: var(--bg-tertiary); - color: var(--text-primary); -} - -.nav-item.active { - background: var(--primary-10); - color: var(--primary-color); - font-weight: 600; -} - -.nav-item i { - width: 20px; - text-align: center; - font-size: 1.1em; -} - -/* 响应式调整 */ -@media (max-width: 768px) { - .sidebar { - width: 100%; - border-right: none; - border-bottom: 1px solid var(--border-color); - } - - .sidebar-nav { - flex-direction: row; - overflow-x: auto; - padding: 0 1rem; - } -} diff --git a/static/components/sidebar.html b/static/components/sidebar.html deleted file mode 100644 index 7d5c9d37aa3700c24e29bfc73eae977606ac7659..0000000000000000000000000000000000000000 --- a/static/components/sidebar.html +++ /dev/null @@ -1,33 +0,0 @@ - - - \ No newline at end of file diff --git a/static/index.html b/static/index.html deleted file mode 100644 index 8e28fc8e684c6cda0b9cd980761c2cab35f23426..0000000000000000000000000000000000000000 --- a/static/index.html +++ /dev/null @@ -1,133 +0,0 @@ - - - - - - - - AIClient2API - 管理控制台 - - - - - -
    - - - -
    - - - - -
    - -
    -
    -
    - - -
    - - - - - - - - - - - diff --git a/static/login.html b/static/login.html deleted file mode 100644 index 84ecb10001f042cd57173acb3c1f69b8c2cbd008..0000000000000000000000000000000000000000 --- a/static/login.html +++ /dev/null @@ -1,350 +0,0 @@ - - - - - - 登录 - AIClient2API - - - - - - -
    - - - - - \ No newline at end of file diff --git a/static/potluck-user.html b/static/potluck-user.html deleted file mode 100644 index a2aa147063c96d61f58b6a2ed05b55cbc9f7dce1..0000000000000000000000000000000000000000 --- a/static/potluck-user.html +++ /dev/null @@ -1,746 +0,0 @@ - - - - - - - API 大锅饭 - 我的用量 - - - - - - - - - - - - - - - - - diff --git a/static/potluck.html b/static/potluck.html deleted file mode 100644 index 62096bba954274f0d8bfc3b50eaff9b622de2b09..0000000000000000000000000000000000000000 --- a/static/potluck.html +++ /dev/null @@ -1,1197 +0,0 @@ - - - - - - - API 大锅饭 - Key 管理 - - - - - - - - - -
    - -
    -
    -
    总 Key 数
    -
    0
    -
    -
    -
    已启用
    -
    0
    -
    -
    -
    今日总调用
    -
    0
    -
    -
    -
    累计调用
    -
    0
    -
    -
    - - - - - - -
    -
    -

    Key 列表

    -
    -
    - - -
    -
    - -
    -
    -
    -
    -
    -
    - - - - - - - - - - - - - - - - - - - diff --git a/tls-sidecar/.gitignore b/tls-sidecar/.gitignore deleted file mode 100644 index 87bc36f70b989910170f332407bbb5398cf12f27..0000000000000000000000000000000000000000 --- a/tls-sidecar/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -tls-sidecar -tls-sidecar.exe diff --git a/tls-sidecar/go.mod b/tls-sidecar/go.mod deleted file mode 100644 index 3e8b6aa0412f23d6e410865a065ea3ee7a531fad..0000000000000000000000000000000000000000 --- a/tls-sidecar/go.mod +++ /dev/null @@ -1,17 +0,0 @@ -module tls-sidecar - -go 1.22 - -require ( - github.com/refraction-networking/utls v1.6.7 - golang.org/x/net v0.33.0 -) - -require ( - github.com/andybalholm/brotli v1.1.1 // indirect - github.com/cloudflare/circl v1.5.0 // indirect - github.com/klauspost/compress v1.17.11 // indirect - golang.org/x/crypto v0.31.0 // indirect - golang.org/x/sys v0.28.0 // indirect - golang.org/x/text v0.21.0 // indirect -) diff --git a/tls-sidecar/main.go b/tls-sidecar/main.go deleted file mode 100644 index 13415c13b302ca6833ab0437150b6039c4e3005e..0000000000000000000000000000000000000000 --- a/tls-sidecar/main.go +++ /dev/null @@ -1,424 +0,0 @@ -package main - -import ( - "context" - "encoding/base64" - "fmt" - "io" - "log" - "net" - "net/http" - "net/url" - "os" - "os/signal" - "strconv" - "strings" - "sync" - "syscall" - "time" - - utls "github.com/refraction-networking/utls" - "golang.org/x/net/http2" - "golang.org/x/net/proxy" -) - -// ────────────────────────────────────────────── -// TLS Sidecar — Go uTLS reverse proxy -// -// Node.js 发请求到 http://127.0.0.1:, -// 通过以下自定义 Header 传递目标信息: -// X-Target-Url: 实际目标 URL(必填) -// X-Proxy-Url: 上游代理(可选,支持 http/socks5) -// -// 所有其他 Header 原样转发给目标服务器。 -// 响应(包括 SSE 流式)透传回 Node.js。 -// -// uTLS 使用 Chrome 最新指纹,ALPN 协商 h2/http1.1, -// 根据服务器返回的 ALPN 自动选择 HTTP/2 或 HTTP/1.1 传输。 -// ────────────────────────────────────────────── - -const ( - defaultPort = 9090 - headerTarget = "X-Target-Url" - headerProxy = "X-Proxy-Url" - readTimeout = 30 * time.Second - writeTimeout = 0 // SSE 流式响应不设写超时(仅监听 localhost,安全) - idleTimeout = 120 * time.Second -) - -// 全局 RoundTripper 缓存(按 proxyURL 分组,复用 H2 连接) -var ( - rtCacheMu sync.Mutex - rtCache = make(map[string]*utlsRoundTripper) -) - -func getOrCreateRT(proxyURL string) *utlsRoundTripper { - rtCacheMu.Lock() - defer rtCacheMu.Unlock() - if rt, ok := rtCache[proxyURL]; ok { - return rt - } - rt := newUTLSRoundTripper(proxyURL) - rtCache[proxyURL] = rt - return rt -} - -// ──────────────── uTLS RoundTripper ──────────────── -// 根据 ALPN 协商结果自动选择 H2 或 H1 传输 - -type utlsRoundTripper struct { - proxyURL string - - mu sync.Mutex - h2Conns map[string]*http2.ClientConn // H2 连接缓存 (per host) -} - -func newUTLSRoundTripper(proxyURL string) *utlsRoundTripper { - return &utlsRoundTripper{ - proxyURL: proxyURL, - h2Conns: make(map[string]*http2.ClientConn), - } -} - -func (rt *utlsRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { - addr := req.URL.Host - if !strings.Contains(addr, ":") { - if req.URL.Scheme == "https" { - addr += ":443" - } else { - addr += ":80" - } - } - - // 尝试复用已有的 H2 连接 - rt.mu.Lock() - if cc, ok := rt.h2Conns[addr]; ok { - rt.mu.Unlock() - if cc.CanTakeNewRequest() { - resp, err := cc.RoundTrip(req) - if err == nil { - return resp, nil - } - // H2 连接已失效,清除缓存重建 - log.Printf("[TLS-Sidecar] Cached H2 conn failed for %s: %v, reconnecting", addr, err) - } - rt.mu.Lock() - delete(rt.h2Conns, addr) - rt.mu.Unlock() - } else { - rt.mu.Unlock() - } - - // 建立新的 uTLS 连接 - conn, err := dialUTLS(req.Context(), "tcp", addr, rt.proxyURL) - if err != nil { - return nil, err - } - - // 根据 ALPN 协商结果决定走 H2 还是 H1 - alpn := conn.ConnectionState().NegotiatedProtocol - log.Printf("[TLS-Sidecar] Connected to %s, ALPN: %q", addr, alpn) - - if alpn == "h2" { - // HTTP/2: 创建 H2 ClientConn - t2 := &http2.Transport{ - StrictMaxConcurrentStreams: true, - AllowHTTP: false, - } - cc, err := t2.NewClientConn(conn) - if err != nil { - conn.Close() - return nil, fmt.Errorf("h2 client conn: %w", err) - } - - rt.mu.Lock() - rt.h2Conns[addr] = cc - rt.mu.Unlock() - - return cc.RoundTrip(req) - } - - // HTTP/1.1: 通过一次性 Transport 使用已建立的 TLS 连接 - // DialTLSContext 返回已完成 TLS 握手的 conn,http.Transport 不会重复握手 - used := false - t1 := &http.Transport{ - DialTLSContext: func(ctx context.Context, network, a string) (net.Conn, error) { - if !used { - used = true - return conn, nil - } - // 后续连接走正常 uTLS dial - return dialUTLS(ctx, network, a, rt.proxyURL) - }, - MaxIdleConnsPerHost: 1, - IdleConnTimeout: 90 * time.Second, - } - - resp, err := t1.RoundTrip(req) - if err != nil { - conn.Close() - t1.CloseIdleConnections() - } - return resp, err -} - -func (rt *utlsRoundTripper) CloseIdleConnections() { - rt.mu.Lock() - defer rt.mu.Unlock() - for k, cc := range rt.h2Conns { - cc.Close() - delete(rt.h2Conns, k) - } -} - -// ──────────────── Main ──────────────── - -func main() { - // 强制将日志输出到 Stdout,避免 Node.js 侧将其误判为 Error - log.SetOutput(os.Stdout) - - port := defaultPort - if p := os.Getenv("TLS_SIDECAR_PORT"); p != "" { - if v, err := strconv.Atoi(p); err == nil { - port = v - } - } - - mux := http.NewServeMux() - mux.HandleFunc("/health", handleHealth) - mux.HandleFunc("/", handleProxy) - - srv := &http.Server{ - Addr: fmt.Sprintf("127.0.0.1:%d", port), - Handler: mux, - ReadTimeout: readTimeout, - WriteTimeout: writeTimeout, - IdleTimeout: idleTimeout, - } - - // Graceful shutdown - go func() { - sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) - <-sigCh - log.Println("[TLS-Sidecar] Shutting down...") - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - srv.Shutdown(ctx) - }() - - log.Printf("[TLS-Sidecar] Listening on 127.0.0.1:%d (Chrome uTLS, H2+H1 auto)\n", port) - if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Fatalf("[TLS-Sidecar] Fatal: %v", err) - } -} - -// ──────────────── Health ──────────────── - -func handleHealth(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) - fmt.Fprintf(w, `{"status":"ok","tls":"utls-chrome-auto","protocols":"h2,http/1.1"}`) -} - -// ──────────────── Proxy Handler ──────────────── - -func handleProxy(w http.ResponseWriter, r *http.Request) { - targetURL := r.Header.Get(headerTarget) - if targetURL == "" { - http.Error(w, `{"error":"missing X-Target-Url header"}`, http.StatusBadRequest) - return - } - - proxyURL := r.Header.Get(headerProxy) - - // Parse target - parsed, err := url.Parse(targetURL) - if err != nil { - http.Error(w, fmt.Sprintf(`{"error":"invalid target url: %s"}`, err), http.StatusBadRequest) - return - } - - // Build outgoing request - outReq, err := http.NewRequestWithContext(r.Context(), r.Method, targetURL, r.Body) - if err != nil { - http.Error(w, fmt.Sprintf(`{"error":"failed to create request: %s"}`, err), http.StatusInternalServerError) - return - } - - // Copy headers (skip internal + hop-by-hop) - // 403 关键修复:彻底清理所有非浏览器标头,严格保持小写 - for key, vals := range r.Header { - lk := strings.ToLower(key) - if lk == strings.ToLower(headerTarget) || lk == strings.ToLower(headerProxy) { - continue - } - // 移除所有代理、本地网络特征标头,防止 Cloudflare 识别 - if lk == "connection" || lk == "keep-alive" || lk == "transfer-encoding" || - lk == "te" || lk == "trailer" || lk == "upgrade" || lk == "host" || - lk == "x-forwarded-for" || lk == "x-real-ip" || lk == "x-forwarded-proto" || - lk == "x-forwarded-host" || lk == "via" || lk == "proxy-connection" || - lk == "cf-connecting-ip" || lk == "true-client-ip" { - continue - } - // 直接通过 map 赋值,确保 Go 的 http2 栈能识别并以原始(小写)形式发出 - outReq.Header[key] = vals - } - outReq.Host = parsed.Host - - // 针对 Grok 的特殊处理:如果 Accept-Encoding 包含 br 且环境可能存在压缩协商问题 - // 强制设置为标准的浏览器组合 - if ae := outReq.Header["Accept-Encoding"]; len(ae) > 0 { - outReq.Header["Accept-Encoding"] = []string{"gzip, deflate, br, zstd"} - } - - // Execute via uTLS RoundTripper - rt := getOrCreateRT(proxyURL) - resp, err := rt.RoundTrip(outReq) - if err != nil { - log.Printf("[TLS-Sidecar] RoundTrip error → %s: %v", parsed.Host, err) - http.Error(w, fmt.Sprintf(`{"error":"upstream request failed: %s"}`, err), http.StatusBadGateway) - return - } - defer resp.Body.Close() - - // Copy response headers - for key, vals := range resp.Header { - for _, v := range vals { - w.Header().Add(key, v) - } - } - w.WriteHeader(resp.StatusCode) - - // Stream body (SSE-friendly: flush after every read) - flusher, canFlush := w.(http.Flusher) - buf := make([]byte, 32*1024) - for { - n, readErr := resp.Body.Read(buf) - if n > 0 { - if _, writeErr := w.Write(buf[:n]); writeErr != nil { - log.Printf("[TLS-Sidecar] Write error: %v", writeErr) - return - } - if canFlush { - flusher.Flush() - } - } - if readErr != nil { - if readErr != io.EOF { - log.Printf("[TLS-Sidecar] Read error: %v", readErr) - } - return - } - } -} - -// ──────────────── uTLS Dial ──────────────── - -func dialUTLS(ctx context.Context, network, addr string, proxyURL string) (*utls.UConn, error) { - host, _, err := net.SplitHostPort(addr) - if err != nil { - host = addr - } - - // TCP 连接(可能经过代理) - var rawConn net.Conn - if proxyURL != "" { - rawConn, err = dialViaProxy(ctx, network, addr, proxyURL) - } else { - var d net.Dialer - rawConn, err = d.DialContext(ctx, network, addr) - } - if err != nil { - return nil, fmt.Errorf("tcp dial failed: %w", err) - } - - // uTLS 握手 — 使用 Chrome 最新自动指纹 - // 403 错误通过保持标头小写和清理转发标头来解决 - tlsConn := utls.UClient(rawConn, &utls.Config{ - ServerName: host, - NextProtos: []string{"h2", "http/1.1"}, - }, utls.HelloChrome_Auto) - - // 握手超时 - if deadline, ok := ctx.Deadline(); ok { - tlsConn.SetDeadline(deadline) - } else { - tlsConn.SetDeadline(time.Now().Add(15 * time.Second)) - } - - if err := tlsConn.Handshake(); err != nil { - rawConn.Close() - return nil, fmt.Errorf("utls handshake failed: %w", err) - } - - // 握手完成,清除超时 - tlsConn.SetDeadline(time.Time{}) - return tlsConn, nil -} - -// ──────────────── Proxy Dialer ──────────────── - -func dialViaProxy(ctx context.Context, network, addr string, proxyURL string) (net.Conn, error) { - parsed, err := url.Parse(proxyURL) - if err != nil { - return nil, fmt.Errorf("invalid proxy url: %w", err) - } - - switch strings.ToLower(parsed.Scheme) { - case "socks5", "socks5h", "socks4", "socks": - var auth *proxy.Auth - if parsed.User != nil { - auth = &proxy.Auth{ - User: parsed.User.Username(), - } - auth.Password, _ = parsed.User.Password() - } - dialer, err := proxy.SOCKS5("tcp", parsed.Host, auth, &net.Dialer{ - Timeout: 15 * time.Second, - }) - if err != nil { - return nil, fmt.Errorf("socks5 dialer: %w", err) - } - if ctxDialer, ok := dialer.(proxy.ContextDialer); ok { - return ctxDialer.DialContext(ctx, network, addr) - } - return dialer.Dial(network, addr) - - case "http", "https": - proxyConn, err := net.DialTimeout("tcp", parsed.Host, 15*time.Second) - if err != nil { - return nil, fmt.Errorf("connect to http proxy: %w", err) - } - - connectReq := fmt.Sprintf("CONNECT %s HTTP/1.1\r\nHost: %s\r\n", addr, addr) - if parsed.User != nil { - username := parsed.User.Username() - password, _ := parsed.User.Password() - cred := base64.StdEncoding.EncodeToString([]byte(username + ":" + password)) - connectReq += fmt.Sprintf("Proxy-Authorization: Basic %s\r\n", cred) - } - connectReq += "\r\n" - - if _, err = proxyConn.Write([]byte(connectReq)); err != nil { - proxyConn.Close() - return nil, fmt.Errorf("proxy CONNECT write: %w", err) - } - - buf := make([]byte, 4096) - n, err := proxyConn.Read(buf) - if err != nil { - proxyConn.Close() - return nil, fmt.Errorf("proxy CONNECT read: %w", err) - } - if !strings.Contains(string(buf[:n]), "200") { - proxyConn.Close() - return nil, fmt.Errorf("proxy CONNECT rejected: %s", strings.TrimSpace(string(buf[:n]))) - } - - return proxyConn, nil - - default: - return nil, fmt.Errorf("unsupported proxy scheme: %s", parsed.Scheme) - } -}