Leeflour commited on
Commit
d0dd276
·
verified ·
1 Parent(s): d14c35a

Upload 197 files

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env +60 -0
  2. .gitattributes +10 -0
  3. .github/ISSUE_TEMPLATE/bug_report.yml +53 -0
  4. .github/ISSUE_TEMPLATE/config.yml +1 -0
  5. .github/ISSUE_TEMPLATE/feature_request.yml +32 -0
  6. .github/workflows/issues-duplicate.yml +25 -0
  7. .github/workflows/main.yml +48 -0
  8. .gitignore +1 -0
  9. .python-version +1 -0
  10. Dockerfile +12 -0
  11. LICENSE +407 -0
  12. README.md +123 -10
  13. app/__init__.py +0 -0
  14. app/api/__init__.py +9 -0
  15. app/api/dashboard.py +830 -0
  16. app/api/nonstream_handlers.py +577 -0
  17. app/api/routes.py +336 -0
  18. app/api/stream_handlers.py +374 -0
  19. app/config/__init__.py +4 -0
  20. app/config/persistence.py +165 -0
  21. app/config/safety.py +49 -0
  22. app/config/settings.py +125 -0
  23. app/main.py +260 -0
  24. app/models/schemas.py +68 -0
  25. app/services/OpenAI.py +107 -0
  26. app/services/__init__.py +9 -0
  27. app/services/gemini.py +472 -0
  28. app/templates/__init__.py +1 -0
  29. app/templates/assets/0506c607efda914c9388132c9cbb0c53.js +0 -0
  30. app/templates/assets/9a4f356975f1a7b8b7bad9e93c1becba.css +1 -0
  31. app/templates/assets/aafbaf642c01961ff24ddb8941d1bf59.html +14 -0
  32. app/templates/assets/favicon.ico +0 -0
  33. app/templates/index.html +15 -0
  34. app/utils/__init__.py +12 -0
  35. app/utils/api_key.py +87 -0
  36. app/utils/auth.py +36 -0
  37. app/utils/cache.py +291 -0
  38. app/utils/error_handling.py +136 -0
  39. app/utils/logging.py +148 -0
  40. app/utils/maintenance.py +95 -0
  41. app/utils/rate_limiting.py +36 -0
  42. app/utils/request.py +52 -0
  43. app/utils/response.py +130 -0
  44. app/utils/stats.py +299 -0
  45. app/utils/version.py +48 -0
  46. app/vertex/__init__.py +1 -0
  47. app/vertex/api_helpers.py +317 -0
  48. app/vertex/auth.py +109 -0
  49. app/vertex/config.py +139 -0
  50. app/vertex/credentials_manager.py +271 -0
.env ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #基础部分
2
+ #设置一个你自己的访问密码
3
+ PASSWORD=123
4
+ #配置时区
5
+ TZ=Asia/Shanghai
6
+
7
+ #ai studio部分
8
+
9
+ #将key1,key2,key3等替换为你真正拥有的gemini api key
10
+ GEMINI_API_KEYS=key1,key2,key3
11
+
12
+ #是否启用存储
13
+ ENABLE_STORAGE=true
14
+
15
+ #存储路径
16
+ STORAGE_DIR=./hajimi
17
+
18
+ #每分钟最大请求数
19
+ MAX_REQUESTS_PER_MINUTE=30
20
+
21
+ #每天每个 IP 最大请求数
22
+ MAX_REQUESTS_PER_DAY_PER_IP=600
23
+
24
+ #是否启用假流式传输
25
+ FAKE_STREAMING=true
26
+
27
+ #单api 24小时最大使用次数
28
+ API_KEY_DAILY_LIMIT=100
29
+
30
+ #空响应重试次数
31
+ MAX_EMPTY_RESPONSES=5
32
+
33
+ #是否启用伪装信息
34
+ RANDOM_STRING=true
35
+
36
+ #伪装信息长度
37
+ RANDOM_STRING_LENGTH=5
38
+
39
+ #默认的并发请求数
40
+ CONCURRENT_REQUESTS=1
41
+
42
+ #当请求失败时增加的并发请求数
43
+ INCREASE_CONCURRENT_ON_FAILURE=0
44
+
45
+ 允许的最大并发请求数
46
+ MAX_CONCURRENT_REQUESTS=3
47
+
48
+ #是否启用联网模式(联网模式有严格的审核)
49
+ SEARCH_MODE=false
50
+
51
+ #联网模式提示词(用英文单引号包裹提示词)
52
+ SEARCH_PROMPT='(使用搜索工具联网搜索,需要在content中结合搜索内容)'
53
+
54
+ #vertex部分(如果您不需要vertex或不知道vertex是什么,无需配置这些内容)
55
+
56
+ #是否启用vertex
57
+ ENABLE_VERTEX=false
58
+
59
+ #vertex ai 凭证
60
+ GOOGLE_CREDENTIALS_JSON=''
.gitattributes CHANGED
@@ -33,3 +33,13 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ wiki/img/claw/1.png filter=lfs diff=lfs merge=lfs -text
37
+ wiki/img/claw/3.png filter=lfs diff=lfs merge=lfs -text
38
+ wiki/img/files.png filter=lfs diff=lfs merge=lfs -text
39
+ wiki/img/settings.png filter=lfs diff=lfs merge=lfs -text
40
+ wiki/img/spaces.png filter=lfs diff=lfs merge=lfs -text
41
+ wiki/img/vertex/index.png filter=lfs diff=lfs merge=lfs -text
42
+ wiki/img/windows/添加key.png filter=lfs diff=lfs merge=lfs -text
43
+ wiki/img/windows/主页-安装包位置.png filter=lfs diff=lfs merge=lfs -text
44
+ wiki/img/zeabur/6.png filter=lfs diff=lfs merge=lfs -text
45
+ wiki/img/zeabur/7.png filter=lfs diff=lfs merge=lfs -text
.github/ISSUE_TEMPLATE/bug_report.yml ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: "问题反馈"
2
+ description: Bug report
3
+ labels: [bug]
4
+ body:
5
+ - type: markdown
6
+ attributes:
7
+ value: |
8
+ 感谢您花时间填写此错误报告,请**务必确认您的issue不是重复的且不是因为您的操作或版本问题**
9
+
10
+ - type: checkboxes
11
+ attributes:
12
+ label: Please make sure of the following things
13
+ description: |
14
+ 您必须勾选以下所有内容,否则您的issue可能会被直接关闭。
15
+ options:
16
+ - label: |
17
+ 我已经阅读了[错误自查](./wiki/error.md)。
18
+ - label: |
19
+ 我确定没有重复的issue或讨论。
20
+ - label: |
21
+ 我确定是`Hajimi`自身的问题,而不是酒馆等三方件的原因。
22
+ - label: |
23
+ 我确定这个问题在最新版本中没有被修复。
24
+
25
+ - type: input
26
+ id: version
27
+ attributes:
28
+ label: hajimi版本
29
+ description: |
30
+ 您使用的是哪个版本的程序?请不要使用`latest`作为答案。
31
+ placeholder: v0.x.x
32
+ validations:
33
+ required: true
34
+ - type: textarea
35
+ id: bug-description
36
+ attributes:
37
+ label: 问题描述
38
+ validations:
39
+ required: true
40
+ - type: textarea
41
+ id: reproduction
42
+ attributes:
43
+ label: 复现方法
44
+ description: |
45
+ 请提供能复现此问题的方法以方便开发者定问题,请知悉如果不提供它你的issue可能会被直接关闭。
46
+ validations:
47
+ required: true
48
+ - type: textarea
49
+ id: logs
50
+ attributes:
51
+ label: Logs / 日志
52
+ description: |
53
+ 请复制粘贴错误日志,或者截图
.github/ISSUE_TEMPLATE/config.yml ADDED
@@ -0,0 +1 @@
 
 
1
+ blank_issues_enabled: false
.github/ISSUE_TEMPLATE/feature_request.yml ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: "功能请求"
2
+ description: Feature request
3
+ labels: [enhancement]
4
+ body:
5
+ - type: checkboxes
6
+ attributes:
7
+ label: 请确认以下事项
8
+ description: 您可以选择多项,甚至全部。
9
+ options:
10
+ - label: 我已阅读了[更新日志](./update.md)。
11
+ - label: 我确定没有重复的议题或讨论。
12
+ - label: 我确定此功能尚未实现。
13
+ - label: 我确定这是一个合理且普遍的需求。
14
+ - type: textarea
15
+ id: feature-description
16
+ attributes:
17
+ label: 功能描述
18
+ validations:
19
+ required: true
20
+ - type: textarea
21
+ id: suggested-solution
22
+ attributes:
23
+ label: 建议的解决方案
24
+ description: |
25
+ 实现此需求的解决思路。
26
+ - type: textarea
27
+ id: additional-context
28
+ attributes:
29
+ label: 附加信息
30
+ description: |
31
+ 关于此功能请求的任何其他上下文或截图,或者您觉得有帮助的信息。
32
+ 相关的任何其他上下文或截图,或者你觉得有帮助的信息
.github/workflows/issues-duplicate.yml ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Issue Duplicate
2
+
3
+ on:
4
+ issues:
5
+ types: [labeled]
6
+
7
+ jobs:
8
+ create-comment:
9
+ runs-on: ubuntu-latest
10
+ if: github.event.label.name == 'duplicate'
11
+ steps:
12
+ - name: Create comment
13
+ uses: actions-cool/issues-helper@v3
14
+ with:
15
+ actions: 'create-comment'
16
+ token: ${{ secrets.GITHUB_TOKEN }}
17
+ issue-number: ${{ github.event.issue.number }}
18
+ body: |
19
+ Hello @${{ github.event.issue.user.login }}, your issue is a duplicate and will be closed.
20
+ 你好 @${{ github.event.issue.user.login }},你的issue是重复的,将被关闭。
21
+ - name: Close issue
22
+ uses: actions-cool/issues-helper@v3
23
+ with:
24
+ actions: 'close-issue'
25
+ token: ${{ secrets.GITHUB_TOKEN }}
.github/workflows/main.yml ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: GHCR CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ workflow_dispatch:
7
+
8
+ jobs:
9
+ build-and-push:
10
+ runs-on: ubuntu-latest
11
+ permissions:
12
+ contents: read
13
+ packages: write
14
+
15
+ steps:
16
+ - name: lowercase repository name
17
+ run: echo "IMAGE_NAME=${GITHUB_REPOSITORY@L}" >> ${GITHUB_ENV}
18
+
19
+ - name: Checkout repository
20
+ uses: actions/checkout@v4
21
+
22
+ - name: Read version from file
23
+ id: version
24
+ run: echo "VERSION=$(cat version.txt | grep version= | cut -d= -f2)" >> $GITHUB_OUTPUT
25
+
26
+ - name: Log in to the GitHub Container Registry
27
+ uses: docker/login-action@v3
28
+ with:
29
+ registry: ghcr.io
30
+ username: ${{ github.actor }}
31
+ password: ${{ secrets.GITHUB_TOKEN }}
32
+
33
+ - name: Set up Docker Buildx
34
+ uses: docker/setup-buildx-action@v3
35
+
36
+ - name: Build and push Docker image
37
+ uses: docker/build-push-action@v6
38
+ with:
39
+ context: .
40
+ push: true
41
+ tags: |
42
+ ghcr.io/${{ env.IMAGE_NAME }}:latest
43
+ ghcr.io/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.VERSION }}
44
+ cache-from: type=gha
45
+ cache-to: type=gha,mode=max
46
+ platforms: |
47
+ linux/amd64
48
+ linux/arm64
.gitignore ADDED
@@ -0,0 +1 @@
 
 
1
+ __pycache__/
.python-version ADDED
@@ -0,0 +1 @@
 
 
1
+ 3.12
Dockerfile ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY . .
6
+
7
+ RUN pip install uv
8
+ RUN uv pip install --system --no-cache-dir -r requirements.txt
9
+
10
+ EXPOSE 7860
11
+
12
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
LICENSE ADDED
@@ -0,0 +1,407 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Attribution-NonCommercial 4.0 International
2
+
3
+ =======================================================================
4
+
5
+ Creative Commons Corporation ("Creative Commons") is not a law firm and
6
+ does not provide legal services or legal advice. Distribution of
7
+ Creative Commons public licenses does not create a lawyer-client or
8
+ other relationship. Creative Commons makes its licenses and related
9
+ information available on an "as-is" basis. Creative Commons gives no
10
+ warranties regarding its licenses, any material licensed under their
11
+ terms and conditions, or any related information. Creative Commons
12
+ disclaims all liability for damages resulting from their use to the
13
+ fullest extent possible.
14
+
15
+ Using Creative Commons Public Licenses
16
+
17
+ Creative Commons public licenses provide a standard set of terms and
18
+ conditions that creators and other rights holders may use to share
19
+ original works of authorship and other material subject to copyright
20
+ and certain other rights specified in the public license below. The
21
+ following considerations are for informational purposes only, are not
22
+ exhaustive, and do not form part of our licenses.
23
+
24
+ Considerations for licensors: Our public licenses are
25
+ intended for use by those authorized to give the public
26
+ permission to use material in ways otherwise restricted by
27
+ copyright and certain other rights. Our licenses are
28
+ irrevocable. Licensors should read and understand the terms
29
+ and conditions of the license they choose before applying it.
30
+ Licensors should also secure all rights necessary before
31
+ applying our licenses so that the public can reuse the
32
+ material as expected. Licensors should clearly mark any
33
+ material not subject to the license. This includes other CC-
34
+ licensed material, or material used under an exception or
35
+ limitation to copyright. More considerations for licensors:
36
+ wiki.creativecommons.org/Considerations_for_licensors
37
+
38
+ Considerations for the public: By using one of our public
39
+ licenses, a licensor grants the public permission to use the
40
+ licensed material under specified terms and conditions. If
41
+ the licensor's permission is not necessary for any reason--for
42
+ example, because of any applicable exception or limitation to
43
+ copyright--then that use is not regulated by the license. Our
44
+ licenses grant only permissions under copyright and certain
45
+ other rights that a licensor has authority to grant. Use of
46
+ the licensed material may still be restricted for other
47
+ reasons, including because others have copyright or other
48
+ rights in the material. A licensor may make special requests,
49
+ such as asking that all changes be marked or described.
50
+ Although not required by our licenses, you are encouraged to
51
+ respect those requests where reasonable. More considerations
52
+ for the public:
53
+ wiki.creativecommons.org/Considerations_for_licensees
54
+
55
+ =======================================================================
56
+
57
+ Creative Commons Attribution-NonCommercial 4.0 International Public
58
+ License
59
+
60
+ By exercising the Licensed Rights (defined below), You accept and agree
61
+ to be bound by the terms and conditions of this Creative Commons
62
+ Attribution-NonCommercial 4.0 International Public License ("Public
63
+ License"). To the extent this Public License may be interpreted as a
64
+ contract, You are granted the Licensed Rights in consideration of Your
65
+ acceptance of these terms and conditions, and the Licensor grants You
66
+ such rights in consideration of benefits the Licensor receives from
67
+ making the Licensed Material available under these terms and
68
+ conditions.
69
+
70
+
71
+ Section 1 -- Definitions.
72
+
73
+ a. Adapted Material means material subject to Copyright and Similar
74
+ Rights that is derived from or based upon the Licensed Material
75
+ and in which the Licensed Material is translated, altered,
76
+ arranged, transformed, or otherwise modified in a manner requiring
77
+ permission under the Copyright and Similar Rights held by the
78
+ Licensor. For purposes of this Public License, where the Licensed
79
+ Material is a musical work, performance, or sound recording,
80
+ Adapted Material is always produced where the Licensed Material is
81
+ synched in timed relation with a moving image.
82
+
83
+ b. Adapter's License means the license You apply to Your Copyright
84
+ and Similar Rights in Your contributions to Adapted Material in
85
+ accordance with the terms and conditions of this Public License.
86
+
87
+ c. Copyright and Similar Rights means copyright and/or similar rights
88
+ closely related to copyright including, without limitation,
89
+ performance, broadcast, sound recording, and Sui Generis Database
90
+ Rights, without regard to how the rights are labeled or
91
+ categorized. For purposes of this Public License, the rights
92
+ specified in Section 2(b)(1)-(2) are not Copyright and Similar
93
+ Rights.
94
+ d. Effective Technological Measures means those measures that, in the
95
+ absence of proper authority, may not be circumvented under laws
96
+ fulfilling obligations under Article 11 of the WIPO Copyright
97
+ Treaty adopted on December 20, 1996, and/or similar international
98
+ agreements.
99
+
100
+ e. Exceptions and Limitations means fair use, fair dealing, and/or
101
+ any other exception or limitation to Copyright and Similar Rights
102
+ that applies to Your use of the Licensed Material.
103
+
104
+ f. Licensed Material means the artistic or literary work, database,
105
+ or other material to which the Licensor applied this Public
106
+ License.
107
+
108
+ g. Licensed Rights means the rights granted to You subject to the
109
+ terms and conditions of this Public License, which are limited to
110
+ all Copyright and Similar Rights that apply to Your use of the
111
+ Licensed Material and that the Licensor has authority to license.
112
+
113
+ h. Licensor means the individual(s) or entity(ies) granting rights
114
+ under this Public License.
115
+
116
+ i. NonCommercial means not primarily intended for or directed towards
117
+ commercial advantage or monetary compensation. For purposes of
118
+ this Public License, the exchange of the Licensed Material for
119
+ other material subject to Copyright and Similar Rights by digital
120
+ file-sharing or similar means is NonCommercial provided there is
121
+ no payment of monetary compensation in connection with the
122
+ exchange.
123
+
124
+ j. Share means to provide material to the public by any means or
125
+ process that requires permission under the Licensed Rights, such
126
+ as reproduction, public display, public performance, distribution,
127
+ dissemination, communication, or importation, and to make material
128
+ available to the public including in ways that members of the
129
+ public may access the material from a place and at a time
130
+ individually chosen by them.
131
+
132
+ k. Sui Generis Database Rights means rights other than copyright
133
+ resulting from Directive 96/9/EC of the European Parliament and of
134
+ the Council of 11 March 1996 on the legal protection of databases,
135
+ as amended and/or succeeded, as well as other essentially
136
+ equivalent rights anywhere in the world.
137
+
138
+ l. You means the individual or entity exercising the Licensed Rights
139
+ under this Public License. Your has a corresponding meaning.
140
+
141
+
142
+ Section 2 -- Scope.
143
+
144
+ a. License grant.
145
+
146
+ 1. Subject to the terms and conditions of this Public License,
147
+ the Licensor hereby grants You a worldwide, royalty-free,
148
+ non-sublicensable, non-exclusive, irrevocable license to
149
+ exercise the Licensed Rights in the Licensed Material to:
150
+
151
+ a. reproduce and Share the Licensed Material, in whole or
152
+ in part, for NonCommercial purposes only; and
153
+
154
+ b. produce, reproduce, and Share Adapted Material for
155
+ NonCommercial purposes only.
156
+
157
+ 2. Exceptions and Limitations. For the avoidance of doubt, where
158
+ Exceptions and Limitations apply to Your use, this Public
159
+ License does not apply, and You do not need to comply with
160
+ its terms and conditions.
161
+
162
+ 3. Term. The term of this Public License is specified in Section
163
+ 6(a).
164
+
165
+ 4. Media and formats; technical modifications allowed. The
166
+ Licensor authorizes You to exercise the Licensed Rights in
167
+ all media and formats whether now known or hereafter created,
168
+ and to make technical modifications necessary to do so. The
169
+ Licensor waives and/or agrees not to assert any right or
170
+ authority to forbid You from making technical modifications
171
+ necessary to exercise the Licensed Rights, including
172
+ technical modifications necessary to circumvent Effective
173
+ Technological Measures. For purposes of this Public License,
174
+ simply making modifications authorized by this Section 2(a)
175
+ (4) never produces Adapted Material.
176
+
177
+ 5. Downstream recipients.
178
+
179
+ a. Offer from the Licensor -- Licensed Material. Every
180
+ recipient of the Licensed Material automatically
181
+ receives an offer from the Licensor to exercise the
182
+ Licensed Rights under the terms and conditions of this
183
+ Public License.
184
+
185
+ b. No downstream restrictions. You may not offer or impose
186
+ any additional or different terms or conditions on, or
187
+ apply any Effective Technological Measures to, the
188
+ Licensed Material if doing so restricts exercise of the
189
+ Licensed Rights by any recipient of the Licensed
190
+ Material.
191
+
192
+ 6. No endorsement. Nothing in this Public License constitutes or
193
+ may be construed as permission to assert or imply that You
194
+ are, or that Your use of the Licensed Material is, connected
195
+ with, or sponsored, endorsed, or granted official status by,
196
+ the Licensor or others designated to receive attribution as
197
+ provided in Section 3(a)(1)(A)(i).
198
+
199
+ b. Other rights.
200
+
201
+ 1. Moral rights, such as the right of integrity, are not
202
+ licensed under this Public License, nor are publicity,
203
+ privacy, and/or other similar personality rights; however, to
204
+ the extent possible, the Licensor waives and/or agrees not to
205
+ assert any such rights held by the Licensor to the limited
206
+ extent necessary to allow You to exercise the Licensed
207
+ Rights, but not otherwise.
208
+
209
+ 2. Patent and trademark rights are not licensed under this
210
+ Public License.
211
+
212
+ 3. To the extent possible, the Licensor waives any right to
213
+ collect royalties from You for the exercise of the Licensed
214
+ Rights, whether directly or through a collecting society
215
+ under any voluntary or waivable statutory or compulsory
216
+ licensing scheme. In all other cases the Licensor expressly
217
+ reserves any right to collect such royalties, including when
218
+ the Licensed Material is used other than for NonCommercial
219
+ purposes.
220
+
221
+
222
+ Section 3 -- License Conditions.
223
+
224
+ Your exercise of the Licensed Rights is expressly made subject to the
225
+ following conditions.
226
+
227
+ a. Attribution.
228
+
229
+ 1. If You Share the Licensed Material (including in modified
230
+ form), You must:
231
+
232
+ a. retain the following if it is supplied by the Licensor
233
+ with the Licensed Material:
234
+
235
+ i. identification of the creator(s) of the Licensed
236
+ Material and any others designated to receive
237
+ attribution, in any reasonable manner requested by
238
+ the Licensor (including by pseudonym if
239
+ designated);
240
+
241
+ ii. a copyright notice;
242
+
243
+ iii. a notice that refers to this Public License;
244
+
245
+ iv. a notice that refers to the disclaimer of
246
+ warranties;
247
+
248
+ v. a URI or hyperlink to the Licensed Material to the
249
+ extent reasonably practicable;
250
+
251
+ b. indicate if You modified the Licensed Material and
252
+ retain an indication of any previous modifications; and
253
+
254
+ c. indicate the Licensed Material is licensed under this
255
+ Public License, and include the text of, or the URI or
256
+ hyperlink to, this Public License.
257
+
258
+ 2. You may satisfy the conditions in Section 3(a)(1) in any
259
+ reasonable manner based on the medium, means, and context in
260
+ which You Share the Licensed Material. For example, it may be
261
+ reasonable to satisfy the conditions by providing a URI or
262
+ hyperlink to a resource that includes the required
263
+ information.
264
+
265
+ 3. If requested by the Licensor, You must remove any of the
266
+ information required by Section 3(a)(1)(A) to the extent
267
+ reasonably practicable.
268
+
269
+ 4. If You Share Adapted Material You produce, the Adapter's
270
+ License You apply must not prevent recipients of the Adapted
271
+ Material from complying with this Public License.
272
+
273
+
274
+ Section 4 -- Sui Generis Database Rights.
275
+
276
+ Where the Licensed Rights include Sui Generis Database Rights that
277
+ apply to Your use of the Licensed Material:
278
+
279
+ a. for the avoidance of doubt, Section 2(a)(1) grants You the right
280
+ to extract, reuse, reproduce, and Share all or a substantial
281
+ portion of the contents of the database for NonCommercial purposes
282
+ only;
283
+
284
+ b. if You include all or a substantial portion of the database
285
+ contents in a database in which You have Sui Generis Database
286
+ Rights, then the database in which You have Sui Generis Database
287
+ Rights (but not its individual contents) is Adapted Material; and
288
+
289
+ c. You must comply with the conditions in Section 3(a) if You Share
290
+ all or a substantial portion of the contents of the database.
291
+
292
+ For the avoidance of doubt, this Section 4 supplements and does not
293
+ replace Your obligations under this Public License where the Licensed
294
+ Rights include other Copyright and Similar Rights.
295
+
296
+
297
+ Section 5 -- Disclaimer of Warranties and Limitation of Liability.
298
+
299
+ a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
300
+ EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
301
+ AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
302
+ ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
303
+ IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
304
+ WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
305
+ PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
306
+ ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
307
+ KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
308
+ ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
309
+
310
+ b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
311
+ TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
312
+ NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
313
+ INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
314
+ COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
315
+ USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
316
+ ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
317
+ DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
318
+ IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
319
+
320
+ c. The disclaimer of warranties and limitation of liability provided
321
+ above shall be interpreted in a manner that, to the extent
322
+ possible, most closely approximates an absolute disclaimer and
323
+ waiver of all liability.
324
+
325
+
326
+ Section 6 -- Term and Termination.
327
+
328
+ a. This Public License applies for the term of the Copyright and
329
+ Similar Rights licensed here. However, if You fail to comply with
330
+ this Public License, then Your rights under this Public License
331
+ terminate automatically.
332
+
333
+ b. Where Your right to use the Licensed Material has terminated under
334
+ Section 6(a), it reinstates:
335
+
336
+ 1. automatically as of the date the violation is cured, provided
337
+ it is cured within 30 days of Your discovery of the
338
+ violation; or
339
+
340
+ 2. upon express reinstatement by the Licensor.
341
+
342
+ For the avoidance of doubt, this Section 6(b) does not affect any
343
+ right the Licensor may have to seek remedies for Your violations
344
+ of this Public License.
345
+
346
+ c. For the avoidance of doubt, the Licensor may also offer the
347
+ Licensed Material under separate terms or conditions or stop
348
+ distributing the Licensed Material at any time; however, doing so
349
+ will not terminate this Public License.
350
+
351
+ d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
352
+ License.
353
+
354
+
355
+ Section 7 -- Other Terms and Conditions.
356
+
357
+ a. The Licensor shall not be bound by any additional or different
358
+ terms or conditions communicated by You unless expressly agreed.
359
+
360
+ b. Any arrangements, understandings, or agreements regarding the
361
+ Licensed Material not stated herein are separate from and
362
+ independent of the terms and conditions of this Public License.
363
+
364
+
365
+ Section 8 -- Interpretation.
366
+
367
+ a. For the avoidance of doubt, this Public License does not, and
368
+ shall not be interpreted to, reduce, limit, restrict, or impose
369
+ conditions on any use of the Licensed Material that could lawfully
370
+ be made without permission under this Public License.
371
+
372
+ b. To the extent possible, if any provision of this Public License is
373
+ deemed unenforceable, it shall be automatically reformed to the
374
+ minimum extent necessary to make it enforceable. If the provision
375
+ cannot be reformed, it shall be severed from this Public License
376
+ without affecting the enforceability of the remaining terms and
377
+ conditions.
378
+
379
+ c. No term or condition of this Public License will be waived and no
380
+ failure to comply consented to unless expressly agreed to by the
381
+ Licensor.
382
+
383
+ d. Nothing in this Public License constitutes or may be interpreted
384
+ as a limitation upon, or waiver of, any privileges and immunities
385
+ that apply to the Licensor or You, including from the legal
386
+ processes of any jurisdiction or authority.
387
+
388
+ =======================================================================
389
+
390
+ Creative Commons is not a party to its public
391
+ licenses. Notwithstanding, Creative Commons may elect to apply one of
392
+ its public licenses to material it publishes and in those instances
393
+ will be considered the “Licensor.” The text of the Creative Commons
394
+ public licenses is dedicated to the public domain under the CC0 Public
395
+ Domain Dedication. Except for the limited purpose of indicating that
396
+ material is shared under a Creative Commons public license or as
397
+ otherwise permitted by the Creative Commons policies published at
398
+ creativecommons.org/policies, Creative Commons does not authorize the
399
+ use of the trademark "Creative Commons" or any other trademark or logo
400
+ of Creative Commons without its prior written consent including,
401
+ without limitation, in connection with any unauthorized modifications
402
+ to any of its public licenses or any other arrangements,
403
+ understandings, or agreements concerning use of licensed material. For
404
+ the avoidance of doubt, this paragraph does not form part of the
405
+ public licenses.
406
+
407
+ Creative Commons may be contacted at creativecommons.org.
README.md CHANGED
@@ -1,10 +1,123 @@
1
- ---
2
- title: Fufeigemini
3
- emoji:
4
- colorFrom: pink
5
- colorTo: gray
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 🚀 HAJIMI Gemini API Proxy
2
+
3
+ - 这是一个基于 FastAPI 构建的 Gemini API 代理,旨在提供一个简单、安全且可配置的方式来访问 Google 的 Gemini 模型。适用于在 Hugging Face Spaces 上部署,并支持openai api格式的工具集成。
4
+
5
+ ## 管理前端一键部署模板
6
+ [![Use EdgeOne Pages to deploy](https://cdnstatic.tencentcs.com/edgeone/pages/deploy.svg)](https://edgeone.ai/pages/new?repository-url=https%3A%2F%2Fgithub.com%2Fwyeeeee%2Fhajimi&root-directory=.%2Fpage&output-directory=..%2Fapp%2Ftemplates%2Fassets&install-command=npm%20install&build-command=npm%20run%20build)
7
+
8
+ # 本项目基于CC BY-NC 4.0许可开源,需遵守以下规则
9
+ - 您必须给出适当的署名,提供指向本协议的链接,并指明是否(对原作)作了修改。您可以以任何合理方式进行,但不得以任何方式暗示许可方认可您或您的使用。
10
+ - 您不得将本作品用于商业目的,包括但不限于任何形式的商业倒卖、SaaS、API 付费接口、二次销售、打包出售、收费分发或其他直接或间接盈利行为。
11
+
12
+ ### 如需商业授权,请联系原作者获得书面许可。违者将承担相应法律责任。
13
+
14
+ ### 感谢[@warming-afternoon](https://github.com/warming-afternoon),[@任梓樂](https://github.com/rzline)在技术上的大力支持
15
+
16
+ ### 错误自查
17
+
18
+ 遇到问题请先查看以下的 **错误自查** 文档,确保已尝试按照其上的指示进行了相应的排查与处理。
19
+
20
+ - [错误自查](./wiki/error.md)
21
+ ### 使用文档
22
+ - [huggingface 部署的使用文档(复活?!)(推荐,免费,手机电脑均可使用)](./wiki/huggingface2.md)
23
+
24
+ - [Claw Cloud部署的使用文档(推荐,免费,手机电脑均可使用)](./wiki/claw.md) 感谢[@IDeposit](https://github.com/IDeposit)编写
25
+
26
+ - [termux部署的使用文档(手机使用)](./wiki/Termux.md) 感谢[@天命不又](https://github.com/tmby)编写
27
+
28
+ - [windows 本地部署的使用文档](./wiki/windows.md)
29
+
30
+ - ~~[zeabur部署的使用文档(需付费)](./wiki/zeabur.md) 感谢**墨舞ink**编写~~(已过时且暂时无人更新,欢迎提交pull requests)
31
+
32
+ - [vertex模式的使用文档](./wiki/vertex.md)
33
+
34
+ ### 更新日志
35
+ * v1.0.1
36
+ * 新增`清除失效密钥`功能
37
+ * 新增`输出有效秘钥`功能
38
+
39
+ ## ✨ 主要功能:
40
+
41
+ ### 🔑 API 密钥轮询和管理
42
+
43
+ ### 📑 模型列表接口
44
+
45
+ ### 💬 聊天补全接口:
46
+
47
+ * 提供 `/v1/chat/completions` 接口,支持流式和非流式响应,支持函数调用,与 OpenAI API 格式兼容。
48
+ * 支持的输入内容: 文本、文件、图像
49
+ * 自动将 OpenAI 格式的请求转换为 Gemini 格式。
50
+
51
+ ### 🔒 密码保护(可选):
52
+
53
+ * 通过 `PASSWORD` 环境变量设置密码。
54
+ * 提供默认密码 `"123"`。
55
+
56
+ ### 🧩 服务兼容
57
+
58
+ * 提供的接口与 OpenAI API 格式兼容,便于接入各种服务
59
+
60
+ ### ⚙️ 功能配置
61
+
62
+ * 方式 1 : 通过网页前端进行配置
63
+ * 方式 2 : 根据 [配置文档](./app/config/settings.py) 中的注释说明,修改对应的变量
64
+
65
+ ## ⚠️ 注意事项:
66
+
67
+ * **强烈建议在生产环境中设置 `PASSWORD` 环境变量,并使用强密码。**
68
+ * 根据你的使用情况调整速率限制相关的环境变量。
69
+ * 确保你的 Gemini API 密钥具有足够的配额。
70
+
71
+
72
+ ## 💡 特色功能:
73
+
74
+ ### 🎭 假流式传输
75
+
76
+ * **作用:** 解决部分网络环境下客户端通过非流式请求 Gemini 时可能遇到的断连问题。**默认开启**。
77
+
78
+ * **原理简述:** 当客户端请求流式响应时,本代理会每隔一段时间向客户端发出一个空信息以维持连接,同时在后台向 Gemini 发起一个完整的、非流式的请求。等 Gemini 返回完整响应后,再一次性将响应发回给客户端。
79
+
80
+ * **注意:** 如果想使用真的流式请求,请**关闭**该功能
81
+
82
+ ### ⚡ 并发与缓存
83
+
84
+ * **作用:** 允许您为用户的单次提问同时向 Gemini 发送多个请求,并将额外的成功响应缓存起来,用于后续重新生成回复。
85
+
86
+ * **注意:** 此功能**默认关闭** 。只有当您将并发数设置为 2 或以上时,缓存才会生效。缓存匹配要求提问的上下文与被缓存的问题**完全一致**(包括标点符号)。此外,该模式目前仅支持非流式及假流式传输
87
+
88
+ **Q: 新版本增加的并发缓存功能会增加 gemini 配额的使用量吗?**
89
+
90
+ **A: 不会**。因为默认情况下该功能是关闭的。只有当你主动将并发数 `CONCURRENT_REQUESTS` 设置为大于 1 的数值时,才会实际发起并发请求,这才会消耗更多配额。
91
+
92
+ **Q: 如何使用并发缓存功能?**
93
+
94
+ **A:** 修改并发请求数,使其等于你想在一次用户提��中同时向 Gemini 发送的请求数量(例如设置为 `3`)。
95
+
96
+ 这样设置后,如果一次并发请求中收到了多个成功的响应,除了第一个返回给用户外,其他的就会被缓存起来。
97
+
98
+ ### 🎭 伪装信息
99
+
100
+ * **作用:** 在发送给 Gemini 的消息中添加一段随机生成的、无意义的字符串,用于“伪装”请求,可能有助于防止被识别为自动化程序。**默认开启**。
101
+
102
+ * **注意:** 如果使用非 SillyTavern 的其余客户端 (例如 cherryStudio ),请**关闭**该功能
103
+
104
+ ### 🌐 联网模式
105
+
106
+ * **作用:** 让 Gemini 模型能够利用搜索工具进行联网搜索,以回答需要最新信息或超出其知识库范围的问题。
107
+
108
+ * **如何使用:**
109
+
110
+ 在客户端请求时,选择模型名称带有 `-search` 后缀的模型(例如 `gemini-2.5-pro-search`,具体可用模型请通过 `/v1/models` 接口查询)。
111
+
112
+
113
+ ### 🚦 速率限制和防滥用:
114
+
115
+ * 通过环境变量自定义限制:
116
+ * `MAX_REQUESTS_PER_MINUTE`:每分钟最大请求数(默认 30)。
117
+ * `MAX_REQUESTS_PER_DAY_PER_IP`:每天每个 IP 最大请求数(默认 600)。
118
+ * 超过速率限制时返回 429 错误。
119
+
120
+ # 赞助商
121
+ [![edgeone logo](https://edgeone.ai/_next/static/media/headLogo.daeb48ad.png?auto=format&fit=max&w=384)](https://edgeone.ai)
122
+ CDN acceleration and security protection for this project are sponsored by Tencent EdgeOne.
123
+ 本项目的CDN加速及安全防护由腾讯EdgeOne赞助支持。
app/__init__.py ADDED
File without changes
app/api/__init__.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ from app.api.routes import router, init_router
2
+ from app.api.dashboard import dashboard_router, init_dashboard_router
3
+
4
+ __all__ = [
5
+ 'router',
6
+ 'init_router',
7
+ 'dashboard_router',
8
+ 'init_dashboard_router'
9
+ ]
app/api/dashboard.py ADDED
@@ -0,0 +1,830 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, HTTPException, Depends
2
+ from datetime import datetime, timedelta
3
+ import time
4
+ import asyncio
5
+ import random
6
+ import threading
7
+ from app.utils import (
8
+ log_manager,
9
+ ResponseCacheManager,
10
+ ActiveRequestsManager,
11
+ clean_expired_stats
12
+ )
13
+ import app.config.settings as settings
14
+ import app.vertex.config as app_config
15
+ from app.services import GeminiClient
16
+ from app.utils.auth import verify_web_password
17
+ from app.utils.maintenance import api_call_stats_clean
18
+ from app.utils.logging import log, vertex_log_manager
19
+ from app.config.persistence import save_settings
20
+ from app.utils.stats import api_stats_manager
21
+ from typing import List
22
+ import json
23
+
24
+ # Import necessary components for Google Credentials JSON update
25
+ from app.vertex.credentials_manager import CredentialManager, parse_multiple_json_credentials
26
+
27
+ # 引入重新初始化vertex的函数
28
+ from app.vertex.vertex_ai_init import init_vertex_ai as re_init_vertex_ai_function, reset_global_fallback_client
29
+
30
+ # 创建路由器
31
+ dashboard_router = APIRouter(prefix="/api", tags=["dashboard"])
32
+
33
+ # 全局变量引用,将在init_dashboard_router中设置
34
+ key_manager = None
35
+ response_cache_manager = None
36
+ active_requests_manager = None
37
+ credential_manager = None # 添加全局credential_manager变量
38
+
39
+ # 用于存储API密钥检测的进度信息
40
+ api_key_test_progress = {
41
+ "is_running": False,
42
+ "completed": 0,
43
+ "total": 0,
44
+ "valid": 0,
45
+ "invalid": 0,
46
+ "is_completed": False
47
+ }
48
+
49
+ def init_dashboard_router(
50
+ key_mgr,
51
+ cache_mgr,
52
+ active_req_mgr,
53
+ cred_mgr=None # 添加credential_manager参数
54
+ ):
55
+ """初始化仪表盘路由器"""
56
+ global key_manager, response_cache_manager, active_requests_manager, credential_manager
57
+ key_manager = key_mgr
58
+ response_cache_manager = cache_mgr
59
+ active_requests_manager = active_req_mgr
60
+ credential_manager = cred_mgr # 保存credential_manager
61
+ return dashboard_router
62
+
63
+ async def run_blocking_init_vertex():
64
+ """Helper to run the init_vertex_ai function with the current credential_manager."""
65
+ try:
66
+ if credential_manager is None:
67
+ # 如果credential_manager为None,记录警告并创建一个新的实例
68
+ log('warning', "Credential Manager不存在,将创建一个新的实例用于初始化")
69
+ temp_credential_manager = CredentialManager()
70
+ credentials_count = temp_credential_manager.get_total_credentials()
71
+ log('info', f"临时Credential Manager已创建,包含{credentials_count}个凭证")
72
+
73
+ # 传递临时创建的credential_manager实例
74
+ success = await re_init_vertex_ai_function(credential_manager=temp_credential_manager)
75
+ else:
76
+ # 记录当前有多少凭证可用
77
+ credentials_count = credential_manager.get_total_credentials()
78
+ log('info', f"使用现有Credential Manager进行初始化,当前有{credentials_count}个凭证")
79
+
80
+ # 传递当前的credential_manager实例
81
+ success = await re_init_vertex_ai_function(credential_manager=credential_manager)
82
+
83
+ if success:
84
+ log('info', "异步重新执行 init_vertex_ai 成功,以响应 Google Credentials JSON 的更新。")
85
+ else:
86
+ log('warning', "异步重新执行 init_vertex_ai 失败或未完成,在 Google Credentials JSON 更新后。")
87
+ except Exception as e:
88
+ log('error', f"执行 run_blocking_init_vertex 时出错: {e}")
89
+
90
+ @dashboard_router.get("/dashboard-data")
91
+ async def get_dashboard_data():
92
+ """获取仪表盘数据的API端点,用于动态刷新"""
93
+ # 先清理过期数据,确保统计数据是最新的
94
+ await api_stats_manager.maybe_cleanup()
95
+ await response_cache_manager.clean_expired() # 使用管理器清理缓存
96
+ active_requests_manager.clean_completed() # 使用管理器清理活跃请求
97
+
98
+ # 获取当前统计数据
99
+ now = datetime.now()
100
+
101
+ # 使用新的统计系统获取调用数据
102
+ last_24h_calls = api_stats_manager.get_calls_last_24h()
103
+ hourly_calls = api_stats_manager.get_calls_last_hour(now)
104
+ minute_calls = api_stats_manager.get_calls_last_minute(now)
105
+
106
+ # 获取时间序列数据
107
+ time_series_data, tokens_time_series = api_stats_manager.get_time_series_data(30, now)
108
+
109
+ # 获取API密钥使用统计
110
+ api_key_stats = api_stats_manager.get_api_key_stats(key_manager.api_keys)
111
+
112
+ # 根据ENABLE_VERTEX设置决定返回哪种日志
113
+ if settings.ENABLE_VERTEX:
114
+ recent_logs = vertex_log_manager.get_recent_logs(500) # 获取最近500条Vertex日志
115
+ else:
116
+ recent_logs = log_manager.get_recent_logs(500) # 获取最近500条普通日志
117
+
118
+ # 获取缓存统计
119
+ total_cache = response_cache_manager.cur_cache_num
120
+
121
+ # 获取活跃请求统计
122
+ active_count = len(active_requests_manager.active_requests)
123
+ active_done = sum(1 for task in active_requests_manager.active_requests.values() if task.done())
124
+ active_pending = active_count - active_done
125
+
126
+ # 获取凭证数量
127
+ credentials_count = 0
128
+ if credential_manager is not None:
129
+ credentials_count = credential_manager.get_total_credentials()
130
+
131
+ # 返回JSON格式的数据
132
+ return {
133
+ "key_count": len(key_manager.api_keys),
134
+ "model_count": len(GeminiClient.AVAILABLE_MODELS),
135
+ "retry_count": settings.MAX_RETRY_NUM,
136
+ "credentials_count": credentials_count, # 添加凭证数量
137
+ "last_24h_calls": last_24h_calls,
138
+ "hourly_calls": hourly_calls,
139
+ "minute_calls": minute_calls,
140
+ "calls_time_series": time_series_data, # 添加API调用时间序列
141
+ "tokens_time_series": tokens_time_series, # 添加Token使用时间序列
142
+ "current_time": datetime.now().strftime('%H:%M:%S'),
143
+ "logs": recent_logs,
144
+ "api_key_stats": api_key_stats,
145
+ # 添加配置信息
146
+ "max_requests_per_minute": settings.MAX_REQUESTS_PER_MINUTE,
147
+ "max_requests_per_day_per_ip": settings.MAX_REQUESTS_PER_DAY_PER_IP,
148
+ # 添加版本信息
149
+ "local_version": settings.version["local_version"],
150
+ "remote_version": settings.version["remote_version"],
151
+ "has_update": settings.version["has_update"],
152
+ # 添加流式响应配置
153
+ "fake_streaming": settings.FAKE_STREAMING,
154
+ "fake_streaming_interval": settings.FAKE_STREAMING_INTERVAL,
155
+ # 添加随机字符串配置
156
+ "random_string": settings.RANDOM_STRING,
157
+ "random_string_length": settings.RANDOM_STRING_LENGTH,
158
+ # 添加联网搜索配置
159
+ "search_mode": settings.search["search_mode"],
160
+ "search_prompt": settings.search["search_prompt"],
161
+ # 添加缓存信息
162
+ "cache_entries": total_cache,
163
+ "cache_expiry_time": settings.CACHE_EXPIRY_TIME,
164
+ "max_cache_entries": settings.MAX_CACHE_ENTRIES,
165
+ # 添加活跃请求池信息
166
+ "active_count": active_count,
167
+ "active_done": active_done,
168
+ "active_pending": active_pending,
169
+ # 添加并发请求配置
170
+ "concurrent_requests": settings.CONCURRENT_REQUESTS,
171
+ "increase_concurrent_on_failure": settings.INCREASE_CONCURRENT_ON_FAILURE,
172
+ "max_concurrent_requests": settings.MAX_CONCURRENT_REQUESTS,
173
+ # 启用vertex
174
+ "enable_vertex": settings.ENABLE_VERTEX,
175
+ # 添加Vertex Express配置
176
+ "enable_vertex_express": settings.ENABLE_VERTEX_EXPRESS,
177
+ "vertex_express_api_key": bool(settings.VERTEX_EXPRESS_API_KEY), # 只返回是否设置的状态
178
+ "google_credentials_json": bool(settings.GOOGLE_CREDENTIALS_JSON), # 只返回是否设置的状态
179
+ # 添加最大重试次数
180
+ "max_retry_num": settings.MAX_RETRY_NUM,
181
+ # 添加空响应重试次数限制
182
+ "max_empty_responses": settings.MAX_EMPTY_RESPONSES,
183
+ }
184
+
185
+ @dashboard_router.post("/reset-stats")
186
+ async def reset_stats(password_data: dict):
187
+ """
188
+ 重置API调用统计数据
189
+
190
+ Args:
191
+ password_data (dict): 包含密码的字典
192
+
193
+ Returns:
194
+ dict: 操作结果
195
+ """
196
+ try:
197
+ if not isinstance(password_data, dict):
198
+ raise HTTPException(status_code=422, detail="请求体格式错误:应为JSON对象")
199
+
200
+ password = password_data.get("password")
201
+ if not password:
202
+ raise HTTPException(status_code=400, detail="缺少密码参数")
203
+
204
+ if not isinstance(password, str):
205
+ raise HTTPException(status_code=422, detail="密码参数类型错误:应为字符串")
206
+
207
+ if not verify_web_password(password):
208
+ raise HTTPException(status_code=401, detail="密码错误")
209
+
210
+ # 调用重置函数
211
+ await api_stats_manager.reset()
212
+
213
+ return {"status": "success", "message": "API调用统计数据已重置"}
214
+ except HTTPException:
215
+ raise
216
+ except Exception as e:
217
+ raise HTTPException(status_code=500, detail=f"重置失败:{str(e)}")
218
+
219
+ @dashboard_router.post("/update-config")
220
+ async def update_config(config_data: dict):
221
+ """
222
+ 更新配置项
223
+
224
+ Args:
225
+ config_data (dict): 包含配置项和密码的字典
226
+
227
+ Returns:
228
+ dict: 操作结果
229
+ """
230
+ try:
231
+ if not isinstance(config_data, dict):
232
+ raise HTTPException(status_code=422, detail="请求体格式错误:应为JSON对象")
233
+
234
+ password = config_data.get("password")
235
+ if not password:
236
+ raise HTTPException(status_code=400, detail="缺少密码参数")
237
+
238
+ if not isinstance(password, str):
239
+ raise HTTPException(status_code=422, detail="密码参数类型错误:应为字符串")
240
+
241
+ if not verify_web_password(password):
242
+ raise HTTPException(status_code=401, detail="密码错误")
243
+
244
+ # 获取要更新的配置项
245
+ config_key = config_data.get("key")
246
+ config_value = config_data.get("value")
247
+
248
+ if not config_key:
249
+ raise HTTPException(status_code=400, detail="缺少配置项键名")
250
+
251
+ # 根据配置项类型进行类型转换和验证
252
+ if config_key == "max_requests_per_minute":
253
+ try:
254
+ value = int(config_value)
255
+ if value <= 0:
256
+ raise ValueError("每分钟请求限制必须大于0")
257
+ settings.MAX_REQUESTS_PER_MINUTE = value
258
+ log('info', f"每分钟请求限制已更新为:{value}")
259
+ except ValueError as e:
260
+ raise HTTPException(status_code=422, detail=f"参数类型错误:{str(e)}")
261
+
262
+ elif config_key == "max_requests_per_day_per_ip":
263
+ try:
264
+ value = int(config_value)
265
+ if value <= 0:
266
+ raise ValueError("每IP每日请求限制必须大于0")
267
+ settings.MAX_REQUESTS_PER_DAY_PER_IP = value
268
+ log('info', f"每IP每日请求限制已更新为:{value}")
269
+ except ValueError as e:
270
+ raise HTTPException(status_code=422, detail=f"参数类型错误:{str(e)}")
271
+
272
+ elif config_key == "fake_streaming":
273
+ if not isinstance(config_value, bool):
274
+ raise HTTPException(status_code=422, detail="参数类型错误:应为布尔值")
275
+ settings.FAKE_STREAMING = config_value
276
+ log('info', f"假流式请求已更新为:{config_value}")
277
+
278
+ # 同步更新vertex配置中的假流式设置
279
+ try:
280
+ import app.vertex.config as vertex_config
281
+ vertex_config.FAKE_STREAMING_ENABLED = config_value # 直接更新全局变量
282
+ vertex_config.update_config('FAKE_STREAMING', config_value) # 同时调用更新函数
283
+ log('info', f"已同步更新Vertex中的假流式设置为:{config_value}")
284
+ except Exception as e:
285
+ log('warning', f"更新Vertex假流式设置时出错: {str(e)}")
286
+
287
+ elif config_key == "enable_vertex_express":
288
+ if not isinstance(config_value, bool):
289
+ raise HTTPException(status_code=422, detail="参数类型错误:应为布尔值")
290
+ settings.ENABLE_VERTEX_EXPRESS = config_value
291
+ log('info', f"Vertex Express已更新为:{config_value}")
292
+
293
+ elif config_key == "vertex_express_api_key":
294
+ if not isinstance(config_value, str):
295
+ raise HTTPException(status_code=422, detail="参数类型错误:应为字符串")
296
+
297
+ # 检查是否为空字符串或"true",如果是,则不更新
298
+ if not config_value or config_value.lower() == "true":
299
+ log('info', f"Vertex Express API Key未更新,因为值为空或为'true'")
300
+ else:
301
+ settings.VERTEX_EXPRESS_API_KEY = config_value
302
+ # 更新app_config中的API密钥列表
303
+ app_config.VERTEX_EXPRESS_API_KEY_VAL = [key.strip() for key in config_value.split(',') if key.strip()]
304
+ log('info', f"Vertex Express API Key已更新,共{len(app_config.VERTEX_EXPRESS_API_KEY_VAL)}个有效密钥")
305
+
306
+ # 尝试刷新模型配置
307
+ try:
308
+ from app.vertex.model_loader import refresh_models_config_cache
309
+ refresh_success = await refresh_models_config_cache()
310
+ if refresh_success:
311
+ log('info', "更新Express API Key后成功刷新模型配置")
312
+ else:
313
+ log('warning', "更新Express API Key后刷新模型配置失败,将使用默认模型或现有缓存")
314
+ except Exception as e:
315
+ log('warning', f"尝试刷新模型配置时出错: {str(e)}")
316
+
317
+ elif config_key == "fake_streaming_interval":
318
+ try:
319
+ value = float(config_value)
320
+ if value <= 0:
321
+ raise ValueError("假流式间隔必须大于0")
322
+ settings.FAKE_STREAMING_INTERVAL = value
323
+ log('info', f"假流式间隔已更新为:{value}")
324
+
325
+ # 同步更新vertex配置中的假流式间隔设置
326
+ try:
327
+ import app.vertex.config as vertex_config
328
+ vertex_config.update_config('FAKE_STREAMING_INTERVAL', value)
329
+ log('info', f"已同步更新Vertex中的假流式间隔设置为:{value}")
330
+ except Exception as e:
331
+ log('warning', f"更新Vertex假流式间隔设置时出错: {str(e)}")
332
+ except ValueError as e:
333
+ raise HTTPException(status_code=422, detail=f"参数类型错误:{str(e)}")
334
+
335
+ elif config_key == "random_string":
336
+ if not isinstance(config_value, bool):
337
+ raise HTTPException(status_code=422, detail="参数类型错误:应为布尔值")
338
+ settings.RANDOM_STRING = config_value
339
+ log('info', f"随机字符串已更新为:{config_value}")
340
+ elif config_key == "random_string_length":
341
+ try:
342
+ value = int(config_value)
343
+ if value <= 0:
344
+ raise ValueError("随机字符串长度必须大于0")
345
+ settings.RANDOM_STRING_LENGTH = value
346
+ log('info', f"随机字符串长度已更新为:{value}")
347
+ except ValueError as e:
348
+ raise HTTPException(status_code=422, detail=f"参数类型错误:{str(e)}")
349
+
350
+ elif config_key == "search_mode":
351
+ if not isinstance(config_value, bool):
352
+ raise HTTPException(status_code=422, detail="参数类型错误:应为布尔值")
353
+ settings.search["search_mode"] = config_value
354
+ log('info', f"联网搜索模式已更新为:{config_value}")
355
+
356
+ # 在切换search_mode时,重新获取一次可用模型列表
357
+ try:
358
+ # 重置密钥栈以确保随机性
359
+ key_manager._reset_key_stack()
360
+ # 获取一个随机API密钥
361
+ for key in key_manager.api_keys:
362
+ log('info', f"使用API密钥 {key[:8]}... 刷新可用模型列表")
363
+ # 使用随机密钥获取可用模型
364
+ all_models = await GeminiClient.list_available_models(key)
365
+ GeminiClient.AVAILABLE_MODELS = [model.replace("models/", "") for model in all_models]
366
+ if len(GeminiClient.AVAILABLE_MODELS) > 0:
367
+ log('info', f"可用模型列表已更新,当前模型数量:{len(GeminiClient.AVAILABLE_MODELS)}")
368
+ break
369
+ else:
370
+ log('warning', f"没有可用的API密钥,无法刷新可用模型列表")
371
+ except Exception as e:
372
+ log('warning', f"刷新可用模型列表时发生错误: {str(e)}")
373
+
374
+ elif config_key == "concurrent_requests":
375
+ try:
376
+ value = int(config_value)
377
+ if value <= 0:
378
+ raise ValueError("并发请求数必须大于0")
379
+ settings.CONCURRENT_REQUESTS = value
380
+ log('info', f"并发请求数已更新为:{value}")
381
+ except ValueError as e:
382
+ raise HTTPException(status_code=422, detail=f"参数类型错误:{str(e)}")
383
+
384
+ elif config_key == "increase_concurrent_on_failure":
385
+ try:
386
+ value = int(config_value)
387
+ if value < 0:
388
+ raise ValueError("失败时增加的并发数不能为负数")
389
+ settings.INCREASE_CONCURRENT_ON_FAILURE = value
390
+ log('info', f"失败时增加的并发数已更新为:{value}")
391
+ except ValueError as e:
392
+ raise HTTPException(status_code=422, detail=f"参数类型错误:{str(e)}")
393
+
394
+ elif config_key == "max_concurrent_requests":
395
+ try:
396
+ value = int(config_value)
397
+ if value <= 0:
398
+ raise ValueError("最大并发请求数必须大于0")
399
+ settings.MAX_CONCURRENT_REQUESTS = value
400
+ log('info', f"最大并发请求数已更新为:{value}")
401
+ except ValueError as e:
402
+ raise HTTPException(status_code=422, detail=f"参数类型错误:{str(e)}")
403
+
404
+ elif config_key == "enable_vertex":
405
+ if not isinstance(config_value, bool):
406
+ raise HTTPException(status_code=422, detail="参数类型错误:应为布尔值")
407
+ settings.ENABLE_VERTEX = config_value
408
+ log('info', f"Vertex AI 已更新为:{config_value}")
409
+
410
+ elif config_key == "google_credentials_json":
411
+ if not isinstance(config_value, str): # Allow empty string to clear
412
+ raise HTTPException(status_code=422, detail="参数类型错误:Google Credentials JSON 应为字符串")
413
+
414
+ # 检查是否为空字符串或"true",如果是,则不更新
415
+ if not config_value or config_value.lower() == "true":
416
+ log('info', f"Google Credentials JSON未更新,因为值为空或为'true'")
417
+ save_settings() # 仍然保存其他可能的设置更改
418
+ return {"status": "success", "message": f"配置项 {config_key} 未更新,值为空或为'true'"}
419
+
420
+ # Validate JSON structure if not empty
421
+ if config_value:
422
+ try:
423
+ # Attempt to parse as single or multiple JSONs
424
+ # parse_multiple_json_credentials logs errors if parsing fails but returns list.
425
+ temp_parsed = parse_multiple_json_credentials(config_value)
426
+ # If parse_multiple_json_credentials returns an empty list for a non-empty string,
427
+ # it means it didn't find any valid top-level JSON objects as per its logic.
428
+ # We can do an additional check for a single valid JSON object.
429
+ if not temp_parsed: # and config_value.strip(): # ensure non-empty string before json.loads
430
+ try:
431
+ # This is a stricter check. If parse_multiple_json_credentials, which is more lenient,
432
+ # failed to find anything, and this also fails, then it's likely malformed.
433
+ json.loads(config_value) # Try parsing as a single JSON object
434
+ # If this succeeds, it implies the string IS a valid single JSON,
435
+ # but not in the multi-JSON format parse_multiple_json_credentials might be looking for initially.
436
+ # parse_multiple_json_credentials will be called again later and should handle it.
437
+ except json.JSONDecodeError:
438
+ # This specific error means it's not even a valid single JSON.
439
+ raise HTTPException(status_code=422, detail="Google Credentials JSON 格式无效。它既不是有效的单个JSON对象,也不是逗号分隔的多个JSON对象。")
440
+ except HTTPException: # Re-raise if it's already an HTTPException from inner check
441
+ raise
442
+ except Exception as e: # Catch any other error during this pre-check
443
+ # This might catch errors if parse_multiple_json_credentials itself had an unexpected issue
444
+ # not related to JSONDecodeError but still an error.
445
+ raise HTTPException(status_code=422, detail=f"Google Credentials JSON 预检查失败: {str(e)}")
446
+
447
+ settings.GOOGLE_CREDENTIALS_JSON = config_value
448
+ log('info', "Google Credentials JSON 设置已更新 (内容未记录)。")
449
+
450
+ # Reset global fallback client first
451
+ reset_global_fallback_client()
452
+
453
+ # Clear previously loaded JSON string credentials from manager
454
+ if credential_manager is not None:
455
+ cleared_count = credential_manager.clear_json_string_credentials()
456
+ log('info', f"从 CredentialManager 中清除了 {cleared_count} 个先前由 JSON 字符串加载的凭据。")
457
+
458
+ if config_value: # If new JSON string is provided
459
+ parsed_json_objects = parse_multiple_json_credentials(config_value)
460
+ if parsed_json_objects:
461
+ loaded_count = credential_manager.load_credentials_from_json_list(parsed_json_objects)
462
+ if loaded_count > 0:
463
+ log('info', f"从更新的 Google Credentials JSON 中加载了 {loaded_count} 个凭据到 CredentialManager。")
464
+ else:
465
+ log('warning', "尝试加载Google Credentials JSON凭据失败,没有凭据被成功加载。")
466
+ else:
467
+ # 尝试作为单个JSON对象加载
468
+ try:
469
+ single_cred = json.loads(config_value)
470
+ if credential_manager.add_credential_from_json(single_cred):
471
+ log('info', "作为单个JSON对象成功加载了一个凭据。")
472
+ else:
473
+ log('warning', "作为单个JSON对象加载凭据失败。")
474
+ except json.JSONDecodeError:
475
+ log('warning', "Google Credentials JSON无法作为JSON对象解析。")
476
+ except Exception as e:
477
+ log('warning', f"尝试加载单个JSON凭据时出错: {str(e)}")
478
+ else:
479
+ log('info', "Google Credentials JSON 已被清空。CredentialManager 中来自 JSON 字符串的凭据已被移除。")
480
+
481
+ # 检查凭证是否存在
482
+ if credential_manager.get_total_credentials() == 0:
483
+ log('warning', "警告:当前没有可用的凭证。Vertex AI功能可能无法正常工作。")
484
+ else:
485
+ log('warning', "CredentialManager未初始化,无法加载Google Credentials JSON。")
486
+
487
+ # Save all settings changes
488
+ save_settings() # Moved save_settings here to ensure it's called for this key
489
+
490
+ # Trigger re-initialization of Vertex AI (which can re-init the global client)
491
+ try:
492
+ # 检查credential_manager是否可用
493
+ if credential_manager is None:
494
+ log('warning', "重新初始化Vertex AI时发现credential_manager为None")
495
+ else:
496
+ log('info', f"开始重新初始化Vertex AI,当前凭证数: {credential_manager.get_total_credentials()}")
497
+
498
+ # 调用run_blocking_init_vertex
499
+ await run_blocking_init_vertex()
500
+ log('info', "Vertex AI服务重新初始化完成")
501
+
502
+ # 显式刷新模型配置缓存
503
+ from app.vertex.model_loader import refresh_models_config_cache
504
+ refresh_success = await refresh_models_config_cache()
505
+ if refresh_success:
506
+ log('info', "成功刷新模型配置缓存")
507
+ else:
508
+ log('warning', "刷新模型配置缓存失败,将使用默认模型或现有缓存")
509
+ except Exception as e:
510
+ log('error', f"重新初始化Vertex AI服务时出错: {str(e)}")
511
+
512
+ elif config_key == "max_retry_num":
513
+ try:
514
+ value = int(config_value)
515
+ if value <= 0:
516
+ raise ValueError("最大重试次数必须大于0")
517
+ settings.MAX_RETRY_NUM = value
518
+ log('info', f"最大重试次数已更新为:{value}")
519
+ except ValueError as e:
520
+ raise HTTPException(status_code=422, detail=f"参数类型错误:{str(e)}")
521
+
522
+ elif config_key == "search_prompt":
523
+ if not isinstance(config_value, str):
524
+ raise HTTPException(status_code=422, detail="参数类型错误:应为字符串")
525
+ settings.search["search_prompt"] = config_value
526
+ log('info', f"联网搜索提示已更新为:{config_value}")
527
+
528
+ elif config_key == "gemini_api_keys":
529
+ if not isinstance(config_value, str):
530
+ raise HTTPException(status_code=422, detail="参数类型错误:API密钥应为逗号分隔的字符串")
531
+
532
+ # 分割并清理API密钥
533
+ new_keys = [key.strip() for key in config_value.split(',') if key.strip()]
534
+ if not new_keys:
535
+ raise HTTPException(status_code=400, detail="未提供有效的API密钥")
536
+
537
+ # 添加到现有的API密钥字符串中
538
+ current_keys = settings.GEMINI_API_KEYS.split(',') if settings.GEMINI_API_KEYS else []
539
+ current_keys = [key.strip() for key in current_keys if key.strip()]
540
+
541
+ # 合并新旧密钥并去重
542
+ all_keys = list(set(current_keys + new_keys))
543
+ settings.GEMINI_API_KEYS = ','.join(all_keys)
544
+
545
+ # 计算新添加的密钥数量
546
+ added_key_count = 0
547
+ for key in new_keys:
548
+ if key not in key_manager.api_keys:
549
+ key_manager.api_keys.append(key)
550
+ added_key_count += 1
551
+
552
+ # 重置密钥栈
553
+ key_manager._reset_key_stack()
554
+
555
+ # 如果可用模型为空,尝试获取模型列表
556
+ if not GeminiClient.AVAILABLE_MODELS:
557
+ try:
558
+ # 使用新添加的密钥之一尝试获取可用模型
559
+ for key in new_keys:
560
+ log('info', f"使用新添加的API密钥 {key[:8]}... 获取可用模型列表")
561
+ all_models = await GeminiClient.list_available_models(key)
562
+ GeminiClient.AVAILABLE_MODELS = [model.replace("models/", "") for model in all_models]
563
+ if GeminiClient.AVAILABLE_MODELS:
564
+ log('info', f"成功获取可用模型列表,共 {len(GeminiClient.AVAILABLE_MODELS)} 个模型")
565
+ break
566
+ except Exception as e:
567
+ log('warning', f"获取可用模型列表时发生错误: {str(e)}")
568
+
569
+ log('info', f"已添加 {added_key_count} 个新API密钥,当前共有 {len(key_manager.api_keys)} 个")
570
+
571
+ elif config_key == "max_empty_responses":
572
+ try:
573
+ value = int(config_value)
574
+ if value < 0: # 通常至少为0或1,根据实际需求调整
575
+ raise ValueError("空响应重试次数不能为负数")
576
+ settings.MAX_EMPTY_RESPONSES = value
577
+ log('info', f"空响应重试次数已更新为:{value}")
578
+ except ValueError as e:
579
+ raise HTTPException(status_code=422, detail=f"参数类型错误:{str(e)}")
580
+
581
+ else:
582
+ raise HTTPException(status_code=400, detail=f"不支持的配置项:{config_key}")
583
+ save_settings()
584
+ return {"status": "success", "message": f"配置项 {config_key} 已更新"}
585
+ except HTTPException:
586
+ raise
587
+ except Exception as e:
588
+ raise HTTPException(status_code=500, detail=f"更新失败:{str(e)}")
589
+
590
+ @dashboard_router.post("/test-api-keys")
591
+ async def test_api_keys(password_data: dict):
592
+ """
593
+ 测试所有API密��的有效性
594
+
595
+ Args:
596
+ password_data (dict): 包含密码的字典
597
+
598
+ Returns:
599
+ dict: 操作结果
600
+ """
601
+ try:
602
+ if not isinstance(password_data, dict):
603
+ raise HTTPException(status_code=422, detail="请求体格式错误:应为JSON对象")
604
+
605
+ password = password_data.get("password")
606
+ if not password:
607
+ raise HTTPException(status_code=400, detail="缺少密码参数")
608
+
609
+ if not isinstance(password, str):
610
+ raise HTTPException(status_code=422, detail="密码参数类型错误:应为字符串")
611
+
612
+ if not verify_web_password(password):
613
+ raise HTTPException(status_code=401, detail="密码错误")
614
+
615
+ # 检查是否已经有测试在运行
616
+ if api_key_test_progress["is_running"]:
617
+ raise HTTPException(status_code=409, detail="已有API密钥检测正在进行中")
618
+
619
+ # 获取有效密钥列表
620
+ valid_keys = key_manager.api_keys.copy()
621
+
622
+ # 启动异步测试
623
+ threading.Thread(
624
+ target=start_api_key_test_in_thread,
625
+ args=(valid_keys,),
626
+ daemon=True
627
+ ).start()
628
+
629
+ return {"status": "success", "message": "API密钥检测已启动,将同时检测有效密钥和无效密钥"}
630
+ except HTTPException:
631
+ raise
632
+ except Exception as e:
633
+ raise HTTPException(status_code=500, detail=f"启动API密钥检测失败:{str(e)}")
634
+
635
+ @dashboard_router.get("/test-api-keys/progress")
636
+ async def get_test_api_keys_progress():
637
+ """
638
+ 获取API密钥检测进度
639
+
640
+ Returns:
641
+ dict: 进度信息
642
+ """
643
+ return api_key_test_progress
644
+
645
+ def check_api_key_in_thread(key):
646
+ """在线程中检查单个API密钥的有效性"""
647
+ loop = asyncio.new_event_loop()
648
+ asyncio.set_event_loop(loop)
649
+ try:
650
+ is_valid = loop.run_until_complete(test_api_key(key))
651
+ if is_valid:
652
+ log('info', f"API密钥 {key[:8]}... 有效")
653
+ return key, True
654
+ else:
655
+ log('warning', f"API密钥 {key[:8]}... 无效")
656
+ return key, False
657
+ finally:
658
+ loop.close()
659
+
660
+ async def test_api_key(key):
661
+ """测试单个API密钥是否有效"""
662
+ try:
663
+ # 尝试列出可用模型来检查API密钥是否有效
664
+ all_models = await GeminiClient.list_available_models(key)
665
+ return len(all_models) > 0
666
+ except Exception as e:
667
+ log('error', f"测试API密钥 {key[:8]}... 时出错: {str(e)}")
668
+ return False
669
+
670
+ def start_api_key_test_in_thread(keys):
671
+ """在线程中启动API密钥检测过程"""
672
+ # 重置进度信息
673
+ api_key_test_progress.update({
674
+ "is_running": True,
675
+ "completed": 0,
676
+ "total": 0, # 稍后会更新
677
+ "valid": 0,
678
+ "invalid": 0,
679
+ "is_completed": False
680
+ })
681
+
682
+ try:
683
+ # 获取所有需要检测的密钥(包括当前GEMINI_API_KEYS和INVALID_API_KEYS)
684
+ current_keys = keys
685
+
686
+ # 获取当前无效密钥
687
+ invalid_api_keys = settings.INVALID_API_KEYS.split(',') if settings.INVALID_API_KEYS else []
688
+ invalid_api_keys = [key.strip() for key in invalid_api_keys if key.strip()]
689
+
690
+ # 合并所有需要测试的密钥,去重
691
+ all_keys_to_test = list(set(current_keys + invalid_api_keys))
692
+
693
+ # 更新总数
694
+ api_key_test_progress["total"] = len(all_keys_to_test)
695
+
696
+ # 创建有效和无效密钥列表
697
+ valid_keys = []
698
+ invalid_keys = []
699
+
700
+ # 检查每个密钥
701
+ for key in all_keys_to_test:
702
+ # 检查密钥
703
+ _, is_valid = check_api_key_in_thread(key)
704
+
705
+ # 更新进度
706
+ api_key_test_progress["completed"] += 1
707
+
708
+ # 将密钥添加到相应列表
709
+ if is_valid:
710
+ valid_keys.append(key)
711
+ api_key_test_progress["valid"] += 1
712
+ else:
713
+ invalid_keys.append(key)
714
+ api_key_test_progress["invalid"] += 1
715
+
716
+ # 更新全局密钥列表
717
+ key_manager.api_keys = valid_keys
718
+
719
+ # 更新设置中的有效和无效密钥
720
+ settings.GEMINI_API_KEYS = ','.join(valid_keys)
721
+ settings.INVALID_API_KEYS = ','.join(invalid_keys)
722
+
723
+ # 保存设置
724
+ save_settings()
725
+
726
+ # 重置密钥栈
727
+ key_manager._reset_key_stack()
728
+
729
+ log('info', f"API密钥检测完成。有效密钥: {len(valid_keys)},无效密钥: {len(invalid_keys)}")
730
+ except Exception as e:
731
+ log('error', f"API密钥检测过程中发生错误: {str(e)}")
732
+ finally:
733
+ # 标记检测完成
734
+ api_key_test_progress.update({
735
+ "is_running": False,
736
+ "is_completed": True
737
+ })
738
+
739
+ @dashboard_router.post("/clear-invalid-api-keys")
740
+ async def clear_invalid_api_keys(password_data: dict):
741
+ """
742
+ 清除所有失效的API密钥
743
+
744
+ Args:
745
+ password_data (dict): 包含密码的字典
746
+
747
+ Returns:
748
+ dict: 操作结果
749
+ """
750
+ try:
751
+ if not isinstance(password_data, dict):
752
+ raise HTTPException(status_code=422, detail="请求体格式错误:应为JSON对象")
753
+
754
+ password = password_data.get("password")
755
+ if not password:
756
+ raise HTTPException(status_code=400, detail="缺少密码参数")
757
+
758
+ if not isinstance(password, str):
759
+ raise HTTPException(status_code=422, detail="密码参数类型错误:应为字符串")
760
+
761
+ if not verify_web_password(password):
762
+ raise HTTPException(status_code=401, detail="密码错误")
763
+
764
+ # 获取当前无效密钥数量
765
+ current_invalid_keys = settings.INVALID_API_KEYS.split(',') if settings.INVALID_API_KEYS else []
766
+ current_invalid_keys = [key.strip() for key in current_invalid_keys if key.strip()]
767
+ invalid_count = len(current_invalid_keys)
768
+
769
+ if invalid_count == 0:
770
+ return {"status": "success", "message": "没有失效的API密钥需要清除"}
771
+
772
+ # 清除无效密钥
773
+ settings.INVALID_API_KEYS = ""
774
+ save_settings()
775
+
776
+ log('info', f"已清除 {invalid_count} 个失效的API密钥")
777
+
778
+ return {
779
+ "status": "success",
780
+ "message": f"已成功清除 {invalid_count} 个失效的API密钥"
781
+ }
782
+ except HTTPException:
783
+ raise
784
+ except Exception as e:
785
+ raise HTTPException(status_code=500, detail=f"清除失效API密钥失败:{str(e)}")
786
+
787
+ @dashboard_router.post("/export-valid-api-keys")
788
+ async def export_valid_api_keys(password_data: dict):
789
+ """
790
+ 输出所有有效的API密钥
791
+
792
+ Args:
793
+ password_data (dict): 包含密码的字典
794
+
795
+ Returns:
796
+ dict: 操作结果,包含有效密钥列表
797
+ """
798
+ try:
799
+ if not isinstance(password_data, dict):
800
+ raise HTTPException(status_code=422, detail="请求体格式错误:应为JSON对象")
801
+
802
+ password = password_data.get("password")
803
+ if not password:
804
+ raise HTTPException(status_code=400, detail="缺少密码参数")
805
+
806
+ if not isinstance(password, str):
807
+ raise HTTPException(status_code=422, detail="密码参数类型错误:应为字符串")
808
+
809
+ if not verify_web_password(password):
810
+ raise HTTPException(status_code=401, detail="密码错误")
811
+
812
+ # 获取当前有效密钥
813
+ valid_keys = key_manager.api_keys.copy()
814
+
815
+ if not valid_keys:
816
+ return {"status": "success", "message": "当前没有有效的API密钥", "keys": []}
817
+
818
+ # 直接返回完整的密钥列表
819
+ log('info', f"用户导出了 {len(valid_keys)} 个有效API密钥")
820
+
821
+ return {
822
+ "status": "success",
823
+ "message": f"成功获取 {len(valid_keys)} 个有效API密钥",
824
+ "keys": valid_keys,
825
+ "count": len(valid_keys)
826
+ }
827
+ except HTTPException:
828
+ raise
829
+ except Exception as e:
830
+ raise HTTPException(status_code=500, detail=f"获取有效API密钥失败:{str(e)}")
app/api/nonstream_handlers.py ADDED
@@ -0,0 +1,577 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ from fastapi import HTTPException, Request
3
+ from fastapi.responses import StreamingResponse, Response
4
+ from app.models.schemas import ChatCompletionRequest
5
+ from app.services import GeminiClient
6
+ from app.utils import update_api_call_stats
7
+ from app.utils.error_handling import handle_gemini_error
8
+ from app.utils.logging import log
9
+ import app.config.settings as settings
10
+ from typing import Literal
11
+ from app.utils.response import gemini_from_text, openAI_from_Gemini, openAI_from_text
12
+ from app.utils.stats import get_api_key_usage
13
+
14
+
15
+ # 非流式请求处理函数
16
+ async def process_nonstream_request(
17
+ chat_request: ChatCompletionRequest,
18
+ contents,
19
+ system_instruction,
20
+ current_api_key: str,
21
+ response_cache_manager,
22
+ safety_settings,
23
+ safety_settings_g2,
24
+ cache_key: str
25
+ ):
26
+ """处理非流式API请求"""
27
+ gemini_client = GeminiClient(current_api_key)
28
+ # 创建调用 Gemini API 的主任务
29
+ gemini_task = asyncio.create_task(
30
+ gemini_client.complete_chat(
31
+ chat_request,
32
+ contents,
33
+ safety_settings_g2 if 'gemini-2.5' in chat_request.model else safety_settings,
34
+ system_instruction
35
+ )
36
+ )
37
+ # 使用 shield 保护任务不被外部轻易取消
38
+ shielded_gemini_task = asyncio.shield(gemini_task)
39
+
40
+ try:
41
+ # 等待受保护的 API 调用任务完成
42
+ response_content = await shielded_gemini_task
43
+ response_content.set_model(chat_request.model)
44
+
45
+ # 检查响应内容是否为空
46
+ if not response_content or (not response_content.text and not response_content.function_call):
47
+ log('warning', f"API密钥 {current_api_key[:8]}... 返回空响应",
48
+ extra={'key': current_api_key[:8], 'request_type': 'non-stream', 'model': chat_request.model})
49
+ return "empty"
50
+
51
+ # 缓存响应结果
52
+ await response_cache_manager.store(cache_key, response_content)
53
+ # 更新 API 调用统计
54
+ await update_api_call_stats(settings.api_call_stats, endpoint=current_api_key, model=chat_request.model,token=response_content.total_token_count)
55
+
56
+ return "success"
57
+
58
+ except Exception as e:
59
+ # 处理 API 调用过程中可能发生的任何异常
60
+ handle_gemini_error(e, current_api_key)
61
+ return "error"
62
+
63
+
64
+ # 带保活功能的非流式请求处理函数
65
+ async def process_nonstream_request_with_keepalive(
66
+ chat_request: ChatCompletionRequest,
67
+ contents,
68
+ system_instruction,
69
+ current_api_key: str,
70
+ response_cache_manager,
71
+ safety_settings,
72
+ safety_settings_g2,
73
+ cache_key: str,
74
+ keepalive_interval: float = 30.0 # 保活间隔,默认30秒
75
+ ):
76
+ """处理非流式API请求,带TCP保活功能"""
77
+ gemini_client = GeminiClient(current_api_key)
78
+
79
+ # 创建调用 Gemini API 的主任务
80
+ gemini_task = asyncio.create_task(
81
+ gemini_client.complete_chat(
82
+ chat_request,
83
+ contents,
84
+ safety_settings_g2 if 'gemini-2.5' in chat_request.model else safety_settings,
85
+ system_instruction
86
+ )
87
+ )
88
+
89
+ # 创建保活任务
90
+ keepalive_task = asyncio.create_task(
91
+ send_keepalive_messages(keepalive_interval)
92
+ )
93
+
94
+ try:
95
+ # 等待Gemini任务完成
96
+ response_content = await gemini_task
97
+
98
+ # 取消保活任务
99
+ keepalive_task.cancel()
100
+
101
+ response_content.set_model(chat_request.model)
102
+
103
+ # 检查响应内容是否为空
104
+ if not response_content or (not response_content.text and not response_content.function_call):
105
+ log('warning', f"API密钥 {current_api_key[:8]}... 返回空响应",
106
+ extra={'key': current_api_key[:8], 'request_type': 'non-stream', 'model': chat_request.model})
107
+ return "empty"
108
+
109
+ # 缓存响应结果
110
+ await response_cache_manager.store(cache_key, response_content)
111
+ # 更新 API 调用统计
112
+ await update_api_call_stats(settings.api_call_stats, endpoint=current_api_key, model=chat_request.model,token=response_content.total_token_count)
113
+
114
+ return "success"
115
+
116
+ except Exception as e:
117
+ # 取消保活任务
118
+ keepalive_task.cancel()
119
+ # 处理 API 调用过程中可能发生的任何异常
120
+ handle_gemini_error(e, current_api_key)
121
+ return "error"
122
+
123
+
124
+ # 简化的保活功能 - 在等待期间发送换行符
125
+ async def process_nonstream_request_with_simple_keepalive(
126
+ chat_request: ChatCompletionRequest,
127
+ contents,
128
+ system_instruction,
129
+ current_api_key: str,
130
+ response_cache_manager,
131
+ safety_settings,
132
+ safety_settings_g2,
133
+ cache_key: str,
134
+ keepalive_interval: float = 30.0 # 保活间隔,默认30秒
135
+ ):
136
+ """处理非流式API请求,带简化TCP保活功能"""
137
+ gemini_client = GeminiClient(current_api_key)
138
+
139
+ # 创建调用 Gemini API 的��任务
140
+ gemini_task = asyncio.create_task(
141
+ gemini_client.complete_chat(
142
+ chat_request,
143
+ contents,
144
+ safety_settings_g2 if 'gemini-2.5' in chat_request.model else safety_settings,
145
+ system_instruction
146
+ )
147
+ )
148
+
149
+ # 创建保活任务
150
+ keepalive_task = asyncio.create_task(
151
+ send_keepalive_messages(keepalive_interval)
152
+ )
153
+
154
+ try:
155
+ # 等待Gemini任务完成
156
+ response_content = await gemini_task
157
+
158
+ # 取消保活任务
159
+ keepalive_task.cancel()
160
+
161
+ response_content.set_model(chat_request.model)
162
+
163
+ # 检查响应内容是否为空
164
+ if not response_content or (not response_content.text and not response_content.function_call):
165
+ log('warning', f"API密钥 {current_api_key[:8]}... 返回空响应",
166
+ extra={'key': current_api_key[:8], 'request_type': 'non-stream', 'model': chat_request.model})
167
+ return "empty"
168
+
169
+ # 缓存响应结果
170
+ await response_cache_manager.store(cache_key, response_content)
171
+ # 更新 API 调用统计
172
+ await update_api_call_stats(settings.api_call_stats, endpoint=current_api_key, model=chat_request.model,token=response_content.total_token_count)
173
+
174
+ return "success"
175
+
176
+ except Exception as e:
177
+ # 取消保活任务
178
+ keepalive_task.cancel()
179
+ # 处理 API 调用过程中可能发生的任何异常
180
+ handle_gemini_error(e, current_api_key)
181
+ return "error"
182
+
183
+
184
+ async def send_keepalive_messages(interval: float):
185
+ """发送保活消息的任务"""
186
+ try:
187
+ while True:
188
+ await asyncio.sleep(interval)
189
+ # 发送换行符作为保活消息
190
+ # 注意:在非流式响应中,我们无法直接发送数据到客户端
191
+ except asyncio.CancelledError:
192
+ # 任务被取消时正常退出
193
+ pass
194
+ except Exception as e:
195
+ log('error', f"保活任务出错: {str(e)}",
196
+ extra={'request_type': 'non-stream', 'keepalive': True})
197
+
198
+
199
+ # 处理 route 中发起请求的函数
200
+ async def process_request(
201
+ chat_request,
202
+ key_manager,
203
+ response_cache_manager,
204
+ safety_settings,
205
+ safety_settings_g2,
206
+ cache_key: str
207
+ ):
208
+ """处理非流式请求"""
209
+ global current_api_key
210
+
211
+ format_type = getattr(chat_request, 'format_type', None)
212
+ if format_type and (format_type == "gemini"):
213
+ is_gemini = True
214
+ contents, system_instruction = None,None
215
+ else:
216
+ is_gemini = False
217
+ # 转换消息格式
218
+ contents, system_instruction = GeminiClient.convert_messages(GeminiClient, chat_request.messages,model=chat_request.model)
219
+
220
+ # 设置初始并发数
221
+ current_concurrent = settings.CONCURRENT_REQUESTS
222
+ max_retry_num = settings.MAX_RETRY_NUM
223
+
224
+ # 当前请求次数
225
+ current_try_num = 0
226
+
227
+ # 空响应计数
228
+ empty_response_count = 0
229
+
230
+ # 尝试使用不同API密钥,直到达到最大重试次数或空响应限制
231
+ while (current_try_num < max_retry_num) and (empty_response_count < settings.MAX_EMPTY_RESPONSES):
232
+ # 获取当前批次的密钥数量
233
+ batch_num = min(max_retry_num - current_try_num, current_concurrent)
234
+
235
+ # 获取当前批次的密钥
236
+ valid_keys = []
237
+ checked_keys = set() # 用于记录已检查过的密钥
238
+ all_keys_checked = False # 标记是否已检查所有密钥
239
+
240
+ # 尝试获取足够数量的有效密钥
241
+ while len(valid_keys) < batch_num:
242
+ api_key = await key_manager.get_available_key()
243
+ if not api_key:
244
+ break
245
+
246
+ # 如果这个密钥已经检查过,说明已经检查了所有密钥
247
+ if api_key in checked_keys:
248
+ all_keys_checked = True
249
+ break
250
+
251
+ checked_keys.add(api_key)
252
+ # 获取API密钥的调用次数
253
+ usage = await get_api_key_usage(settings.api_call_stats, api_key)
254
+ # 如果调用次数小于限制,则添加到有效密钥列表
255
+ if usage < settings.API_KEY_DAILY_LIMIT:
256
+ valid_keys.append(api_key)
257
+ else:
258
+ log('warning', f"API密钥 {api_key[:8]}... 已达到每日调用限制 ({usage}/{settings.API_KEY_DAILY_LIMIT})",
259
+ extra={'key': api_key[:8], 'request_type': 'non-stream', 'model': chat_request.model})
260
+
261
+ # 如果已经检查了所有密钥且没有找到有效密钥,则重置密钥栈
262
+ if all_keys_checked and not valid_keys:
263
+ log('warning', "所有API密钥已达到每日调用限制,重置密钥栈",
264
+ extra={'request_type': 'non-stream', 'model': chat_request.model})
265
+ key_manager._reset_key_stack()
266
+ # 重置后重新获取一个密钥
267
+ api_key = await key_manager.get_available_key()
268
+ if api_key:
269
+ valid_keys = [api_key]
270
+
271
+ # 如果没有获取到任何有效密钥,跳出循环
272
+ if not valid_keys:
273
+ break
274
+
275
+ # 更新当前尝试次数
276
+ current_try_num += len(valid_keys)
277
+
278
+ # 创建并发任务
279
+ tasks = []
280
+ tasks_map = {}
281
+ for api_key in valid_keys:
282
+ # 记录当前尝试的密钥信息
283
+ log('info', f"非流式请求开始,使用密钥: {api_key[:8]}...",
284
+ extra={'key': api_key[:8], 'request_type': 'non-stream', 'model': chat_request.model})
285
+
286
+ # 创建任务 - 根据配置决定是否使用保活功能
287
+ if settings.NONSTREAM_KEEPALIVE_ENABLED:
288
+ task = asyncio.create_task(
289
+ process_nonstream_request_with_simple_keepalive(
290
+ chat_request,
291
+ contents,
292
+ system_instruction,
293
+ api_key,
294
+ response_cache_manager,
295
+ safety_settings,
296
+ safety_settings_g2,
297
+ cache_key,
298
+ settings.NONSTREAM_KEEPALIVE_INTERVAL
299
+ )
300
+ )
301
+ else:
302
+ task = asyncio.create_task(
303
+ process_nonstream_request(
304
+ chat_request,
305
+ contents,
306
+ system_instruction,
307
+ api_key,
308
+ response_cache_manager,
309
+ safety_settings,
310
+ safety_settings_g2,
311
+ cache_key
312
+ )
313
+ )
314
+ tasks.append((api_key, task))
315
+ tasks_map[task] = api_key
316
+
317
+ # 等待所有任务完成或找到成功响应
318
+ success = False
319
+ while tasks and not success:
320
+ # 短时间等待任务完成
321
+ done, pending = await asyncio.wait(
322
+ [task for _, task in tasks],
323
+ return_when=asyncio.FIRST_COMPLETED
324
+ )
325
+ # 检查已完成的任务是否成功
326
+ for task in done:
327
+ api_key = tasks_map[task]
328
+ try:
329
+ status = task.result()
330
+ # 如果有成功响应内容
331
+ if status == "success" :
332
+ success = True
333
+ log('info', f"非流式请求成功",
334
+ extra={'key': api_key[:8],'request_type': 'non-stream', 'model': chat_request.model})
335
+ cached_response, cache_hit = await response_cache_manager.get_and_remove(cache_key)
336
+ if is_gemini :
337
+ return cached_response.data
338
+ else:
339
+ return openAI_from_Gemini(cached_response,stream=False)
340
+ elif status == "empty":
341
+ # 增加空响应计数
342
+ empty_response_count += 1
343
+ log('warning', f"空响应计数: {empty_response_count}/{settings.MAX_EMPTY_RESPONSES}",
344
+ extra={'key': api_key[:8], 'request_type': 'non-stream', 'model': chat_request.model})
345
+
346
+ except Exception as e:
347
+ handle_gemini_error(e, api_key)
348
+
349
+ # 更新任务列表,移除已完成的任务
350
+ tasks = [(k, t) for k, t in tasks if not t.done()]
351
+
352
+ # 如果当前批次没有成功响应,并且还有密钥可用,则继续尝试
353
+ if not success and valid_keys:
354
+ # 增加并发数,但不超过最大并发数
355
+ current_concurrent = min(current_concurrent + settings.INCREASE_CONCURRENT_ON_FAILURE, settings.MAX_CONCURRENT_REQUESTS)
356
+ log('info', f"所有并发请求失败或返回空响应,增加并发数至: {current_concurrent}",
357
+ extra={'request_type': 'non-stream', 'model': chat_request.model})
358
+
359
+ # 如果空响应次数达到限制,跳出循环,并返回酒馆正常响应(包含错误信息)
360
+ if empty_response_count >= settings.MAX_EMPTY_RESPONSES:
361
+ log('warning', f"空响应次数达到限制 ({empty_response_count}/{settings.MAX_EMPTY_RESPONSES}),停止轮询",
362
+ extra={'request_type': 'non-stream', 'model': chat_request.model})
363
+
364
+ if is_gemini :
365
+ return gemini_from_text(content="空响应次数达到上限\n请修改输入提示词",finish_reason="STOP",stream=False)
366
+ else:
367
+ return openAI_from_text(model=chat_request.model,content="空响应次数达到上限\n请修改输入提示词",finish_reason="stop",stream=False)
368
+
369
+ # 如果所有尝试都失败
370
+ log('error', "API key 替换失败,所有API key���已尝试,请重新配置或稍后重试", extra={'request_type': 'switch_key'})
371
+
372
+ if is_gemini:
373
+ return gemini_from_text(content="所有API密钥均请求失败\n具体错误请查看轮询日志",finish_reason="STOP",stream=False)
374
+ else:
375
+ return openAI_from_text(model=chat_request.model,content="所有API密钥均请求失败\n具体错误请查看轮询日志",finish_reason="stop",stream=False)
376
+
377
+ # raise HTTPException(status_code=500, detail=f"API key 替换失败,所有API key都已尝试,请重新配置或稍后重试")
378
+
379
+
380
+ # 处理带保活的非流式请求(使用流式响应)
381
+ async def process_nonstream_with_keepalive_stream(
382
+ chat_request,
383
+ key_manager,
384
+ response_cache_manager,
385
+ safety_settings,
386
+ safety_settings_g2,
387
+ cache_key: str,
388
+ is_gemini: bool
389
+ ):
390
+ """处理带保活的非流式请求,使用流式响应发送保活消息但最终返回非流式格式"""
391
+ from fastapi.responses import StreamingResponse
392
+ import json
393
+
394
+ async def keepalive_stream_generator():
395
+ """生成带保活的流式响应"""
396
+ try:
397
+ # 转换消息格式
398
+ format_type = getattr(chat_request, 'format_type', None)
399
+ if format_type and (format_type == "gemini"):
400
+ contents, system_instruction = None, None
401
+ else:
402
+ contents, system_instruction = GeminiClient.convert_messages(GeminiClient, chat_request.messages, model=chat_request.model)
403
+
404
+ # 设置初始并发数
405
+ current_concurrent = settings.CONCURRENT_REQUESTS
406
+ max_retry_num = settings.MAX_RETRY_NUM
407
+
408
+ # 当前请求次数
409
+ current_try_num = 0
410
+
411
+ # 空响应计数
412
+ empty_response_count = 0
413
+
414
+ # 尝试使用不同API密钥,直到达到最大重试次数或空响应限制
415
+ while (current_try_num < max_retry_num) and (empty_response_count < settings.MAX_EMPTY_RESPONSES):
416
+ # 获取当前批次的密钥数量
417
+ batch_num = min(max_retry_num - current_try_num, current_concurrent)
418
+
419
+ # 获取当前批次的密钥
420
+ valid_keys = []
421
+ checked_keys = set() # 用于记录已检查过的密钥
422
+ all_keys_checked = False # 标记是否已检查所有密钥
423
+
424
+ # 尝试获取足够数量的有效密钥
425
+ while len(valid_keys) < batch_num:
426
+ api_key = await key_manager.get_available_key()
427
+ if not api_key:
428
+ break
429
+
430
+ # 如果这个密钥已经检查过,说明已经检查了所有密钥
431
+ if api_key in checked_keys:
432
+ all_keys_checked = True
433
+ break
434
+
435
+ checked_keys.add(api_key)
436
+ # 获取API密钥的调用次数
437
+ usage = await get_api_key_usage(settings.api_call_stats, api_key)
438
+ # 如果调用次数小于限制,则添加到有效密钥列表
439
+ if usage < settings.API_KEY_DAILY_LIMIT:
440
+ valid_keys.append(api_key)
441
+ else:
442
+ log('warning', f"API密钥 {api_key[:8]}... 已达到每日调用限制 ({usage}/{settings.API_KEY_DAILY_LIMIT})",
443
+ extra={'key': api_key[:8], 'request_type': 'non-stream', 'model': chat_request.model})
444
+
445
+ # 如果已经检查了所有密钥且没有找到有效密钥,则重置密钥栈
446
+ if all_keys_checked and not valid_keys:
447
+ log('warning', "所有API密钥已达到每日调用限制,重置密钥栈",
448
+ extra={'request_type': 'non-stream', 'model': chat_request.model})
449
+ key_manager._reset_key_stack()
450
+ # 重置后重新获取一个密钥
451
+ api_key = await key_manager.get_available_key()
452
+ if api_key:
453
+ valid_keys = [api_key]
454
+
455
+ # 如果没有获取到任何有效密钥,跳出循环
456
+ if not valid_keys:
457
+ break
458
+
459
+ # 更新当前尝试次数
460
+ current_try_num += len(valid_keys)
461
+
462
+ # 创建并发任务
463
+ tasks = []
464
+ tasks_map = {}
465
+ for api_key in valid_keys:
466
+ # 记录当前尝试的密钥信息
467
+ log('info', f"非流式请求开始,使用密钥: {api_key[:8]}...",
468
+ extra={'key': api_key[:8], 'request_type': 'non-stream', 'model': chat_request.model})
469
+
470
+ # 创建任务
471
+ task = asyncio.create_task(
472
+ process_nonstream_request(
473
+ chat_request,
474
+ contents,
475
+ system_instruction,
476
+ api_key,
477
+ response_cache_manager,
478
+ safety_settings,
479
+ safety_settings_g2,
480
+ cache_key
481
+ )
482
+ )
483
+ tasks.append((api_key, task))
484
+ tasks_map[task] = api_key
485
+
486
+ # 等待所有任务完成或找到成功响应
487
+ success = False
488
+ keepalive_counter = 0
489
+ while tasks and not success:
490
+ # 短时间等待任务完成
491
+ done, pending = await asyncio.wait(
492
+ [task for _, task in tasks],
493
+ timeout=settings.NONSTREAM_KEEPALIVE_INTERVAL,
494
+ return_when=asyncio.FIRST_COMPLETED
495
+ )
496
+
497
+ # 如果没有任务完成,发送保活消息
498
+ if not done:
499
+ keepalive_counter += 1
500
+
501
+ # 发送简单的换行符作为保活消息
502
+ yield "\n"
503
+ continue
504
+
505
+ # 检查已完成的任务是否成功
506
+ for task in done:
507
+ api_key = tasks_map[task]
508
+ try:
509
+ status = task.result()
510
+ # 如果有成功响应内容
511
+ if status == "success" :
512
+ success = True
513
+ log('info', f"非流式请求成功",
514
+ extra={'key': api_key[:8],'request_type': 'non-stream', 'model': chat_request.model})
515
+ cached_response, cache_hit = await response_cache_manager.get_and_remove(cache_key)
516
+
517
+ # 发送最终的非流式响应
518
+ if is_gemini:
519
+ final_response = cached_response.data
520
+ else:
521
+ final_response = openAI_from_Gemini(cached_response, stream=False)
522
+
523
+ # 将非流式响应作为字符串发送
524
+ yield json.dumps(final_response, ensure_ascii=False)
525
+ return
526
+ elif status == "empty":
527
+ # 增加空响应计数
528
+ empty_response_count += 1
529
+ log('warning', f"空响应计数: {empty_response_count}/{settings.MAX_EMPTY_RESPONSES}",
530
+ extra={'key': api_key[:8], 'request_type': 'non-stream', 'model': chat_request.model})
531
+
532
+ except Exception as e:
533
+ handle_gemini_error(e, api_key)
534
+
535
+ # 更新任务列表,移除已完成的任务
536
+ tasks = [(k, t) for k, t in tasks if not t.done()]
537
+
538
+ # 如果当前批次没有成功响应,并且还有密钥可用,则继续尝试
539
+ if not success and valid_keys:
540
+ # 增加并发数,但不超过最大并发数
541
+ current_concurrent = min(current_concurrent + settings.INCREASE_CONCURRENT_ON_FAILURE, settings.MAX_CONCURRENT_REQUESTS)
542
+ log('info', f"所有并发请求失败或返回空响应,增加并发数至: {current_concurrent}",
543
+ extra={'request_type': 'non-stream', 'model': chat_request.model})
544
+
545
+ # 如果空响应次数达到限制,跳出循环,并返回酒馆正常响应(包含错误信息)
546
+ if empty_response_count >= settings.MAX_EMPTY_RESPONSES:
547
+ log('warning', f"空响应次数达到限制 ({empty_response_count}/{settings.MAX_EMPTY_RESPONSES}),停止轮询",
548
+ extra={'request_type': 'non-stream', 'model': chat_request.model})
549
+
550
+ if is_gemini :
551
+ error_response = gemini_from_text(content="空响应次数达到上限\n请修改输入提示词", finish_reason="STOP", stream=False)
552
+ else:
553
+ error_response = openAI_from_text(model=chat_request.model, content="空响应次数达到上限\n请修改输入提示词", finish_reason="stop", stream=False)
554
+
555
+ yield json.dumps(error_response, ensure_ascii=False)
556
+ return
557
+
558
+ # 如果所有尝试都失败
559
+ log('error', "API key 替换失败,所有API key都已尝试,请重新配置或稍后重试", extra={'request_type': 'switch_key'})
560
+
561
+ if is_gemini:
562
+ error_response = gemini_from_text(content="所有API密钥均请求失败\n具体错误请查看轮询日志", finish_reason="STOP", stream=False)
563
+ else:
564
+ error_response = openAI_from_text(model=chat_request.model, content="所有API密钥均请求失败\n具体错误请查看轮询日志", finish_reason="stop", stream=False)
565
+
566
+ yield json.dumps(error_response, ensure_ascii=False)
567
+
568
+ except Exception as e:
569
+ log('error', f"保活流式处理出错: {str(e)}",
570
+ extra={'request_type': 'non-stream', 'keepalive': True})
571
+ raise
572
+
573
+ # 返回流式响应,但使用application/json媒体类型
574
+ return StreamingResponse(
575
+ keepalive_stream_generator(),
576
+ media_type="application/json"
577
+ )
app/api/routes.py ADDED
@@ -0,0 +1,336 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from typing import Optional, Union
3
+ from fastapi import APIRouter, Body, HTTPException, Path, Query, Request, Depends, status, Header
4
+ from fastapi.responses import StreamingResponse
5
+ from app.services import GeminiClient
6
+ from app.utils import protect_from_abuse,generate_cache_key,openAI_from_text,log
7
+ from app.utils.response import openAI_from_Gemini
8
+ from app.utils.auth import custom_verify_password
9
+ from .stream_handlers import process_stream_request
10
+ from .nonstream_handlers import process_request, process_nonstream_with_keepalive_stream
11
+ from app.models.schemas import ChatCompletionRequest, ChatCompletionResponse, ModelList, AIRequest, ChatRequestGemini
12
+ import app.config.settings as settings
13
+ import asyncio
14
+ from app.vertex.routes import chat_api, models_api
15
+ from app.vertex.models import OpenAIRequest, OpenAIMessage
16
+
17
+ # 创建路由器
18
+ router = APIRouter()
19
+
20
+ # 全局变量引用 - 这些将在main.py中初始化并传递给路由
21
+ key_manager = None
22
+ response_cache_manager = None
23
+ active_requests_manager = None
24
+ safety_settings = None
25
+ safety_settings_g2 = None
26
+ current_api_key = None
27
+ FAKE_STREAMING = None
28
+ FAKE_STREAMING_INTERVAL = None
29
+ PASSWORD = None
30
+ MAX_REQUESTS_PER_MINUTE = None
31
+ MAX_REQUESTS_PER_DAY_PER_IP = None
32
+
33
+ # 初始化路由器的函数
34
+ def init_router(
35
+ _key_manager,
36
+ _response_cache_manager,
37
+ _active_requests_manager,
38
+ _safety_settings,
39
+ _safety_settings_g2,
40
+ _current_api_key,
41
+ _fake_streaming,
42
+ _fake_streaming_interval,
43
+ _password,
44
+ _max_requests_per_minute,
45
+ _max_requests_per_day_per_ip
46
+ ):
47
+ global key_manager, response_cache_manager, active_requests_manager
48
+ global safety_settings, safety_settings_g2, current_api_key
49
+ global FAKE_STREAMING, FAKE_STREAMING_INTERVAL
50
+ global PASSWORD, MAX_REQUESTS_PER_MINUTE, MAX_REQUESTS_PER_DAY_PER_IP
51
+
52
+ key_manager = _key_manager
53
+ response_cache_manager = _response_cache_manager
54
+ active_requests_manager = _active_requests_manager
55
+ safety_settings = _safety_settings
56
+ safety_settings_g2 = _safety_settings_g2
57
+ current_api_key = _current_api_key
58
+ FAKE_STREAMING = _fake_streaming
59
+ FAKE_STREAMING_INTERVAL = _fake_streaming_interval
60
+ PASSWORD = _password
61
+ MAX_REQUESTS_PER_MINUTE = _max_requests_per_minute
62
+ MAX_REQUESTS_PER_DAY_PER_IP = _max_requests_per_day_per_ip
63
+
64
+ async def verify_user_agent(request: Request):
65
+ if not settings.WHITELIST_USER_AGENT:
66
+ return
67
+ if request.headers.get("User-Agent") not in settings.WHITELIST_USER_AGENT:
68
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not allowed client")
69
+
70
+ # todo : 添加 gemini 支持(流式返回)
71
+ async def get_cache(cache_key,is_stream: bool,is_gemini=False):
72
+ # 检查缓存是否存在,如果存在,返回缓存
73
+ cached_response, cache_hit = await response_cache_manager.get_and_remove(cache_key)
74
+
75
+ if cache_hit and cached_response:
76
+ log('info', f"缓存命中: {cache_key[:8]}...",
77
+ extra={'request_type': 'non-stream', 'model': cached_response.model})
78
+
79
+ if is_gemini:
80
+ if is_stream:
81
+ data = f"data: {json.dumps(cached_response.data, ensure_ascii=False)}\n\n"
82
+ return StreamingResponse(data, media_type="text/event-stream")
83
+ else:
84
+ return cached_response.data
85
+
86
+
87
+ if is_stream:
88
+ chunk = openAI_from_Gemini(cached_response,stream=True)
89
+ return StreamingResponse(chunk, media_type="text/event-stream")
90
+ else:
91
+ return openAI_from_Gemini(cached_response,stream=False)
92
+
93
+ return None
94
+
95
+ @router.get("/aistudio/models",response_model=ModelList)
96
+ async def aistudio_list_models(_ = Depends(custom_verify_password),
97
+ _2 = Depends(verify_user_agent)):
98
+ if settings.WHITELIST_MODELS:
99
+ filtered_models = [model for model in GeminiClient.AVAILABLE_MODELS if model in settings.WHITELIST_MODELS]
100
+ else:
101
+ filtered_models = [model for model in GeminiClient.AVAILABLE_MODELS if model not in settings.BLOCKED_MODELS]
102
+ return ModelList(data=[{"id": model, "object": "model", "created": 1678888888, "owned_by": "organization-owner"} for model in filtered_models])
103
+
104
+ @router.get("/vertex/models",response_model=ModelList)
105
+ async def vertex_list_models(request: Request,
106
+ _ = Depends(custom_verify_password),
107
+ _2 = Depends(verify_user_agent)):
108
+ # 使用vertex/routes/models_api的实现
109
+ return await models_api.list_models(request, current_api_key)
110
+
111
+ # API路由
112
+ @router.get("/v1/models",response_model=ModelList)
113
+ @router.get("/models",response_model=ModelList)
114
+ async def list_models(request: Request,
115
+ _ = Depends(custom_verify_password),
116
+ _2 = Depends(verify_user_agent)):
117
+ if settings.ENABLE_VERTEX:
118
+ return await vertex_list_models(request, _, _2)
119
+ return await aistudio_list_models(_, _2)
120
+
121
+ @router.post("/aistudio/chat/completions", response_model=ChatCompletionResponse)
122
+ async def aistudio_chat_completions(
123
+ request: Union[ChatCompletionRequest, AIRequest],
124
+ http_request: Request,
125
+ _ = Depends(custom_verify_password),
126
+ _2 = Depends(verify_user_agent),
127
+ ):
128
+ format_type = getattr(request, 'format_type', None)
129
+ if format_type and (format_type == "gemini"):
130
+ is_gemini = True
131
+ else:
132
+ is_gemini = False
133
+
134
+ # 生成缓存键 - 用于匹配请求内容对应缓存
135
+ if settings.PRECISE_CACHE:
136
+ cache_key = generate_cache_key(request, is_gemini = is_gemini)
137
+ else:
138
+ cache_key = generate_cache_key(request, last_n_messages = settings.CALCULATE_CACHE_ENTRIES,is_gemini = is_gemini)
139
+
140
+ # 请求前基本检查
141
+ await protect_from_abuse(
142
+ http_request,
143
+ settings.MAX_REQUESTS_PER_MINUTE,
144
+ settings.MAX_REQUESTS_PER_DAY_PER_IP)
145
+
146
+ if request.model not in GeminiClient.AVAILABLE_MODELS:
147
+ log('error', "无效的模型",
148
+ extra={'model': request.model, 'status_code': 400})
149
+ raise HTTPException(
150
+ status_code=status.HTTP_400_BAD_REQUEST, detail="无效的模型")
151
+
152
+
153
+ # 记录请求缓存键信息
154
+ log('info', f"请求缓存键: {cache_key[:8]}...",
155
+ extra={'request_type': 'non-stream', 'model': request.model})
156
+
157
+ # 检查缓存是否存在,如果存在,返回缓存
158
+ cached_response = await get_cache(cache_key, is_stream = request.stream,is_gemini=is_gemini)
159
+ if cached_response :
160
+ return cached_response
161
+
162
+ if not settings.PUBLIC_MODE:
163
+ # 构建包含缓存键的活跃请求池键
164
+ pool_key = f"{cache_key}"
165
+
166
+ # 查找所有使用相同缓存键的活跃任务
167
+ active_task = active_requests_manager.get(pool_key)
168
+ if active_task and not active_task.done():
169
+ log('info', f"发现相同请求的进行中任务",
170
+ extra={'request_type': 'stream' if request.stream else "non-stream", 'model': request.model})
171
+
172
+ # 等待已有任务完成
173
+ try:
174
+ # 设置超时,避免无限等待
175
+ await asyncio.wait_for(active_task, timeout=240)
176
+
177
+ # 使用任务结果
178
+ if active_task.done() and not active_task.cancelled():
179
+
180
+ result = active_task.result()
181
+ active_requests_manager.remove(pool_key)
182
+ if result:
183
+ return result
184
+
185
+ except (asyncio.TimeoutError, asyncio.CancelledError) as e:
186
+ # 任务超时或被取消的情况下,记录日志然后让代码继续执行
187
+ error_type = "超时" if isinstance(e, asyncio.TimeoutError) else "被取消"
188
+ log('warning', f"等待已有任务{error_type}: {pool_key}",
189
+ extra={'request_type': 'non-stream', 'model': request.model})
190
+
191
+ # 从活跃请求池移除该任务
192
+ if active_task.done() or active_task.cancelled():
193
+ active_requests_manager.remove(pool_key)
194
+ log('info', f"已从活跃请求池移除{error_type}任务: {pool_key}",
195
+ extra={'request_type': 'non-stream'})
196
+
197
+
198
+ if request.stream:
199
+ # 流式请求处理任务
200
+ process_task = asyncio.create_task(
201
+ process_stream_request(
202
+ chat_request = request,
203
+ key_manager=key_manager,
204
+ response_cache_manager = response_cache_manager,
205
+ safety_settings = safety_settings,
206
+ safety_settings_g2 = safety_settings_g2,
207
+ cache_key = cache_key
208
+ )
209
+ )
210
+
211
+ else:
212
+ # 检查是否启用非流式保活功能
213
+ if settings.NONSTREAM_KEEPALIVE_ENABLED:
214
+ # 使用带保活功能的非流式请求处理
215
+ process_task = asyncio.create_task(
216
+ process_nonstream_with_keepalive_stream(
217
+ chat_request = request,
218
+ key_manager = key_manager,
219
+ response_cache_manager = response_cache_manager,
220
+ safety_settings = safety_settings,
221
+ safety_settings_g2 = safety_settings_g2,
222
+ cache_key = cache_key,
223
+ is_gemini = is_gemini
224
+ )
225
+ )
226
+ else:
227
+ # 创建非流式请求处理任务
228
+ process_task = asyncio.create_task(
229
+ process_request(
230
+ chat_request = request,
231
+ key_manager = key_manager,
232
+ response_cache_manager = response_cache_manager,
233
+ safety_settings = safety_settings,
234
+ safety_settings_g2 = safety_settings_g2,
235
+ cache_key = cache_key
236
+ )
237
+ )
238
+
239
+ if not settings.PUBLIC_MODE:
240
+ # 将任务添加到活跃请求池
241
+ active_requests_manager.add(pool_key, process_task)
242
+
243
+ # 等待任务完成
244
+ try:
245
+ response = await process_task
246
+ if not settings.PUBLIC_MODE:
247
+ active_requests_manager.remove(pool_key)
248
+
249
+ return response
250
+ except Exception as e:
251
+ if not settings.PUBLIC_MODE:
252
+ # 如果任务失败,从活跃请求池中移除
253
+ active_requests_manager.remove(pool_key)
254
+
255
+ # 检查是否已有缓存的结果(可能是由另一个任务创建的)
256
+ cached_response = await get_cache(cache_key, is_stream = request.stream,is_gemini=is_gemini)
257
+ if cached_response :
258
+ return cached_response
259
+
260
+ # 发送错误信息给客户端
261
+ raise HTTPException(status_code=500, detail=f" hajimi 服务器内部处理时发生错误\n具体原因:{e}")
262
+
263
+ @router.post("/vertex/chat/completions", response_model=ChatCompletionResponse)
264
+ async def vertex_chat_completions(
265
+ request: ChatCompletionRequest,
266
+ http_request: Request,
267
+ _dp = Depends(custom_verify_password),
268
+ _du = Depends(verify_user_agent),
269
+ ):
270
+ # 使用vertex/routes/chat_api的实现
271
+
272
+ # 转换消息格式
273
+ openai_messages = []
274
+ for message in request.messages:
275
+ openai_messages.append(OpenAIMessage(
276
+ role=message.get('role', ''),
277
+ content=message.get('content', '')
278
+ ))
279
+
280
+ # 转换请求格式
281
+ vertex_request = OpenAIRequest(
282
+ model=request.model,
283
+ messages=openai_messages,
284
+ temperature=request.temperature,
285
+ max_tokens=request.max_tokens,
286
+ top_p=request.top_p,
287
+ top_k=request.top_k,
288
+ stream=request.stream,
289
+ stop=request.stop,
290
+ presence_penalty=request.presence_penalty,
291
+ frequency_penalty=request.frequency_penalty,
292
+ seed=getattr(request, 'seed', None),
293
+ logprobs=getattr(request, 'logprobs', None),
294
+ response_logprobs=getattr(request, 'response_logprobs', None),
295
+ n=request.n
296
+ )
297
+
298
+ # 调用vertex/routes/chat_api的实现
299
+ return await chat_api.chat_completions(http_request, vertex_request, current_api_key)
300
+
301
+ @router.post("/v1/chat/completions", response_model=ChatCompletionResponse)
302
+ @router.post("/chat/completions", response_model=ChatCompletionResponse)
303
+ async def chat_completions(
304
+ request: ChatCompletionRequest,
305
+ http_request: Request,
306
+ _dp = Depends(custom_verify_password),
307
+ _du = Depends(verify_user_agent),
308
+ ):
309
+ """处理API请求的主函数,根据需要处理流式或非流式请求"""
310
+ if settings.ENABLE_VERTEX:
311
+ return await vertex_chat_completions(request, http_request, _dp, _du)
312
+ return await aistudio_chat_completions(request, http_request, _dp, _du)
313
+
314
+ @router.post("/gemini/{api_version:str}/models/{model_and_responseType:path}")
315
+ async def gemini_chat_completions(
316
+ request: Request,
317
+ model_and_responseType: str = Path(...),
318
+ key: Optional[str] = Query(None),
319
+ alt: Optional[str] = Query(None, description=" sse 或 None"),
320
+ payload: ChatRequestGemini = Body(...),
321
+ _dp = Depends(custom_verify_password),
322
+ _du = Depends(verify_user_agent),
323
+ ):
324
+ # 提取路径参数
325
+ is_stream = False
326
+ try:
327
+ model_name, action_type = model_and_responseType.split(":", 1)
328
+ if action_type == "streamGenerateContent":
329
+ is_stream = True
330
+
331
+ except ValueError:
332
+ raise HTTPException(status_code=400, detail="无效的请求路径")
333
+
334
+ geminiRequest = AIRequest(payload=payload,model=model_name,stream=is_stream,format_type='gemini')
335
+ return await aistudio_chat_completions(geminiRequest, request, _dp, _du)
336
+
app/api/stream_handlers.py ADDED
@@ -0,0 +1,374 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import json
3
+ from fastapi.responses import StreamingResponse
4
+ from app.models.schemas import ChatCompletionRequest
5
+ from app.services import GeminiClient
6
+ from app.utils import handle_gemini_error, update_api_call_stats,log,openAI_from_text
7
+ from app.utils.response import openAI_from_Gemini,gemini_from_text
8
+ from app.utils.stats import get_api_key_usage
9
+ import app.config.settings as settings
10
+
11
+ async def stream_response_generator(
12
+ chat_request,
13
+ key_manager,
14
+ response_cache_manager,
15
+ safety_settings,
16
+ safety_settings_g2,
17
+ cache_key: str
18
+ ):
19
+ format_type = getattr(chat_request, 'format_type', None)
20
+ if format_type and (format_type == "gemini"):
21
+ is_gemini = True
22
+ contents, system_instruction = None,None
23
+ else:
24
+ is_gemini = False
25
+ # 转换消息格式
26
+ contents, system_instruction = GeminiClient.convert_messages(GeminiClient, chat_request.messages,model=chat_request.model)
27
+ # 设置初始并发数
28
+ current_concurrent = settings.CONCURRENT_REQUESTS
29
+ max_retry_num = settings.MAX_RETRY_NUM
30
+
31
+ # 当前请求次数
32
+ current_try_num = 0
33
+
34
+ # 空响应计数
35
+ empty_response_count = 0
36
+
37
+ # (假流式) 尝试使用不同API密钥,直到达到最大重试次数或空响应限制
38
+ while (settings.FAKE_STREAMING and (current_try_num < max_retry_num) and (empty_response_count < settings.MAX_EMPTY_RESPONSES)):
39
+ # 获取当前批次的密钥数量
40
+ batch_num = min(max_retry_num - current_try_num, current_concurrent)
41
+
42
+ # 获取当前批次的密钥
43
+ valid_keys = []
44
+ checked_keys = set() # 用于记录已检查过的密钥
45
+ all_keys_checked = False # 标记是否已检查所有密钥
46
+
47
+ # 尝试获取足够数量的有效密钥
48
+ while len(valid_keys) < batch_num:
49
+ api_key = await key_manager.get_available_key()
50
+ if not api_key:
51
+ break
52
+
53
+ # 如果这个密钥已经检查过,说明已经检查了所有密钥
54
+ if api_key in checked_keys:
55
+ all_keys_checked = True
56
+ break
57
+
58
+ checked_keys.add(api_key)
59
+ # 获取API密钥的调用次数
60
+ usage = await get_api_key_usage(settings.api_call_stats, api_key)
61
+ # 如果调用次数小于限制,则添加到有效密钥列表
62
+ if usage < settings.API_KEY_DAILY_LIMIT:
63
+ valid_keys.append(api_key)
64
+ else:
65
+ log('warning', f"API密钥 {api_key[:8]}... 已达到每日调用限制 ({usage}/{settings.API_KEY_DAILY_LIMIT})",
66
+ extra={'key': api_key[:8], 'request_type': 'stream', 'model': chat_request.model})
67
+
68
+ # 如果已经检查了所有密钥且没有找到有效密钥,则重置密钥栈
69
+ if all_keys_checked and not valid_keys:
70
+ log('warning', "所有API密钥已达到每日调用限制,重置密钥栈",
71
+ extra={'request_type': 'stream', 'model': chat_request.model})
72
+ key_manager._reset_key_stack()
73
+ # 重置后重新获取一个密钥
74
+ api_key = await key_manager.get_available_key()
75
+ if api_key:
76
+ valid_keys = [api_key]
77
+
78
+ # 如果没有获取到任何有效密钥,跳出循环
79
+ if not valid_keys:
80
+ break
81
+
82
+ # 更新当前尝试次数
83
+ current_try_num += len(valid_keys)
84
+
85
+ # 创建并发任务
86
+ tasks = []
87
+ tasks_map = {}
88
+ for api_key in valid_keys:
89
+ # 假流式模式的处理逻辑
90
+ log('info', f"假流式请求开始,使用密钥: {api_key[:8]}...",
91
+ extra={'key': api_key[:8], 'request_type': 'fake-stream', 'model': chat_request.model})
92
+
93
+ task = asyncio.create_task(
94
+ handle_fake_streaming(
95
+ api_key,
96
+ chat_request,
97
+ contents,
98
+ response_cache_manager,
99
+ system_instruction,
100
+ safety_settings,
101
+ safety_settings_g2,
102
+ cache_key
103
+ )
104
+ )
105
+
106
+ tasks.append((api_key, task))
107
+ tasks_map[task] = api_key
108
+
109
+ # 等待所有任务完成或找到成功响应
110
+ success = False
111
+ while tasks and not success:
112
+ # 等待任务完成
113
+ done, pending = await asyncio.wait(
114
+ [task for _, task in tasks],
115
+ timeout=settings.FAKE_STREAMING_INTERVAL,
116
+ return_when=asyncio.FIRST_COMPLETED
117
+ )
118
+
119
+ # 如果没有任务完成,发送保活消息
120
+ if not done :
121
+ if is_gemini:
122
+ yield gemini_from_text(content='',stream=True)
123
+ else:
124
+ yield openAI_from_text(model=chat_request.model,content='',stream=True)
125
+ continue
126
+
127
+ # 检查已完成的任务是否成功
128
+ for task in done:
129
+ api_key = tasks_map[task]
130
+ if not task.cancelled():
131
+ try:
132
+ status = task.result()
133
+ # 如果有成功响应内容
134
+ if status == "success" :
135
+ success = True
136
+ log('info', f"假流式请求成功",
137
+ extra={'key': api_key[:8],'request_type': "fake-stream", 'model': chat_request.model})
138
+ cached_response, cache_hit = await response_cache_manager.get_and_remove(cache_key)
139
+ if cache_hit and cached_response:
140
+ if is_gemini :
141
+ json_payload = json.dumps(cached_response.data, ensure_ascii=False)
142
+ data_to_yield = f"data: {json_payload}\n\n"
143
+ yield data_to_yield
144
+ else:
145
+ yield openAI_from_Gemini(cached_response,stream=True)
146
+ else:
147
+ success = False
148
+ break
149
+ elif status == "empty":
150
+ # 增加空响应计数
151
+ empty_response_count += 1
152
+ log('warning', f"空响应计数: {empty_response_count}/{settings.MAX_EMPTY_RESPONSES}",
153
+ extra={'key': api_key[:8], 'request_type': 'stream', 'model': chat_request.model})
154
+
155
+ except Exception as e:
156
+ error_detail = handle_gemini_error(e, api_key)
157
+ log('error', f"请求失败: {error_detail}",
158
+ extra={'key': api_key[:8], 'request_type': 'stream', 'model': chat_request.model})
159
+
160
+ # 如果找到成功的响应,跳出循环
161
+ if success:
162
+ return
163
+
164
+ # 如果空响应次数达到限制,跳出循环
165
+ if empty_response_count >= settings.MAX_EMPTY_RESPONSES:
166
+ log('warning', f"空响应次数达到限制 ({empty_response_count}/{settings.MAX_EMPTY_RESPONSES}),停止轮询",
167
+ extra={'request_type': 'fake-stream', 'model': chat_request.model})
168
+ if is_gemini :
169
+ yield gemini_from_text(content="空响应次数达到上限\n请修改输入提示词",finish_reason="STOP",stream=True)
170
+ else:
171
+ yield openAI_from_text(model=chat_request.model,content="空响应次数达到上限\n请修改输入提示词",finish_reason="stop",stream=True)
172
+
173
+ return
174
+
175
+ # 更新任务列表,移除已完成的任务
176
+ tasks = [(k, t) for k, t in tasks if not t.done()]
177
+
178
+ # 如果所有请求都失败,增加并发数并继续尝试
179
+ if not success and valid_keys:
180
+ # 增加并发数,但不超过最大并发数
181
+ current_concurrent = min(current_concurrent + settings.INCREASE_CONCURRENT_ON_FAILURE, settings.MAX_CONCURRENT_REQUESTS)
182
+ log('info', f"所有假流式请求失败,增加并发数至: {current_concurrent}",
183
+ extra={'request_type': 'stream', 'model': chat_request.model})
184
+
185
+ # (真流式) 尝试使用不同API密钥,直到达到最大重试次数或空响应限制
186
+ while (not settings.FAKE_STREAMING and (current_try_num < max_retry_num) and (empty_response_count < settings.MAX_EMPTY_RESPONSES)):
187
+ # 获取当前批次的密钥
188
+ valid_keys = []
189
+ checked_keys = set() # 用于记录已检查过的密钥
190
+ all_keys_checked = False # 标记是否已检查所有密钥
191
+
192
+ # 尝试获取一个有效密钥
193
+ while len(valid_keys) < 1:
194
+ api_key = await key_manager.get_available_key()
195
+ if not api_key:
196
+ break
197
+
198
+ # 如果这个密钥已经检查过,说明已经检查了所有密钥
199
+ if api_key in checked_keys:
200
+ all_keys_checked = True
201
+ break
202
+
203
+ checked_keys.add(api_key)
204
+ # 获取API密钥的调用次数
205
+ usage = await get_api_key_usage(settings.api_call_stats, api_key)
206
+ # 如果调用次数小于限制,则添加到有效密钥列表
207
+ if usage < settings.API_KEY_DAILY_LIMIT:
208
+ valid_keys.append(api_key)
209
+ else:
210
+ log('warning', f"API密钥 {api_key[:8]}... 已达到每日调用限制 ({usage}/{settings.API_KEY_DAILY_LIMIT})",
211
+ extra={'key': api_key[:8], 'request_type': 'stream', 'model': chat_request.model})
212
+
213
+ # 如果已经检查了所有密钥且没有找到有效密钥,则重置密钥栈
214
+ if all_keys_checked and not valid_keys:
215
+ log('warning', "所有API密钥已达到每日调用限制,重置密钥栈",
216
+ extra={'request_type': 'stream', 'model': chat_request.model})
217
+ key_manager._reset_key_stack()
218
+ # 重置后重新获取一个密钥
219
+ api_key = await key_manager.get_available_key()
220
+ if api_key:
221
+ valid_keys = [api_key]
222
+
223
+ # 如果没有获取到任何有效密钥,跳出循环
224
+ if not valid_keys:
225
+ break
226
+
227
+ # 更新当前尝试次数
228
+ current_try_num += 1
229
+
230
+ # 获取密钥
231
+ api_key = valid_keys[0]
232
+
233
+ success = False
234
+ try:
235
+ client = GeminiClient(api_key)
236
+
237
+ # 获取流式响应
238
+ stream_generator = client.stream_chat(
239
+ chat_request,
240
+ contents,
241
+ safety_settings_g2 if 'gemini-2.5' in chat_request.model else safety_settings,
242
+ system_instruction
243
+ )
244
+ token=0
245
+ # 处理流式响应
246
+ async for chunk in stream_generator:
247
+ if chunk :
248
+
249
+ if chunk.total_token_count:
250
+ token = int(chunk.total_token_count)
251
+ success = True
252
+
253
+ if is_gemini:
254
+ json_payload = json.dumps(chunk.data, ensure_ascii=False)
255
+ data = f"data: {json_payload}\n\n"
256
+ else:
257
+ data = openAI_from_Gemini(chunk,stream=True)
258
+
259
+ # log('info', f"流式响应发送数据: {data}")
260
+ yield data
261
+
262
+ else:
263
+ log('warning', f"流式请求返回空响应,空响应计数: {empty_response_count}/{settings.MAX_EMPTY_RESPONSES}",
264
+ extra={'key': api_key[:8], 'request_type': 'stream', 'model': chat_request.model})
265
+ # 增加空响应计数
266
+ empty_response_count += 1
267
+ await update_api_call_stats(
268
+ settings.api_call_stats,
269
+ endpoint=api_key,
270
+ model=chat_request.model,
271
+ token=token
272
+ )
273
+ break
274
+
275
+ except Exception as e:
276
+ error_detail = handle_gemini_error(e, api_key)
277
+ log('error', f"流式响应: API密钥 {api_key[:8]}... 请求失败: {error_detail}",
278
+ extra={'key': api_key[:8], 'request_type': 'stream', 'model': chat_request.model})
279
+ finally:
280
+ # 如果成功获取相应,更新API调用统计
281
+ if success:
282
+ await update_api_call_stats(
283
+ settings.api_call_stats,
284
+ endpoint=api_key,
285
+ model=chat_request.model,
286
+ token=token
287
+ )
288
+ return
289
+
290
+ # 如果空响应次数达到限制,跳出循环
291
+ if empty_response_count >= settings.MAX_EMPTY_RESPONSES:
292
+
293
+ log('warning', f"空响应次数达到限制 ({empty_response_count}/{settings.MAX_EMPTY_RESPONSES}),停止轮询",
294
+ extra={'request_type': 'stream', 'model': chat_request.model})
295
+
296
+ if is_gemini:
297
+ yield gemini_from_text(content="空响应次数达到上限\n请修改输入提示词",finish_reason="STOP",stream=True)
298
+ else:
299
+ yield openAI_from_text(model=chat_request.model,content="空响应次数达到上限\n请修改输入提示词",finish_reason="stop",stream=True)
300
+
301
+ return
302
+
303
+ # 所有API密钥都尝试失败的处理
304
+ log('error', "所有 API 密钥均请求失败,请稍后重试",
305
+ extra={'key': 'ALL', 'request_type': 'stream', 'model': chat_request.model})
306
+
307
+ if is_gemini:
308
+ yield gemini_from_text(content="所有API密钥均请求失败\n具体错误请查看轮询日志",finish_reason="STOP",stream=True)
309
+ else:
310
+ yield openAI_from_text(model=chat_request.model,content="所有API密钥均请求失败\n具体错误请查看轮询日志",finish_reason="stop")
311
+
312
+ # 处理假流式模式
313
+ async def handle_fake_streaming(api_key,chat_request, contents, response_cache_manager,system_instruction, safety_settings, safety_settings_g2, cache_key):
314
+
315
+ # 使用非流式请求内容
316
+ gemini_client = GeminiClient(api_key)
317
+
318
+ gemini_task = asyncio.create_task(
319
+ gemini_client.complete_chat(
320
+ chat_request,
321
+ contents,
322
+ safety_settings_g2 if 'gemini-2.5' in chat_request.model else safety_settings,
323
+ system_instruction
324
+ )
325
+ )
326
+ gemini_task = asyncio.shield(gemini_task)
327
+
328
+ try:
329
+ # 获取响应内容
330
+ response_content = await gemini_task
331
+ response_content.set_model(chat_request.model)
332
+ log('info', f"假流式成功获取响应,进行缓存",
333
+ extra={'key': api_key[:8], 'request_type': 'fake-stream', 'model': chat_request.model})
334
+
335
+ # 更新API调用统计
336
+ await update_api_call_stats(settings.api_call_stats, endpoint=api_key, model=chat_request.model,token=response_content.total_token_count)
337
+
338
+ # 检查响应内容是否为空
339
+ if not response_content or (not response_content.text and not response_content.function_call):
340
+ log('warning', f"请求返回空响应",
341
+ extra={'key': api_key[:8], 'request_type': 'fake-stream', 'model': chat_request.model})
342
+ return "empty"
343
+
344
+ # 缓存
345
+ await response_cache_manager.store(cache_key, response_content)
346
+ return "success"
347
+
348
+ except Exception as e:
349
+ handle_gemini_error(e, api_key)
350
+ # log('error', f"假流式模式: API密钥 {api_key[:8]}... 请求失败: {error_detail}",
351
+ # extra={'key': api_key[:8], 'request_type': 'fake-stream', 'model': chat_request.model})
352
+ return "error"
353
+
354
+
355
+
356
+ # 流式请求处理函数
357
+ async def process_stream_request(
358
+ chat_request: ChatCompletionRequest,
359
+ key_manager,
360
+ response_cache_manager,
361
+ safety_settings,
362
+ safety_settings_g2,
363
+ cache_key: str
364
+ ) -> StreamingResponse:
365
+ """处理流式API请求"""
366
+
367
+ return StreamingResponse(stream_response_generator(
368
+ chat_request,
369
+ key_manager,
370
+ response_cache_manager,
371
+ safety_settings,
372
+ safety_settings_g2,
373
+ cache_key
374
+ ), media_type="text/event-stream")
app/config/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ # 配置模块初始化文件
2
+ import app.config.settings as settings
3
+ from app.config.safety import *
4
+ from app.config.persistence import save_settings, load_settings
app/config/persistence.py ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ import inspect
4
+ import pathlib
5
+ from app.config import settings
6
+ from app.utils.logging import log
7
+
8
+ # 定义不应该被保存或加载的配置项
9
+ EXCLUDED_SETTINGS = [
10
+ "STORAGE_DIR",
11
+ "ENABLE_STORAGE",
12
+ "BASE_DIR",
13
+ "PASSWORD",
14
+ "WEB_PASSWORD",
15
+ "WHITELIST_MODELS",
16
+ "BLOCKED_MODELS",
17
+ "DEFAULT_BLOCKED_MODELS",
18
+ "PUBLIC_MODE",
19
+ "DASHBOARD_URL",
20
+ "version"
21
+ ]
22
+
23
+ def save_settings():
24
+ """
25
+ 将settings中所有的从os.environ.get获取的配置保存到JSON文件中,
26
+ 但排除特定的配置项
27
+ """
28
+ if settings.ENABLE_STORAGE:
29
+ # 确保存储目录存在
30
+ storage_dir = pathlib.Path(settings.STORAGE_DIR)
31
+ storage_dir.mkdir(parents=True, exist_ok=True)
32
+
33
+ # 设置JSON文件路径
34
+ settings_file = storage_dir / "settings.json"
35
+
36
+ # 获取settings模块中的所有变量
37
+ settings_dict = {}
38
+ for name, value in inspect.getmembers(settings):
39
+ # 跳过内置和私有变量,以及函数/模块/类,以及排除列表中的配置项
40
+ if (not name.startswith('_') and
41
+ not inspect.isfunction(value) and
42
+ not inspect.ismodule(value) and
43
+ not inspect.isclass(value) and
44
+ name not in EXCLUDED_SETTINGS):
45
+
46
+ # 尝试将可序列化的值添加到字典中
47
+ try:
48
+ json.dumps({name: value}) # 测试是否可序列化
49
+ settings_dict[name] = value
50
+ except (TypeError, OverflowError):
51
+ # 如果不可序列化,则跳过
52
+ continue
53
+ log('info', f"保存设置到JSON文件: {settings_file}")
54
+
55
+ # 保存到JSON文件
56
+ with open(settings_file, 'w', encoding='utf-8') as f:
57
+ json.dump(settings_dict, f, ensure_ascii=False, indent=4)
58
+
59
+ return settings_file
60
+
61
+ def load_settings():
62
+ """
63
+ 从JSON文件中加载设置并更新settings模块,
64
+ 排除特定的配置项,并合并GEMINI_API_KEYS
65
+ """
66
+ if settings.ENABLE_STORAGE:
67
+ # 设置JSON文件路径
68
+ storage_dir = pathlib.Path(settings.STORAGE_DIR)
69
+ settings_file = storage_dir / "settings.json"
70
+
71
+ # 如果文件不存在,则返回
72
+ if not settings_file.exists():
73
+ return False
74
+
75
+ # 从JSON文件中加载设置
76
+ try:
77
+ with open(settings_file, 'r', encoding='utf-8') as f:
78
+ loaded_settings = json.load(f)
79
+
80
+ # 保存当前环境变量中的GEMINI_API_KEYS
81
+ current_api_keys = []
82
+ if hasattr(settings, 'GEMINI_API_KEYS') and settings.GEMINI_API_KEYS:
83
+ current_api_keys = settings.GEMINI_API_KEYS.split(',')
84
+ current_api_keys = [key.strip() for key in current_api_keys if key.strip()]
85
+
86
+ # 保存当前环境变量中的GOOGLE_CREDENTIALS_JSON和VERTEX_EXPRESS_API_KEY
87
+ current_google_credentials_json = settings.GOOGLE_CREDENTIALS_JSON if hasattr(settings, 'GOOGLE_CREDENTIALS_JSON') else ""
88
+ current_vertex_express_api_key = settings.VERTEX_EXPRESS_API_KEY if hasattr(settings, 'VERTEX_EXPRESS_API_KEY') else ""
89
+
90
+ # 更新settings模块中的变量,但排除特定配置项
91
+ for name, value in loaded_settings.items():
92
+ if hasattr(settings, name) and name not in EXCLUDED_SETTINGS:
93
+ # 特殊处理GEMINI_API_KEYS,进行合并去重
94
+ if name == "GEMINI_API_KEYS":
95
+ loaded_api_keys = value.split(',') if value else []
96
+ loaded_api_keys = [key.strip() for key in loaded_api_keys if key.strip()]
97
+ all_keys = list(set(current_api_keys + loaded_api_keys))
98
+ setattr(settings, name, ','.join(all_keys))
99
+ # 特殊处理GOOGLE_CREDENTIALS_JSON,如果当前环境变量中有值,则优先使用环境变量中的值
100
+ elif name == "GOOGLE_CREDENTIALS_JSON":
101
+ # 检查当前值是否为空(None、空字符串、只有空白字符,或者是"''"这样的空引号)
102
+ is_empty = (not current_google_credentials_json or
103
+ not current_google_credentials_json.strip() or
104
+ current_google_credentials_json.strip() in ['""', "''"])
105
+ log('debug', f"is_empty检查结果: {is_empty}")
106
+ if is_empty:
107
+ log('debug', f"当前GOOGLE_CREDENTIALS_JSON为空,将使用持久化的值")
108
+ setattr(settings, name, value)
109
+ # 更新环境变量,确保其他模块能够访问到
110
+ if value: # 只有当value不为空���才设置环境变量
111
+ os.environ["GOOGLE_CREDENTIALS_JSON"] = value
112
+ log('info', f"从持久化存储加载了GOOGLE_CREDENTIALS_JSON配置")
113
+ else:
114
+ log('warning', f"持久化的GOOGLE_CREDENTIALS_JSON值为空")
115
+ else:
116
+ log('debug', f"当前GOOGLE_CREDENTIALS_JSON不为空,保持现有值")
117
+ # 特殊处理VERTEX_EXPRESS_API_KEY,如果当前环境变量中有值,则优先使用环境变量中的值
118
+ elif name == "VERTEX_EXPRESS_API_KEY":
119
+ # 检查当前值是否为空(None、空字符串或只有空白字符)
120
+ if not current_vertex_express_api_key or not current_vertex_express_api_key.strip():
121
+ setattr(settings, name, value)
122
+ # 更新环境变量,确保其他模块能够访问到
123
+ if value: # 只有当value不为空时才设置环境变量
124
+ os.environ["VERTEX_EXPRESS_API_KEY"] = value
125
+ log('info', f"从持久化存储加载了VERTEX_EXPRESS_API_KEY配置")
126
+ else:
127
+ setattr(settings, name, value)
128
+
129
+ # 在加载完设置后,检查是否需要刷新模型配置
130
+ try:
131
+ # 如果加载了Google Credentials JSON或Vertex Express API Key,需要刷新模型配置
132
+ if (hasattr(settings, 'GOOGLE_CREDENTIALS_JSON') and settings.GOOGLE_CREDENTIALS_JSON) or \
133
+ (hasattr(settings, 'VERTEX_EXPRESS_API_KEY') and settings.VERTEX_EXPRESS_API_KEY):
134
+ log('info', "检测到Google Credentials JSON或Vertex Express API Key,准备更新配置")
135
+
136
+ # 更新配置
137
+ import app.vertex.config as app_config
138
+
139
+ # 重新加载vertex配置
140
+ app_config.reload_config()
141
+
142
+ # 更新app_config中的GOOGLE_CREDENTIALS_JSON
143
+ if hasattr(settings, 'GOOGLE_CREDENTIALS_JSON') and settings.GOOGLE_CREDENTIALS_JSON:
144
+ app_config.GOOGLE_CREDENTIALS_JSON = settings.GOOGLE_CREDENTIALS_JSON
145
+ # 同时更新环境变量,确保其他模块能够访问到
146
+ os.environ["GOOGLE_CREDENTIALS_JSON"] = settings.GOOGLE_CREDENTIALS_JSON
147
+ log('info', "已更新app_config和环境变量中的GOOGLE_CREDENTIALS_JSON")
148
+
149
+ # 更新app_config中的VERTEX_EXPRESS_API_KEY_VAL
150
+ if hasattr(settings, 'VERTEX_EXPRESS_API_KEY') and settings.VERTEX_EXPRESS_API_KEY:
151
+ app_config.VERTEX_EXPRESS_API_KEY_VAL = [key.strip() for key in settings.VERTEX_EXPRESS_API_KEY.split(',') if key.strip()]
152
+ # 同时更新环境变量
153
+ os.environ["VERTEX_EXPRESS_API_KEY"] = settings.VERTEX_EXPRESS_API_KEY
154
+ log('info', f"已更新app_config和环境变量中的VERTEX_EXPRESS_API_KEY_VAL,共{len(app_config.VERTEX_EXPRESS_API_KEY_VAL)}个有效密钥")
155
+
156
+ log('info', "配置更新完成,Vertex AI将在下次请求时重新初始化")
157
+
158
+ except Exception as e:
159
+ log('error', f"更新配置时出错: {str(e)}")
160
+
161
+ log('info', f"加载设置成功")
162
+ return True
163
+ except Exception as e:
164
+ log('error', f"加载设置时出错: {e}")
165
+ return False
app/config/safety.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 安全设置配置
2
+
3
+ # Gemini 1.0 安全设置
4
+ SAFETY_SETTINGS = [
5
+ {
6
+ "category": "HARM_CATEGORY_HARASSMENT",
7
+ "threshold": "BLOCK_NONE"
8
+ },
9
+ {
10
+ "category": "HARM_CATEGORY_HATE_SPEECH",
11
+ "threshold": "BLOCK_NONE"
12
+ },
13
+ {
14
+ "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
15
+ "threshold": "BLOCK_NONE"
16
+ },
17
+ {
18
+ "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
19
+ "threshold": "BLOCK_NONE"
20
+ },
21
+ {
22
+ "category": 'HARM_CATEGORY_CIVIC_INTEGRITY',
23
+ "threshold": 'BLOCK_NONE'
24
+ }
25
+ ]
26
+
27
+ # Gemini 2.0 安全设置
28
+ SAFETY_SETTINGS_G2 = [
29
+ {
30
+ "category": "HARM_CATEGORY_HARASSMENT",
31
+ "threshold": "OFF"
32
+ },
33
+ {
34
+ "category": "HARM_CATEGORY_HATE_SPEECH",
35
+ "threshold": "OFF"
36
+ },
37
+ {
38
+ "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
39
+ "threshold": "OFF"
40
+ },
41
+ {
42
+ "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
43
+ "threshold": "OFF"
44
+ },
45
+ {
46
+ "category": 'HARM_CATEGORY_CIVIC_INTEGRITY',
47
+ "threshold": 'OFF'
48
+ }
49
+ ]
app/config/settings.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import pathlib
3
+ import logging
4
+ from datetime import datetime, timedelta
5
+ import asyncio
6
+
7
+ # ---------- 以下是基础配置信息 ----------
8
+
9
+ # 调用本项目时使用的密码
10
+ PASSWORD = os.environ.get("PASSWORD", "123").strip('"')
11
+
12
+ # 网页配置密码,设置后,在网页修改配置时使用 WEB_PASSWORD 而不是上面的 PASSWORD
13
+ WEB_PASSWORD = os.environ.get("WEB_PASSWORD", PASSWORD).strip('"')
14
+
15
+ # API密钥
16
+ GEMINI_API_KEYS = os.environ.get("GEMINI_API_KEYS", "")
17
+
18
+ # 假流式是否开启
19
+ FAKE_STREAMING = os.environ.get("FAKE_STREAMING", "true").lower() in ["true", "1", "yes"]
20
+
21
+ # 配置持久化存储目录
22
+ STORAGE_DIR = os.environ.get("STORAGE_DIR", "/hajimi/settings/")
23
+ ENABLE_STORAGE = os.environ.get("ENABLE_STORAGE", "false").lower() in ["true", "1", "yes"]
24
+
25
+ # 并发请求配置
26
+ CONCURRENT_REQUESTS = int(os.environ.get("CONCURRENT_REQUESTS", "1")) # 默认并发请求数
27
+ INCREASE_CONCURRENT_ON_FAILURE = int(os.environ.get("INCREASE_CONCURRENT_ON_FAILURE", "0")) # 失败时增加的并发数
28
+ MAX_CONCURRENT_REQUESTS = int(os.environ.get("MAX_CONCURRENT_REQUESTS", "3")) # 最大并发请求数
29
+
30
+ # 缓存配置
31
+ CACHE_EXPIRY_TIME = int(os.environ.get("CACHE_EXPIRY_TIME", "21600")) # 默认缓存 6 小时 (21600 秒)
32
+ MAX_CACHE_ENTRIES = int(os.environ.get("MAX_CACHE_ENTRIES", "500")) # 默认最多缓存500条响应
33
+ CALCULATE_CACHE_ENTRIES = int(os.environ.get("CALCULATE_CACHE_ENTRIES", "6")) # 默认取最后 6 条消息算缓存键
34
+ PRECISE_CACHE = os.environ.get("PRECISE_CACHE", "false").lower() in ["true", "1", "yes"] #是否取所有消息来算缓存键
35
+
36
+ # 是否启用 Vertex AI
37
+ ENABLE_VERTEX = os.environ.get("ENABLE_VERTEX", "false").lower() in ["true", "1", "yes"]
38
+ GOOGLE_CREDENTIALS_JSON = os.environ.get("GOOGLE_CREDENTIALS_JSON", "")
39
+
40
+ # 是否启用快速模式 Vertex
41
+ ENABLE_VERTEX_EXPRESS = os.environ.get("ENABLE_VERTEX_EXPRESS", "false").lower() in ["true", "1", "yes"]
42
+ VERTEX_EXPRESS_API_KEY = os.environ.get("VERTEX_EXPRESS_API_KEY", "")
43
+
44
+ # 联网搜索配置
45
+ search={
46
+ "search_mode":os.environ.get("SEARCH_MODE", "false").lower() in ["true", "1", "yes"],
47
+ "search_prompt":os.environ.get("SEARCH_PROMPT", "(使用搜索工具联网搜索,需要在content中结合搜索内容)").strip('"')
48
+ }
49
+
50
+ #随机字符串
51
+ RANDOM_STRING = os.environ.get("RANDOM_STRING", "true").lower() in ["true", "1", "yes"]
52
+ RANDOM_STRING_LENGTH = int(os.environ.get("RANDOM_STRING_LENGTH", "5"))
53
+
54
+ # 空响应重试次数限制
55
+ MAX_EMPTY_RESPONSES = int(os.environ.get("MAX_EMPTY_RESPONSES", "5")) # 默认最多允许5次空响应
56
+
57
+ # ---------- 以下是其他配置信息 ----------
58
+
59
+ # 访问限制
60
+ MAX_RETRY_NUM = int(os.environ.get("MAX_RETRY_NUM", "15")) # 请求时的最大总轮询 key 数
61
+ MAX_REQUESTS_PER_MINUTE = int(os.environ.get("MAX_REQUESTS_PER_MINUTE", "30"))
62
+ MAX_REQUESTS_PER_DAY_PER_IP = int(os.environ.get("MAX_REQUESTS_PER_DAY_PER_IP", "600"))
63
+
64
+ # API密钥使用限制
65
+ API_KEY_DAILY_LIMIT = int(os.environ.get("API_KEY_DAILY_LIMIT", "100"))# 默认每个API密钥每24小时可使用100次
66
+
67
+ # 模型屏蔽黑名单,格式应为逗号分隔的模型名称集合
68
+ BLOCKED_MODELS = { model.strip() for model in os.environ.get("BLOCKED_MODELS", "").split(",") if model.strip() }
69
+
70
+ #公益站模式
71
+ PUBLIC_MODE = os.environ.get("PUBLIC_MODE", "false").lower() in ["true", "1", "yes"]
72
+ #前端地址
73
+ DASHBOARD_URL = os.environ.get("DASHBOARD_URL", "")
74
+
75
+ # 模型屏蔽白名单
76
+ WHITELIST_MODELS = { x.strip() for x in os.environ.get("WHITELIST_MODELS", "").split(",") if x.strip() }
77
+ # 白名单User-Agent
78
+ WHITELIST_USER_AGENT = { x.strip().lower() for x in os.environ.get("WHITELIST_USER_AGENT", "").split(",") if x.strip() }
79
+
80
+ # 跨域配置
81
+ # 允许的源列表,逗号分隔,例如 "http://localhost:3000,https://example.com"
82
+ ALLOWED_ORIGINS_STR = os.environ.get("ALLOWED_ORIGINS", "")
83
+ ALLOWED_ORIGINS = [origin.strip() for origin in ALLOWED_ORIGINS_STR.split(",") if origin.strip()]
84
+
85
+ # ---------- 运行时全局信息,无需修改 ----------
86
+
87
+ # 基础目录设置
88
+ BASE_DIR = pathlib.Path(__file__).parent.parent
89
+
90
+ # 失效的API密钥
91
+ INVALID_API_KEYS = os.environ.get("INVALID_API_KEYS", "")
92
+
93
+ version={
94
+ "local_version":"0.0.0",
95
+ "remote_version":"0.0.0",
96
+ "has_update":False
97
+ }
98
+
99
+ # API调用统计
100
+ # 这个对象保留为空结构以保持向后兼容性
101
+ # 实际统计数据已迁移到 app/utils/stats.py 中的 ApiStatsManager 类
102
+ api_call_stats = {
103
+ 'calls': [] # 兼容旧版代码结构
104
+ }
105
+
106
+ # 用于保护 api_call_stats 并发访问的锁
107
+ stats_lock = asyncio.Lock()
108
+
109
+ # 日志配置
110
+ logging.getLogger("uvicorn").disabled = True
111
+ logging.getLogger("uvicorn.access").disabled = True
112
+
113
+
114
+ # ---------- 以下配置信息已废弃 ----------
115
+
116
+ # 假流式请求的空内容返回间隔(秒)
117
+ FAKE_STREAMING_INTERVAL = float(os.environ.get("FAKE_STREAMING_INTERVAL", "1"))
118
+ # 假流式响应的每个块大小
119
+ FAKE_STREAMING_CHUNK_SIZE = int(os.environ.get("FAKE_STREAMING_CHUNK_SIZE", "10"))
120
+ # ���流式响应的每个块之间的延迟(秒)
121
+ FAKE_STREAMING_DELAY_PER_CHUNK = float(os.environ.get("FAKE_STREAMING_DELAY_PER_CHUNK", "0.1"))
122
+
123
+ # 非流式请求TCP保活配置
124
+ NONSTREAM_KEEPALIVE_ENABLED = os.environ.get("NONSTREAM_KEEPALIVE_ENABLED", "true").lower() in ["true", "1", "yes"]
125
+ NONSTREAM_KEEPALIVE_INTERVAL = float(os.environ.get("NONSTREAM_KEEPALIVE_INTERVAL", "5.0"))
app/main.py ADDED
@@ -0,0 +1,260 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, Request
2
+ from fastapi.responses import JSONResponse, HTMLResponse
3
+ from fastapi.staticfiles import StaticFiles
4
+ from fastapi.templating import Jinja2Templates
5
+ from fastapi.middleware.cors import CORSMiddleware
6
+ from app.models.schemas import ErrorResponse
7
+ from app.services import GeminiClient
8
+ from app.utils import (
9
+ APIKeyManager,
10
+ test_api_key,
11
+ ResponseCacheManager,
12
+ ActiveRequestsManager,
13
+ check_version,
14
+ schedule_cache_cleanup,
15
+ handle_exception,
16
+ log
17
+ )
18
+ from app.config.persistence import save_settings, load_settings
19
+ from app.api import router, init_router, dashboard_router, init_dashboard_router
20
+ from app.vertex.vertex_ai_init import init_vertex_ai
21
+ from app.vertex.credentials_manager import CredentialManager
22
+ import app.config.settings as settings
23
+ from app.config.safety import SAFETY_SETTINGS, SAFETY_SETTINGS_G2
24
+ import asyncio
25
+ import sys
26
+ import pathlib
27
+ import os
28
+ # 设置模板目录
29
+ BASE_DIR = pathlib.Path(__file__).parent
30
+ templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
31
+
32
+ app = FastAPI(limit="50M")
33
+
34
+ # --------------- CORS 中间件 ---------------
35
+ # 如果 ALLOWED_ORIGINS 为空列表,则不允许任何跨域请求
36
+ if settings.ALLOWED_ORIGINS:
37
+ app.add_middleware(
38
+ CORSMiddleware,
39
+ allow_origins=settings.ALLOWED_ORIGINS,
40
+ allow_credentials=True,
41
+ allow_methods=["*"],
42
+ allow_headers=["*"],
43
+ )
44
+
45
+ # --------------- 全局实例 ---------------
46
+ load_settings()
47
+ # 初始化API密钥管理器
48
+ key_manager = APIKeyManager()
49
+
50
+ # 创建全局缓存字典,将作为缓存管理器的内部存储
51
+ response_cache = {}
52
+
53
+ # 初始化缓存管理器,使用全局字典作为存储
54
+ response_cache_manager = ResponseCacheManager(
55
+ expiry_time=settings.CACHE_EXPIRY_TIME,
56
+ max_entries=settings.MAX_CACHE_ENTRIES,
57
+ cache_dict=response_cache
58
+ )
59
+
60
+ # 活跃请求池 - 将作为活跃请求管理器的内部存储
61
+ active_requests_pool = {}
62
+
63
+ # 初始化活跃请求管理器
64
+ active_requests_manager = ActiveRequestsManager(requests_pool=active_requests_pool)
65
+
66
+ SKIP_CHECK_API_KEY = os.environ.get("SKIP_CHECK_API_KEY", "").lower() == "true"
67
+
68
+ # --------------- 工具函数 ---------------
69
+ # @app.middleware("http")
70
+ # async def log_requests(request: Request, call_next):
71
+ # """
72
+ # DEBUG用,接收并打印请求内容
73
+ # """
74
+ # log('info', f"接收到请求: {request.method} {request.url}")
75
+ # try:
76
+ # body = await request.json()
77
+ # log('info', f"请求体: {body}")
78
+ # except Exception:
79
+ # log('info', "请求体不是 JSON 格式或者为空")
80
+
81
+ # response = await call_next(request)
82
+ # return response
83
+
84
+ async def check_remaining_keys_async(keys_to_check: list, initial_invalid_keys: list):
85
+ """
86
+ 在后台异步检查剩余的 API 密钥。
87
+ """
88
+ local_invalid_keys = []
89
+ found_valid_keys =False
90
+
91
+ log('info', f" 开始在后台检查剩余 API Key 是否有效")
92
+ for key in keys_to_check:
93
+ is_valid = await test_api_key(key)
94
+ if is_valid:
95
+ if key not in key_manager.api_keys: # 避免重复添加
96
+ key_manager.api_keys.append(key)
97
+ found_valid_keys = True
98
+ # log('info', f"API Key {key[:8]}... 有效")
99
+ else:
100
+ local_invalid_keys.append(key)
101
+ log('warning', f" API Key {key[:8]}... 无效")
102
+
103
+ await asyncio.sleep(0.05) # 短暂休眠,避免请求过于密集
104
+
105
+ if found_valid_keys:
106
+ key_manager._reset_key_stack() # 如果找到新的有效key,重置栈
107
+
108
+ # 合并所有无效密钥 (初始无效 + 后台检查出的无效)
109
+ combined_invalid_keys = list(set(initial_invalid_keys + local_invalid_keys))
110
+
111
+ # 获取当前设置中的无效密钥
112
+ current_invalid_keys_str = settings.INVALID_API_KEYS or ""
113
+ current_invalid_keys_set = set(k.strip() for k in current_invalid_keys_str.split(',') if k.strip())
114
+
115
+ # 更新无效密钥集合
116
+ new_invalid_keys_set = current_invalid_keys_set.union(set(combined_invalid_keys))
117
+
118
+ # 只有当无效密钥列表发生变化时才保存
119
+ if new_invalid_keys_set != current_invalid_keys_set:
120
+ settings.INVALID_API_KEYS = ','.join(sorted(list(new_invalid_keys_set)))
121
+ save_settings()
122
+
123
+ log('info', f"密钥检查任务完成。当前总可用密钥数量: {len(key_manager.api_keys)}")
124
+
125
+ # 设置全局异常处理
126
+ sys.excepthook = handle_exception
127
+
128
+ # --------------- 事件处理 ---------------
129
+
130
+ @app.on_event("startup")
131
+ async def startup_event():
132
+
133
+ # 首先加载持久化设置,确保所有配置都是最新的
134
+ load_settings()
135
+
136
+
137
+ # 重新加载vertex配置,确保获取到最新的持久化设置
138
+ import app.vertex.config as vertex_config
139
+ vertex_config.reload_config()
140
+
141
+
142
+ # 初始化CredentialManager
143
+ credential_manager_instance = CredentialManager()
144
+ # 添加到应用程序状态
145
+ app.state.credential_manager = credential_manager_instance
146
+
147
+ # 初始化Vertex AI服务
148
+ await init_vertex_ai(credential_manager=credential_manager_instance)
149
+ schedule_cache_cleanup(response_cache_manager, active_requests_manager)
150
+ # 检查版本
151
+ await check_version()
152
+
153
+ # 密钥检查
154
+ initial_keys = key_manager.api_keys.copy()
155
+ key_manager.api_keys = [] # 清空,等待检查结果
156
+ first_valid_key = None
157
+ initial_invalid_keys = []
158
+ keys_to_check_later = []
159
+
160
+ # 阻塞式查找第一个有效密钥
161
+ for index, key in enumerate(initial_keys):
162
+ is_valid = await test_api_key(key)
163
+ if is_valid:
164
+ log('info', f"找到第一个有效密钥: {key[:8]}...")
165
+ first_valid_key = key
166
+ key_manager.api_keys.append(key) # 添加到管理器
167
+ key_manager._reset_key_stack()
168
+ # 将剩余的key放入后台检查列表
169
+ keys_to_check_later = initial_keys[index + 1:]
170
+ break # 找到即停止
171
+ else:
172
+ log('warning', f"密钥 {key[:8]}... 无效")
173
+ initial_invalid_keys.append(key)
174
+
175
+ if not first_valid_key:
176
+ log('error', "启动时未能找到任何有效 API 密钥!")
177
+ keys_to_check_later = [] # 没有有效key,无需后台检查
178
+ else:
179
+ # 使用第一个有效密钥加载模型
180
+ try:
181
+ all_models = await GeminiClient.list_available_models(first_valid_key)
182
+ GeminiClient.AVAILABLE_MODELS = [model.replace("models/", "") for model in all_models]
183
+ log('info', f"使用密钥 {first_valid_key[:8]}... 加载可用模型成功")
184
+ except Exception as e:
185
+ log('warning', f"使用密钥 {first_valid_key[:8]}... 加载可用模型失败",extra={'error_message': str(e)})
186
+
187
+ if not SKIP_CHECK_API_KEY:
188
+ # 创建后台任务检查剩余密钥
189
+ if keys_to_check_later:
190
+ asyncio.create_task(check_remaining_keys_async(keys_to_check_later, initial_invalid_keys))
191
+ else:
192
+ # 如果没有需要后台检查的key,也要处理初始无效key
193
+ current_invalid_keys_str = settings.INVALID_API_KEYS or ""
194
+ current_invalid_keys_set = set(k.strip() for k in current_invalid_keys_str.split(',') if k.strip())
195
+ new_invalid_keys_set = current_invalid_keys_set.union(set(initial_invalid_keys))
196
+ if new_invalid_keys_set != current_invalid_keys_set:
197
+ settings.INVALID_API_KEYS = ','.join(sorted(list(new_invalid_keys_set)))
198
+ save_settings()
199
+ log('info', f"更新初始无效密钥列表完成,总无效密钥数: {len(new_invalid_keys_set)}")
200
+
201
+ else: # 跳过检查
202
+ log('info',"跳过 API 密钥检查")
203
+ key_manager.api_keys.extend(keys_to_check_later)
204
+ key_manager._reset_key_stack()
205
+
206
+ # 初始化路由器
207
+ init_router(
208
+ key_manager,
209
+ response_cache_manager,
210
+ active_requests_manager,
211
+ SAFETY_SETTINGS,
212
+ SAFETY_SETTINGS_G2,
213
+ first_valid_key,
214
+ settings.FAKE_STREAMING,
215
+ settings.FAKE_STREAMING_INTERVAL,
216
+ settings.PASSWORD,
217
+ settings.MAX_REQUESTS_PER_MINUTE,
218
+ settings.MAX_REQUESTS_PER_DAY_PER_IP
219
+ )
220
+
221
+ # 初始化仪表盘路由器
222
+ init_dashboard_router(
223
+ key_manager,
224
+ response_cache_manager,
225
+ active_requests_manager,
226
+ credential_manager_instance
227
+ )
228
+
229
+ # --------------- 异常处理 ---------------
230
+
231
+ @app.exception_handler(Exception)
232
+ async def global_exception_handler(request: Request, exc: Exception):
233
+ from app.utils import translate_error
234
+ error_message = translate_error(str(exc))
235
+ extra_log_unhandled_exception = {'status_code': 500, 'error_message': error_message}
236
+ log('error', f"Unhandled exception: {error_message}", extra=extra_log_unhandled_exception)
237
+ return JSONResponse(status_code=500, content=ErrorResponse(message=str(exc), type="internal_error").dict())
238
+
239
+ # --------------- 路由 ---------------
240
+
241
+ app.include_router(router)
242
+ app.include_router(dashboard_router)
243
+
244
+ # 挂载静态文件目录
245
+ app.mount("/assets", StaticFiles(directory="app/templates/assets"), name="assets")
246
+
247
+ # 设置根路由路径
248
+ dashboard_path = f"/{settings.DASHBOARD_URL}" if settings.DASHBOARD_URL else "/"
249
+
250
+ @app.get(dashboard_path, response_class=HTMLResponse)
251
+ async def root(request: Request):
252
+ """
253
+ 根路由 - 返回静态 HTML 文件
254
+ """
255
+ base_url = str(request.base_url).replace("http", "https")
256
+ api_url = f"{base_url}v1" if base_url.endswith("/") else f"{base_url}/v1"
257
+ # 直接返回 index.html 文件
258
+ return templates.TemplateResponse(
259
+ "index.html", {"request": request, "api_url": api_url}
260
+ )
app/models/schemas.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List, Dict, Optional, Union, Literal, Any
2
+ from pydantic import BaseModel, Field
3
+
4
+ # openAI 请求
5
+ class ChatCompletionRequest(BaseModel):
6
+ model: str
7
+ messages: List[Dict[str, Any]]
8
+ temperature: float = 0.7
9
+ top_p: Optional[float] = None
10
+ top_k: Optional[float] = None
11
+ n: int = 1
12
+ stream: bool = False
13
+ stop: Optional[Union[str, List[str]]] = None
14
+ max_tokens: Optional[int] = None
15
+ presence_penalty: Optional[float] = 0.0
16
+ frequency_penalty: Optional[float] = 0.0
17
+ seed: Optional[int] = None
18
+ logprobs: Optional[int] = None
19
+ response_logprobs: Optional[bool] = None
20
+ thinking_budget: Optional[int] = None
21
+ reasoning_effort : Optional[str] = None
22
+ # 函数调用
23
+ tools: Optional[List[Dict[str, Any]]] = None
24
+ tool_choice: Optional[Union[Literal["none", "auto"], Dict[str, Any]]] = "auto"
25
+
26
+ # gemini 请求
27
+ class ChatRequestGemini(BaseModel):
28
+ contents: List[Dict[str, Any]]
29
+ system_instruction: Optional[Dict[str, Any]]= None
30
+ systemInstruction: Optional[Dict[str, Any]]= None
31
+ safetySettings: Optional[List[Dict[str, Any]]] = None
32
+ generationConfig: Optional[Dict[str, Any]] = None
33
+ tools: Optional[List[Dict[str, Any]]] = None
34
+
35
+ # AI模型请求包装
36
+ class AIRequest(BaseModel):
37
+ payload: Optional[ChatRequestGemini] = None
38
+ model: Optional[str] = None
39
+ stream: bool = False
40
+ format_type: Optional[str] = "gemini"
41
+
42
+ class Usage(BaseModel):
43
+ prompt_tokens: int = 0
44
+ completion_tokens: int = 0
45
+ total_tokens: int = 0
46
+
47
+ class ChatCompletionResponse(BaseModel):
48
+ id: str
49
+ object: Literal["chat.completion"]
50
+ created: int
51
+ model: str
52
+ choices: List[Any]
53
+ usage: Usage = Field(default_factory=Usage)
54
+
55
+ class ErrorResponse(BaseModel):
56
+ message: str
57
+ type: str
58
+ param: Optional[str] = None
59
+ code: Optional[str] = None
60
+
61
+ class ModelList(BaseModel):
62
+ object: str = "list"
63
+ data: List[Dict[str, Any]]
64
+
65
+ class ChatResponseGemini(BaseModel):
66
+ candidates: Optional[List[Any]] = None
67
+ promptFeedback: Optional[Any] = None
68
+ usageMetadata: Optional[Dict[str, int]] = None
app/services/OpenAI.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ import asyncio
4
+ from app.models.schemas import ChatCompletionRequest
5
+ from dataclasses import dataclass
6
+ from typing import Optional, Dict, Any, List
7
+ import httpx
8
+ import logging
9
+ import secrets
10
+ import string
11
+ from app.utils import format_log_message
12
+ import app.config.settings as settings
13
+
14
+ from app.utils.logging import log
15
+
16
+ def generate_secure_random_string(length):
17
+ all_characters = string.ascii_letters + string.digits
18
+ secure_random_string = ''.join(secrets.choice(all_characters) for _ in range(length))
19
+ return secure_random_string
20
+
21
+ @dataclass
22
+ class GeneratedText:
23
+ text: str
24
+ finish_reason: Optional[str] = None
25
+
26
+ class OpenAIClient:
27
+
28
+ AVAILABLE_MODELS = []
29
+ EXTRA_MODELS = os.environ.get("EXTRA_MODELS", "").split(",")
30
+
31
+ def __init__(self, api_key: str):
32
+ self.api_key = api_key
33
+
34
+ def filter_data_by_whitelist(data, allowed_keys):
35
+ """
36
+ 根据白名单过滤字典。
37
+ Args:
38
+ data (dict): 原始的 Python 字典 (代表 JSON 对象)。
39
+ allowed_keys (list or set): 包含允许保留的键名的列表或集合。
40
+ 使用集合 (set) 进行查找通常更快。
41
+ Returns:
42
+ dict: 只包含白名单中键的新字典。
43
+ """
44
+ # 使用集合(set)可以提高查找效率,特别是当白名单很大时
45
+ allowed_keys_set = set(allowed_keys)
46
+ # 使用字典推导式创建过滤后的新字典
47
+ filtered_data = {key: value for key, value in data.items() if key in allowed_keys_set}
48
+ return filtered_data
49
+
50
+ # 真流式处理
51
+ async def stream_chat(self, request: ChatCompletionRequest):
52
+ whitelist = ["model", "messages", "temperature", "max_tokens","stream","tools","reasoning_effort","top_k","presence_penalty"]
53
+
54
+ data = self.filter_data_by_whitelist(request, whitelist)
55
+
56
+
57
+ if settings.search["search_mode"] and data.model.endswith("-search"):
58
+ log('INFO', "开启联网搜索模式", extra={'key': self.api_key[:8], 'model':request.model})
59
+ data.setdefault("tools", []).append({"google_search": {}})
60
+
61
+ data.model = data.model.removesuffix("-search")
62
+
63
+ # 真流式请求处理逻辑
64
+ extra_log = {'key': self.api_key[:8], 'request_type': 'stream', 'model': request.model}
65
+ log('INFO', "流式请求开始", extra=extra_log)
66
+
67
+
68
+ url = f"https://generativelanguage.googleapis.com/v1beta/openai/chat/completions"
69
+ headers = {
70
+ "Content-Type": "application/json",
71
+ "Authorization": f"Bearer {self.api_key}"
72
+ }
73
+
74
+ async with httpx.AsyncClient() as client:
75
+ async with client.stream("POST", url, headers=headers, json=data, timeout=600) as response:
76
+ buffer = b"" # 用于累积可能不完整的 JSON 数据
77
+ try:
78
+ async for line in response.aiter_lines():
79
+ if not line.strip(): # 跳过空行 (SSE 消息分隔符)
80
+ continue
81
+ if line.startswith("data: "):
82
+ line = line[len("data: "):].strip() # 去除 "data: " 前缀
83
+
84
+ # 检查是否是结束标志,如果是,结束循环
85
+ if line == "[DONE]":
86
+ break
87
+
88
+ buffer += line.encode('utf-8')
89
+ try:
90
+ # 尝试解析整个缓冲区
91
+ data = json.loads(buffer.decode('utf-8'))
92
+ # 解析成功,清空缓冲区
93
+ buffer = b""
94
+
95
+ yield data
96
+
97
+ except json.JSONDecodeError:
98
+ # JSON 不完整,继续累积到 buffer
99
+ continue
100
+ except Exception as e:
101
+ log('ERROR', f"流式处理期间发生错误",
102
+ extra={'key': self.api_key[:8], 'request_type': 'stream', 'model': request.model})
103
+ raise e
104
+ except Exception as e:
105
+ raise e
106
+ finally:
107
+ log('info', "流式请求结束")
app/services/__init__.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ from app.services.gemini import GeminiClient, GeminiResponseWrapper, GeneratedText
2
+ from app.services.OpenAI import OpenAIClient
3
+
4
+ __all__ = [
5
+ 'GeminiClient',
6
+ 'OpenAIClient',
7
+ 'GeminiResponseWrapper',
8
+ 'GeneratedText'
9
+ ]
app/services/gemini.py ADDED
@@ -0,0 +1,472 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ import httpx
4
+ from app.models.schemas import ChatCompletionRequest
5
+ from dataclasses import dataclass
6
+ from typing import Optional, Dict, Any, List
7
+ import httpx
8
+ import secrets
9
+ import string
10
+ import app.config.settings as settings
11
+
12
+ from app.utils.logging import log
13
+
14
+ def generate_secure_random_string(length):
15
+ all_characters = string.ascii_letters + string.digits
16
+ secure_random_string = ''.join(secrets.choice(all_characters) for _ in range(length))
17
+ return secure_random_string
18
+
19
+ @dataclass
20
+ class GeneratedText:
21
+ text: str
22
+ finish_reason: Optional[str] = None
23
+
24
+
25
+ class GeminiResponseWrapper:
26
+ def __init__(self, data: Dict[Any, Any]):
27
+ self._data = data
28
+ self._text = self._extract_text()
29
+ self._finish_reason = self._extract_finish_reason()
30
+ self._prompt_token_count = self._extract_prompt_token_count()
31
+ self._candidates_token_count = self._extract_candidates_token_count()
32
+ self._total_token_count = self._extract_total_token_count()
33
+ self._thoughts = self._extract_thoughts()
34
+ self._function_call = self._extract_function_call()
35
+ self._json_dumps = json.dumps(self._data, indent=4, ensure_ascii=False)
36
+ self._model = "gemini"
37
+
38
+ def _extract_thoughts(self) -> Optional[str]:
39
+ try:
40
+ for part in self._data['candidates'][0]['content']['parts']:
41
+ if 'thought' in part:
42
+ return part['text']
43
+ return ""
44
+ except (KeyError, IndexError):
45
+ return ""
46
+
47
+ def _extract_text(self) -> str:
48
+ try:
49
+ text=""
50
+ for part in self._data['candidates'][0]['content']['parts']:
51
+ if 'thought' not in part and 'text' in part:
52
+ text += part['text']
53
+ return text
54
+ except (KeyError, IndexError):
55
+ return ""
56
+
57
+ def _extract_function_call(self) -> Optional[Dict[str, Any]]:
58
+ try:
59
+ parts = self._data.get('candidates', [{}])[0].get('content', {}).get('parts', [])
60
+ # 使用列表推导式查找所有包含 'functionCall' 的 part,并提取其值
61
+ function_calls = [
62
+ part['functionCall']
63
+ for part in parts
64
+ if isinstance(part, dict) and 'functionCall' in part
65
+ ]
66
+ # 如果列表不为空,则返回列表;否则返回 None
67
+ return function_calls if function_calls else None
68
+ except (KeyError, IndexError, TypeError):
69
+ return None
70
+
71
+ def _extract_finish_reason(self) -> Optional[str]:
72
+ try:
73
+ return self._data['candidates'][0].get('finishReason')
74
+ except (KeyError, IndexError):
75
+ return None
76
+
77
+ def _extract_prompt_token_count(self) -> Optional[int]:
78
+ try:
79
+ return self._data['usageMetadata'].get('promptTokenCount')
80
+ except (KeyError):
81
+ return None
82
+
83
+ def _extract_candidates_token_count(self) -> Optional[int]:
84
+ try:
85
+ return self._data['usageMetadata'].get('candidatesTokenCount')
86
+ except (KeyError):
87
+ return None
88
+
89
+ def _extract_total_token_count(self) -> Optional[int]:
90
+ try:
91
+ return self._data['usageMetadata'].get('totalTokenCount')
92
+ except (KeyError):
93
+ return None
94
+
95
+ def set_model(self,model) -> Optional[str]:
96
+ self._model = model
97
+
98
+ @property
99
+ def data(self) -> Dict[Any, Any]:
100
+ return self._data
101
+
102
+ @property
103
+ def text(self) -> str:
104
+ return self._text
105
+
106
+ @property
107
+ def finish_reason(self) -> Optional[str]:
108
+ return self._finish_reason
109
+
110
+ @property
111
+ def prompt_token_count(self) -> Optional[int]:
112
+ return self._prompt_token_count
113
+
114
+ @property
115
+ def candidates_token_count(self) -> Optional[int]:
116
+ return self._candidates_token_count
117
+
118
+ @property
119
+ def total_token_count(self) -> Optional[int]:
120
+ return self._total_token_count
121
+
122
+ @property
123
+ def thoughts(self) -> Optional[str]:
124
+ return self._thoughts
125
+
126
+ @property
127
+ def json_dumps(self) -> str:
128
+ return self._json_dumps
129
+
130
+ @property
131
+ def model(self) -> str:
132
+ return self._model
133
+
134
+ @property
135
+ def function_call(self) -> Optional[Dict[str, Any]]:
136
+ return self._function_call
137
+
138
+
139
+ class GeminiClient:
140
+
141
+ AVAILABLE_MODELS = []
142
+ extra_models_str = os.environ.get("EXTRA_MODELS", "")
143
+ EXTRA_MODELS = [model.strip() for model in extra_models_str.split(",") if model.strip()]
144
+
145
+ def __init__(self, api_key: str):
146
+ self.api_key = api_key
147
+
148
+ # 请求参数处理
149
+ def _convert_request_data(self, request, contents, safety_settings, system_instruction):
150
+
151
+ model = request.model
152
+ format_type = getattr(request, 'format_type', None)
153
+ if format_type and (format_type == "gemini"):
154
+ api_version = "v1alpha" if "think" in request.model else "v1beta"
155
+ if request.payload:
156
+ # 将 Pydantic 模型转换为字典, 假设 Pydantic V2+
157
+ data = request.payload.model_dump(exclude_none=True)
158
+ # # 注入搜索提示
159
+ # if settings.search["search_mode"] and request.model and request.model.endswith("-search"):
160
+ # data.insert(len(data)-2,{'role': 'user', 'parts': [{'text':settings.search["search_prompt"]}]})
161
+
162
+ # # 注入随机字符串
163
+ # if settings.RANDOM_STRING:
164
+ # data.insert(1,{'role': 'user', 'parts': [{'text': generate_secure_random_string(settings.RANDOM_STRING_LENGTH)}]})
165
+ # data.insert(len(data)-1,{'role': 'user', 'parts': [{'text': generate_secure_random_string(settings.RANDOM_STRING_LENGTH)}]})
166
+ # log('INFO', "伪装消息成功")
167
+
168
+ else:
169
+ api_version, data = self._convert_openAI_request(request, contents, safety_settings, system_instruction)
170
+
171
+ # 联网模式
172
+ if settings.search["search_mode"] and request.model.endswith("-search"):
173
+ log('INFO', "开启联网搜索模式", extra={'key': self.api_key[:8], 'model':request.model})
174
+
175
+ data.setdefault("tools", []).append({"google_search": {}})
176
+ model= request.model.removesuffix("-search")
177
+
178
+ return api_version, model, data
179
+
180
+
181
+ def _convert_openAI_request(self, request: ChatCompletionRequest, contents, safety_settings, system_instruction):
182
+
183
+ config_params = {
184
+ "temperature": request.temperature,
185
+ "maxOutputTokens": request.max_tokens,
186
+ "topP": request.top_p,
187
+ "topK": request.top_k,
188
+ "stopSequences": request.stop if isinstance(request.stop, list) else [request.stop] if request.stop is not None else None,
189
+ "candidateCount": request.n,
190
+ }
191
+ if request.thinking_budget:
192
+ config_params["thinkingConfig"] = {
193
+ "thinkingBudget": request.thinking_budget
194
+ }
195
+ generationConfig = {k: v for k, v in config_params.items() if v is not None}
196
+
197
+ api_version = "v1alpha" if "think" in request.model else "v1beta"
198
+
199
+ data = {
200
+ "contents": contents,
201
+ "generationConfig": generationConfig,
202
+ "safetySettings": safety_settings,
203
+ }
204
+
205
+ # --- 函数调用处理 ---
206
+ # 1. 添加 tools (函数声明)
207
+ function_declarations = []
208
+ if request.tools:
209
+ # 显式提取 Gemini API 所需的字段,避免包含 'id' 等无效字段
210
+ function_declarations = []
211
+ for tool in request.tools:
212
+ if tool.get("type") == "function":
213
+ func_def = tool.get("function")
214
+ if func_def:
215
+ # 只包含 Gemini API 接受的字段
216
+ declaration = {
217
+ "name": func_def.get("name"),
218
+ "description": func_def.get("description"),
219
+ }
220
+ # 获取 parameters 并移除可能存在的 $schema 字段
221
+ parameters = func_def.get("parameters")
222
+ if isinstance(parameters, dict) and "$schema" in parameters:
223
+ parameters = parameters.copy()
224
+ del parameters["$schema"]
225
+ if parameters is not None:
226
+ declaration["parameters"] = parameters
227
+
228
+ # 移除值为 None 的键,以保持 payload 清洁
229
+ declaration = {k: v for k, v in declaration.items() if v is not None}
230
+ if declaration.get("name"): # 确保 name 存在
231
+ function_declarations.append(declaration)
232
+
233
+ if function_declarations:
234
+ data["tools"] = [{"function_declarations": function_declarations}]
235
+
236
+ # 2. 添加 tool_config (基于 tool_choice)
237
+ tool_config = None
238
+ if request.tool_choice:
239
+ choice = request.tool_choice
240
+ mode = None
241
+ allowed_functions = None
242
+ if isinstance(choice, str):
243
+ if choice == "none":
244
+ mode = "NONE"
245
+ elif choice == "auto":
246
+ mode = "AUTO"
247
+ elif isinstance(choice, dict) and choice.get("type") == "function":
248
+ func_name = choice.get("function", {}).get("name")
249
+ if func_name:
250
+ mode = "ANY" # 'ANY' 模式用于强制调用特定函数
251
+ allowed_functions = [func_name]
252
+
253
+ # 如果成功解析出有效的 mode,构建 tool_config
254
+ if mode:
255
+ config = {"mode": mode}
256
+ if allowed_functions:
257
+ config["allowed_function_names"] = allowed_functions
258
+ tool_config = {"function_calling_config": config}
259
+
260
+ # 3. 添加 tool_config 到 data
261
+ if tool_config:
262
+ data["tool_config"] = tool_config
263
+
264
+ if system_instruction:
265
+ data["system_instruction"] = system_instruction
266
+
267
+ return api_version, data
268
+
269
+
270
+ # 流式请求
271
+ async def stream_chat(self, request, contents, safety_settings, system_instruction):
272
+ # 真流式请求处理逻辑
273
+ extra_log = {'key': self.api_key[:8], 'request_type': 'stream', 'model': request.model}
274
+ log('INFO', "流式请求开始", extra=extra_log)
275
+
276
+ api_version, model, data = self._convert_request_data(request, contents, safety_settings, system_instruction)
277
+
278
+
279
+ url = f"https://generativelanguage.googleapis.com/{api_version}/models/{model}:streamGenerateContent?key={self.api_key}&alt=sse"
280
+ headers = {
281
+ "Content-Type": "application/json",
282
+ }
283
+
284
+ async with httpx.AsyncClient() as client:
285
+ async with client.stream("POST", url, headers=headers, json=data, timeout=600) as response:
286
+ response.raise_for_status()
287
+ buffer = b"" # 用于累积可能不完整的 JSON 数据
288
+ try:
289
+ async for line in response.aiter_lines():
290
+ if not line.strip(): # 跳过空行 (SSE 消息分隔符)
291
+ continue
292
+ if line.startswith("data: "):
293
+ line = line[len("data: "):].strip() # 去除 "data: " 前缀
294
+
295
+ # 检查是否是结束标志,如果是,结束循环
296
+ if line == "[DONE]":
297
+ break
298
+
299
+ buffer += line.encode('utf-8')
300
+ try:
301
+ # 尝试解析整个缓冲区
302
+ data = json.loads(buffer.decode('utf-8'))
303
+ # 解析成功,清空缓冲区
304
+ buffer = b""
305
+ yield GeminiResponseWrapper(data)
306
+
307
+ except json.JSONDecodeError:
308
+ # JSON 不完整,继续累积到 buffer
309
+ continue
310
+ except Exception as e:
311
+ log('ERROR', f"流式处理期间发生错误",
312
+ extra={'key': self.api_key[:8], 'request_type': 'stream', 'model': request.model})
313
+ raise e
314
+ except Exception as e:
315
+ raise e
316
+ finally:
317
+ log('info', "流式请求结束")
318
+
319
+ # 非流式处理
320
+ async def complete_chat(self, request, contents, safety_settings, system_instruction):
321
+
322
+ api_version, model, data = self._convert_request_data(request, contents, safety_settings, system_instruction)
323
+
324
+ url = f"https://generativelanguage.googleapis.com/{api_version}/models/{model}:generateContent?key={self.api_key}"
325
+ headers = {
326
+ "Content-Type": "application/json",
327
+ }
328
+
329
+ try:
330
+ async with httpx.AsyncClient() as client:
331
+ response = await client.post(url, headers=headers, json=data, timeout=600)
332
+ response.raise_for_status() # 检查 HTTP 错误状态
333
+
334
+ return GeminiResponseWrapper(response.json())
335
+ except Exception as e:
336
+ raise
337
+
338
+ # OpenAI 格式请求转换为 gemini 格式请求
339
+ def convert_messages(self, messages, use_system_prompt=False, model=None):
340
+ gemini_history = []
341
+ errors = []
342
+
343
+ system_instruction_text = ""
344
+ system_instruction_parts = [] # 用于收集系统指令文本
345
+
346
+ # 处理系统指令
347
+ if use_system_prompt:
348
+ # 遍历消息列表,查找开头的连续 system 消息
349
+ for i, message in enumerate(messages):
350
+ # 必须是 system 角色且内容是字符串
351
+ if message.get('role') == 'system' and isinstance(message.get('content'), str):
352
+ system_instruction_parts.append(message.get('content'))
353
+ else:
354
+ break # 遇到第一个非 system 或内容非字符串的消息就停止
355
+
356
+ # 将收集到的系统指令合并为一个字符串
357
+ system_instruction_text = "\n".join(system_instruction_parts)
358
+ system_instruction = {"parts": [{"text": system_instruction_text}]} if system_instruction_text else None
359
+
360
+ # 转换主要消息
361
+
362
+ for i, message in enumerate(messages):
363
+ role = message.get('role')
364
+ content = message.get('content')
365
+ if isinstance(content, str):
366
+
367
+ if role == 'tool':
368
+ role_to_use = 'function'
369
+ tool_call_id = message.get('tool_call_id')
370
+
371
+ prefix = "call_"
372
+ if tool_call_id.startswith(prefix):
373
+ # 假设 tool_call_id = f"call_{function_name}" (response.py中的处理)
374
+ function_name = tool_call_id[len(prefix):]
375
+ else:
376
+ continue
377
+
378
+ function_response_part = {
379
+ "functionResponse": {
380
+ "name": function_name,
381
+ "response": {"content": content}
382
+ }
383
+ }
384
+
385
+ gemini_history.append({"role": role_to_use, "parts": [function_response_part]})
386
+
387
+ continue
388
+ elif role in ['user', 'system']:
389
+ role_to_use = 'user'
390
+ elif role == 'assistant':
391
+ role_to_use = 'model'
392
+
393
+ else:
394
+ errors.append(f"Invalid role: {role}")
395
+ continue
396
+
397
+ # Gemini 的一个重要规则:连续的同角色消息需要合并
398
+ # 如果 gemini_history 已有内容,并且最后一条消息的角色和当前要添加的角色相同
399
+ if gemini_history and gemini_history[-1]['role'] == role_to_use:
400
+ gemini_history[-1]['parts'].append({"text": content})
401
+ else:
402
+ gemini_history.append({"role": role_to_use, "parts": [{"text": content}]})
403
+ elif isinstance(content, list):
404
+ parts = []
405
+ for item in content:
406
+ if item.get('type') == 'text':
407
+ parts.append({"text": item.get('text')})
408
+ elif item.get('type') == 'image_url':
409
+ image_data = item.get('image_url', {}).get('url', '')
410
+ if image_data.startswith('data:image/'):
411
+ try:
412
+ mime_type, base64_data = image_data.split(';')[0].split(':')[1], image_data.split(',')[1]
413
+ parts.append({
414
+ "inline_data": {
415
+ "mime_type": mime_type,
416
+ "data": base64_data
417
+ }
418
+ })
419
+ except (IndexError, ValueError):
420
+ errors.append(
421
+ f"Invalid data URI for image: {image_data}")
422
+ else:
423
+ errors.append(
424
+ f"Invalid image URL format for item: {item}")
425
+
426
+ if parts:
427
+ if role in ['user', 'system']:
428
+ role_to_use = 'user'
429
+ elif role == 'assistant':
430
+ role_to_use = 'model'
431
+ else:
432
+ errors.append(f"Invalid role: {role}")
433
+ continue
434
+
435
+ if gemini_history and gemini_history[-1]['role'] == role_to_use:
436
+ gemini_history[-1]['parts'].extend(parts)
437
+ else:
438
+ gemini_history.append(
439
+ {"role": role_to_use, "parts": parts})
440
+ if errors:
441
+ return errors
442
+
443
+ # --- 后处理 ---
444
+
445
+ # 注入搜索提示
446
+ if settings.search["search_mode"] and model and model.endswith("-search"):
447
+ gemini_history.insert(len(gemini_history)-2,{'role': 'user', 'parts': [{'text':settings.search["search_prompt"]}]})
448
+
449
+ # 注入随机字符串
450
+ if settings.RANDOM_STRING:
451
+ gemini_history.insert(1,{'role': 'user', 'parts': [{'text': generate_secure_random_string(settings.RANDOM_STRING_LENGTH)}]})
452
+ gemini_history.insert(len(gemini_history)-1,{'role': 'user', 'parts': [{'text': generate_secure_random_string(settings.RANDOM_STRING_LENGTH)}]})
453
+ log('INFO', "伪装消息成功")
454
+
455
+ return gemini_history, system_instruction
456
+
457
+ @staticmethod
458
+ async def list_available_models(api_key) -> list:
459
+ url = "https://generativelanguage.googleapis.com/v1beta/models?key={}".format(
460
+ api_key)
461
+ async with httpx.AsyncClient() as client:
462
+ response = await client.get(url)
463
+ response.raise_for_status()
464
+ data = response.json()
465
+ models = []
466
+ for model in data.get("models", []):
467
+ models.append(model["name"])
468
+ if model["name"].startswith("models/gemini-2") and settings.search["search_mode"]:
469
+ models.append(model["name"] + "-search")
470
+ models.extend(GeminiClient.EXTRA_MODELS)
471
+
472
+ return models
app/templates/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Templates package initialization
app/templates/assets/0506c607efda914c9388132c9cbb0c53.js ADDED
The diff for this file is too large to render. See raw diff
 
app/templates/assets/9a4f356975f1a7b8b7bad9e93c1becba.css ADDED
@@ -0,0 +1 @@
 
 
1
+ :root{--vt-c-white: #ffffff;--vt-c-white-soft: #f8f8f8;--vt-c-white-mute: #f2f2f2;--vt-c-black: #181818;--vt-c-black-soft: #222222;--vt-c-black-mute: #282828;--vt-c-indigo: #2c3e50;--vt-c-divider-light-1: rgba(60, 60, 60, .29);--vt-c-divider-light-2: rgba(60, 60, 60, .12);--vt-c-divider-dark-1: rgba(84, 84, 84, .65);--vt-c-divider-dark-2: rgba(84, 84, 84, .48);--vt-c-text-light-1: var(--vt-c-indigo);--vt-c-text-light-2: rgba(60, 60, 60, .66);--vt-c-text-dark-1: var(--vt-c-white);--vt-c-text-dark-2: rgba(235, 235, 235, .64)}:root{--color-background: var(--vt-c-white);--color-background-soft: var(--vt-c-white-soft);--color-background-mute: var(--vt-c-white-mute);--color-border: var(--vt-c-divider-light-2);--color-border-hover: var(--vt-c-divider-light-1);--color-heading: var(--vt-c-text-light-1);--color-text: var(--vt-c-text-light-1);--section-gap: 160px;--card-background: #ffffff;--card-border: #e0e0e0;--button-primary: #4f46e5;--button-primary-hover: #4338ca;--button-secondary: #f3f4f6;--button-secondary-hover: #e5e7eb;--button-secondary-text: #4b5563;--button-text: #ffffff;--stats-item-bg: #f8f9fa;--log-entry-bg: #f8f9fa;--log-entry-border: #e9ecef;--toggle-bg: #ccc;--toggle-active: #4f46e5;--gradient-primary: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);--gradient-secondary: linear-gradient(135deg, #3b82f6 0%, #2dd4bf 100%);--gradient-success: linear-gradient(135deg, #10b981 0%, #059669 100%);--gradient-warning: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);--gradient-danger: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);--gradient-info: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, .05);--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, .1), 0 2px 4px -1px rgba(0, 0, 0, .06);--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, .1), 0 4px 6px -2px rgba(0, 0, 0, .05);--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, .1), 0 10px 10px -5px rgba(0, 0, 0, .04);--radius-sm: .25rem;--radius-md: .375rem;--radius-lg: .5rem;--radius-xl: .75rem;--radius-2xl: 1rem;--radius-full: 9999px;--transition-fast: .15s;--transition-normal: .3s;--transition-slow: .5s}.dark-mode{--color-background: var(--vt-c-black);--color-background-soft: var(--vt-c-black-soft);--color-background-mute: var(--vt-c-black-mute);--color-border: var(--vt-c-divider-dark-2);--color-border-hover: var(--vt-c-divider-dark-1);--color-heading: var(--vt-c-text-dark-1);--color-text: rgba(255, 255, 255, .85);--card-background: #1a1a1a;--card-border: #2a2a2a;--button-primary: #8b5cf6;--button-primary-hover: #7c3aed;--button-secondary: #2a2a2a;--button-secondary-hover: #3a3a3a;--button-secondary-text: #f1f5f9;--button-text: #ffffff;--stats-item-bg: #151515;--log-entry-bg: #151515;--log-entry-border: #2a2a2a;--toggle-bg: #3a3a3a;--toggle-active: #8b5cf6;--gradient-primary: linear-gradient(135deg, #7c3aed 0%, #5b21b6 100%);--gradient-secondary: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);--gradient-success: linear-gradient(135deg, #10b981 0%, #047857 100%);--gradient-warning: linear-gradient(135deg, #f59e0b 0%, #b45309 100%);--gradient-danger: linear-gradient(135deg, #ef4444 0%, #b91c1c 100%);--gradient-info: linear-gradient(135deg, #3b82f6 0%, #1e40af 100%);--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, .3);--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, .4), 0 2px 4px -1px rgba(0, 0, 0, .2);--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, .4), 0 4px 6px -2px rgba(0, 0, 0, .2);--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, .4), 0 10px 10px -5px rgba(0, 0, 0, .2)}@media (prefers-color-scheme: dark){:root:not(.dark-mode):not(.light-mode){--color-background: var(--vt-c-black);--color-background-soft: var(--vt-c-black-soft);--color-background-mute: var(--vt-c-black-mute);--color-border: var(--vt-c-divider-dark-2);--color-border-hover: var(--vt-c-divider-dark-1);--color-heading: var(--vt-c-text-dark-1);--color-text: rgba(255, 255, 255, .85);--card-background: #1a1a1a;--card-border: #2a2a2a;--button-primary: #8b5cf6;--button-primary-hover: #7c3aed;--button-secondary: #2a2a2a;--button-secondary-hover: #3a3a3a;--button-secondary-text: #f1f5f9;--button-text: #ffffff;--stats-item-bg: #151515;--log-entry-bg: #151515;--log-entry-border: #2a2a2a;--toggle-bg: #3a3a3a;--toggle-active: #8b5cf6;--gradient-primary: linear-gradient(135deg, #7c3aed 0%, #5b21b6 100%);--gradient-secondary: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%);--gradient-success: linear-gradient(135deg, #10b981 0%, #047857 100%);--gradient-warning: linear-gradient(135deg, #f59e0b 0%, #b45309 100%);--gradient-danger: linear-gradient(135deg, #ef4444 0%, #b91c1c 100%);--gradient-info: linear-gradient(135deg, #3b82f6 0%, #1e40af 100%);--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, .3);--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, .4), 0 2px 4px -1px rgba(0, 0, 0, .2);--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, .4), 0 4px 6px -2px rgba(0, 0, 0, .2);--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, .4), 0 10px 10px -5px rgba(0, 0, 0, .2)}}*,*:before,*:after{box-sizing:border-box;margin:0;font-weight:400}body{min-height:100vh;color:var(--color-text);background:var(--color-background);transition:color .5s,background-color .5s;line-height:1.6;font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen,Ubuntu,Cantarell,Fira Sans,Droid Sans,Helvetica Neue,sans-serif;font-size:15px;text-rendering:optimizeLegibility;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}#app{max-width:1280px;margin:0 auto;padding:1rem;font-weight:400}a,.green{text-decoration:none;color:#00bd7e;transition:.4s;padding:3px}@media (hover: hover){a:hover{background-color:#00bd7e33}}body{margin:0;padding:0}.stats-grid[data-v-8b643ea6]{display:grid;grid-template-columns:repeat(3,1fr);gap:15px;margin-top:15px;margin-bottom:20px}@media (max-width: 768px){.stats-grid[data-v-8b643ea6]{gap:6px}}.stat-card[data-v-8b643ea6]{background-color:var(--stats-item-bg);padding:15px;border-radius:var(--radius-lg);text-align:center;box-shadow:var(--shadow-sm);transition:all .3s ease;position:relative;overflow:hidden;border:1px solid var(--card-border)}.stat-card[data-v-8b643ea6]:before{content:"";position:absolute;top:0;left:0;width:100%;height:4px;background:var(--gradient-secondary);opacity:0;transition:opacity .3s ease}.stat-card[data-v-8b643ea6]:hover:before{opacity:1}.stat-card[data-v-8b643ea6]:hover{transform:translateY(-5px);box-shadow:var(--shadow-md);border-color:var(--button-primary)}.stat-value[data-v-8b643ea6]{font-size:24px;font-weight:700;color:var(--button-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;transition:all .3s ease;margin-bottom:5px}.stat-label[data-v-8b643ea6]{font-size:14px;color:var(--color-text);margin-top:5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;transition:all .3s ease;opacity:.8}@media (max-width: 768px){.stat-card[data-v-8b643ea6]{padding:8px 5px}.stat-value[data-v-8b643ea6]{font-size:16px}.stat-label[data-v-8b643ea6]{font-size:11px;margin-top:3px}}@media (max-width: 480px){.stat-card[data-v-8b643ea6]{padding:6px 3px}.stat-value[data-v-8b643ea6]{font-size:14px}.stat-label[data-v-8b643ea6]{font-size:10px;margin-top:2px}}.api-calls-chart-container[data-v-d9262b8e]{margin:20px 0;border-radius:var(--radius-lg);background-color:var(--stats-item-bg);padding:15px;box-shadow:var(--shadow-sm);border:1px solid var(--card-border);transition:all .3s ease}.api-calls-chart-container[data-v-d9262b8e]:hover{box-shadow:var(--shadow-md);border-color:var(--button-primary);transform:translateY(-3px)}.chart-title[data-v-d9262b8e]{margin-top:0;margin-bottom:15px;color:var(--color-heading);font-weight:600;text-align:center}.chart-container[data-v-d9262b8e]{width:100%;height:350px}@media (max-width: 768px){.chart-container[data-v-d9262b8e]{height:300px}}@media (max-width: 480px){.chart-container[data-v-d9262b8e]{height:250px}}.api-key-stats-container[data-v-a6a7d43b]{margin-top:20px}.header-section[data-v-a6a7d43b]{display:flex;justify-content:space-between;align-items:center;margin-bottom:15px}.header-buttons[data-v-a6a7d43b]{display:flex;gap:10px}.add-api-key-button[data-v-a6a7d43b]{display:flex;align-items:center;gap:8px;background-color:var(--button-primary);color:#fff;border:none;border-radius:var(--radius-md);padding:8px 16px;font-size:14px;font-weight:500;cursor:pointer;transition:all .3s ease;box-shadow:var(--shadow-sm)}.add-api-key-button svg[data-v-a6a7d43b]{transition:transform .3s ease;stroke:#fff}.add-api-key-button[data-v-a6a7d43b]:hover{background-color:var(--button-primary-hover);transform:translateY(-2px);box-shadow:var(--shadow-md)}.add-api-key-button:hover svg[data-v-a6a7d43b]{transform:rotate(90deg);stroke:#fff}.test-api-key-button[data-v-a6a7d43b]{display:flex;align-items:center;gap:8px;background-color:var(--button-secondary);color:var(--button-secondary-text);border:1px solid var(--color-border);border-radius:var(--radius-md);padding:8px 16px;font-size:14px;font-weight:500;cursor:pointer;transition:all .3s ease;box-shadow:var(--shadow-sm)}.test-api-key-button svg[data-v-a6a7d43b]{transition:transform .3s ease;stroke:var(--button-secondary-text)}.test-api-key-button[data-v-a6a7d43b]:hover{background-color:var(--button-secondary-hover);transform:translateY(-2px);box-shadow:var(--shadow-md)}.test-api-key-button:hover svg[data-v-a6a7d43b]{transform:rotate(15deg)}.clear-invalid-keys-button[data-v-a6a7d43b]{display:flex;align-items:center;gap:8px;background-color:var(--button-danger, #dc3545);color:#fff;border:none;border-radius:var(--radius-md);padding:8px 16px;font-size:14px;font-weight:500;cursor:pointer;transition:all .3s ease;box-shadow:var(--shadow-sm)}.clear-invalid-keys-button svg[data-v-a6a7d43b]{transition:transform .3s ease;stroke:#fff}.clear-invalid-keys-button[data-v-a6a7d43b]:hover{background-color:var(--button-danger-hover, #c82333);transform:translateY(-2px);box-shadow:var(--shadow-md)}.clear-invalid-keys-button:hover svg[data-v-a6a7d43b]{transform:scale(1.1)}.export-valid-keys-button[data-v-a6a7d43b]{display:flex;align-items:center;gap:8px;background-color:var(--button-success, #28a745);color:#fff;border:none;border-radius:var(--radius-md);padding:8px 16px;font-size:14px;font-weight:500;cursor:pointer;transition:all .3s ease;box-shadow:var(--shadow-sm)}.export-valid-keys-button svg[data-v-a6a7d43b]{transition:transform .3s ease;stroke:#fff}.export-valid-keys-button[data-v-a6a7d43b]:hover{background-color:var(--button-success-hover, #218838);transform:translateY(-2px);box-shadow:var(--shadow-md)}.export-valid-keys-button:hover svg[data-v-a6a7d43b]{transform:translateY(-1px)}.api-key-test-form[data-v-a6a7d43b],.clear-invalid-keys-form[data-v-a6a7d43b],.export-valid-keys-form[data-v-a6a7d43b]{background-color:var(--color-background-mute);border-radius:var(--radius-lg);padding:20px;margin-bottom:20px;border:1px solid var(--card-border);box-shadow:var(--shadow-md)}.exported-keys-container[data-v-a6a7d43b]{margin-top:15px}.exported-keys-header[data-v-a6a7d43b]{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px}.exported-keys-header h5[data-v-a6a7d43b]{font-size:14px;font-weight:600;color:var(--color-heading);margin:0}.copy-all-button[data-v-a6a7d43b]{display:flex;align-items:center;gap:6px;background-color:var(--button-primary);color:#fff;border:none;border-radius:var(--radius-sm);padding:6px 12px;font-size:12px;font-weight:500;cursor:pointer;transition:all .3s ease}.copy-all-button[data-v-a6a7d43b]:hover{background-color:var(--button-primary-hover);transform:translateY(-1px)}.copy-all-button svg[data-v-a6a7d43b]{stroke:#fff}.exported-keys-list[data-v-a6a7d43b]{max-height:300px;overflow-y:auto;border:1px solid var(--color-border);border-radius:var(--radius-md);background-color:var(--color-background)}.exported-key-item[data-v-a6a7d43b]{display:flex;justify-content:space-between;align-items:center;padding:10px 12px;border-bottom:1px solid var(--color-border);transition:background-color .2s ease}.exported-key-item[data-v-a6a7d43b]:last-child{border-bottom:none}.exported-key-item[data-v-a6a7d43b]:hover{background-color:var(--color-background-mute)}.key-text[data-v-a6a7d43b]{font-family:Courier New,monospace;font-size:13px;color:var(--color-text);word-break:break-all;flex:1;margin-right:10px}.copy-key-button[data-v-a6a7d43b]{display:flex;align-items:center;justify-content:center;background-color:transparent;color:var(--color-text-muted);border:1px solid var(--color-border);border-radius:var(--radius-sm);padding:4px;cursor:pointer;transition:all .2s ease;min-width:28px;height:28px}.copy-key-button[data-v-a6a7d43b]:hover{background-color:var(--button-primary);color:#fff;border-color:var(--button-primary)}.copy-key-button svg[data-v-a6a7d43b]{stroke:currentColor}.form-title[data-v-a6a7d43b]{margin-bottom:15px}.form-title h4[data-v-a6a7d43b]{font-size:16px;font-weight:600;color:var(--color-heading);margin-bottom:8px}.form-description[data-v-a6a7d43b]{font-size:14px;color:var(--color-text);line-height:1.5;opacity:.8}.testing-progress[data-v-a6a7d43b]{margin:15px 0}.progress-bar-container[data-v-a6a7d43b]{height:10px;background-color:var(--color-background-soft);border-radius:var(--radius-full);overflow:hidden;margin-bottom:10px}.progress-bar-fill[data-v-a6a7d43b]{height:100%;background:var(--gradient-primary);border-radius:var(--radius-full);transition:width .3s ease;position:relative}.progress-bar-fill[data-v-a6a7d43b]:after{content:"";position:absolute;top:0;left:0;width:100%;height:100%;background:linear-gradient(90deg,transparent,rgba(255,255,255,.2),transparent);transform:translate(-100%);animation:progressShine-a6a7d43b 2s infinite}.progress-text[data-v-a6a7d43b]{font-size:14px;text-align:center;color:var(--color-heading)}.slide-enter-active[data-v-a6a7d43b],.slide-leave-active[data-v-a6a7d43b]{transition:all .3s ease;max-height:500px;opacity:1;overflow:hidden}.slide-enter-from[data-v-a6a7d43b],.slide-leave-to[data-v-a6a7d43b]{max-height:0;opacity:0;padding:0;margin:0;overflow:hidden}.stats-grid[data-v-a6a7d43b]{display:grid;grid-template-columns:repeat(3,1fr);gap:15px;margin-top:15px;margin-bottom:20px}.stat-card[data-v-a6a7d43b]{background-color:var(--stats-item-bg);padding:15px;border-radius:var(--radius-lg);text-align:center;box-shadow:var(--shadow-sm);transition:all .3s ease;position:relative;overflow:hidden;border:1px solid var(--card-border)}.stat-card[data-v-a6a7d43b]:before{content:"";position:absolute;top:0;left:0;width:100%;height:4px;background:var(--gradient-secondary);opacity:0;transition:opacity .3s ease}.stat-card[data-v-a6a7d43b]:hover:before{opacity:1}.stat-card[data-v-a6a7d43b]:hover{transform:translateY(-5px);box-shadow:var(--shadow-md);border-color:var(--button-primary)}.stat-value[data-v-a6a7d43b]{font-size:24px;font-weight:700;color:var(--button-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;transition:all .3s ease;margin-bottom:5px}.stat-label[data-v-a6a7d43b]{font-size:14px;color:var(--color-text);margin-top:5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;transition:all .3s ease;opacity:.8}.stats-summary[data-v-a6a7d43b]{display:flex;justify-content:space-between;margin-bottom:20px;background-color:var(--color-background-mute);border-radius:var(--radius-lg);padding:15px;border:1px solid var(--card-border)}.summary-item[data-v-a6a7d43b]{display:flex;flex-direction:column;align-items:center;flex:1}.summary-label[data-v-a6a7d43b]{font-size:12px;color:var(--color-text);opacity:.8;margin-bottom:5px}.summary-value[data-v-a6a7d43b]{font-size:18px;font-weight:600;color:var(--button-primary)}.api-key-stats-list[data-v-a6a7d43b]{display:grid;grid-template-columns:repeat(3,1fr);gap:15px;margin-top:15px}.api-key-item[data-v-a6a7d43b]{background-color:var(--stats-item-bg);border-radius:var(--radius-lg);padding:15px;box-shadow:var(--shadow-sm);transition:all .3s ease;position:relative;overflow:hidden;border:1px solid var(--card-border)}.api-key-item[data-v-a6a7d43b]:before{content:"";position:absolute;top:0;left:0;width:100%;height:4px;background:var(--gradient-info);opacity:0;transition:opacity .3s ease}.api-key-item[data-v-a6a7d43b]:hover:before{opacity:1}.api-key-item[data-v-a6a7d43b]:hover{transform:translateY(-3px);box-shadow:var(--shadow-md);border-color:var(--button-primary)}.api-key-header[data-v-a6a7d43b]{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px}.api-key-name[data-v-a6a7d43b]{font-weight:700;color:var(--color-heading);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:50%;transition:all .3s ease}.api-key-usage[data-v-a6a7d43b]{display:flex;align-items:center;gap:10px;white-space:nowrap}.api-key-count[data-v-a6a7d43b]{font-weight:700;color:var(--button-primary);transition:all .3s ease}.progress-container[data-v-a6a7d43b]{width:100%;height:10px;background-color:var(--color-background-soft);border-radius:var(--radius-full);overflow:hidden;transition:all .3s ease;margin:10px 0}.progress-bar[data-v-a6a7d43b]{height:100%;border-radius:var(--radius-full);transition:width .5s ease,background-color .3s;position:relative;overflow:hidden}.progress-bar[data-v-a6a7d43b]:after{content:"";position:absolute;top:0;left:0;width:100%;height:100%;background:linear-gradient(90deg,transparent,rgba(255,255,255,.2),transparent);transform:translate(-100%);animation:progressShine-a6a7d43b 2s infinite}@keyframes progressShine-a6a7d43b{0%{transform:translate(-100%)}to{transform:translate(100%)}}.progress-bar.low[data-v-a6a7d43b]{background:var(--gradient-success)}.progress-bar.medium[data-v-a6a7d43b]{background:var(--gradient-warning)}.progress-bar.high[data-v-a6a7d43b]{background:var(--gradient-danger)}.model-stats-container[data-v-a6a7d43b]{margin-top:10px;border-top:1px dashed var(--color-border);padding-top:10px;transition:all .3s ease}.model-stats-header[data-v-a6a7d43b]{display:flex;justify-content:space-between;align-items:center;cursor:pointer;-webkit-user-select:none;user-select:none;margin-bottom:8px;color:var(--color-heading);font-size:14px;transition:all .3s ease;padding:5px 8px;border-radius:var(--radius-md)}.model-stats-header[data-v-a6a7d43b]:hover{background-color:var(--color-background-mute)}.model-stats-title[data-v-a6a7d43b]{font-weight:600}.model-stats-list[data-v-a6a7d43b]{display:flex;flex-direction:column;gap:8px}.model-stat-item[data-v-a6a7d43b]{display:flex;justify-content:space-between;align-items:flex-start;padding:10px;background-color:var(--color-background-mute);border-radius:var(--radius-md);font-size:13px;transition:all .3s ease;border:1px solid transparent}.model-stat-item[data-v-a6a7d43b]:hover{transform:translate(5px);box-shadow:var(--shadow-sm);border-color:var(--button-primary)}.model-info[data-v-a6a7d43b]{display:flex;flex-direction:column;gap:4px;width:100%}.model-name[data-v-a6a7d43b]{font-weight:500;color:var(--color-heading);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%;transition:all .3s ease}.model-count[data-v-a6a7d43b]{display:flex;align-items:center;gap:8px;color:var(--button-primary);font-weight:600;transition:all .3s ease}.model-usage-text[data-v-a6a7d43b]{color:var(--color-text);font-weight:400;font-size:12px;transition:all .3s ease;opacity:.8}.model-tokens[data-v-a6a7d43b]{font-size:12px;color:var(--color-text);opacity:.8;transition:all .3s ease}.view-more-models[data-v-a6a7d43b]{text-align:center;color:var(--button-primary);font-size:12px;cursor:pointer;padding:8px;margin-top:5px;border-radius:var(--radius-md);background-color:#4f46e50d;transition:all .3s ease;border:1px dashed var(--button-primary)}.view-more-models[data-v-a6a7d43b]:hover{background-color:#4f46e51a;transform:translateY(-2px);box-shadow:var(--shadow-sm)}.section-title[data-v-a6a7d43b]{color:var(--color-heading);border-bottom:1px solid var(--color-border);padding-bottom:10px;transition:all .3s ease;position:relative;font-weight:600;margin:0}.section-title[data-v-a6a7d43b]:after{content:"";position:absolute;bottom:-1px;left:0;width:50px;height:2px;background:var(--gradient-primary)}.fold-header[data-v-a6a7d43b]{cursor:pointer;-webkit-user-select:none;user-select:none;display:flex;justify-content:space-between;align-items:center;transition:all .3s ease;border-radius:var(--radius-md);padding:8px 12px;background-color:var(--color-background-mute);margin-bottom:0;margin-right:10px;flex:1}.fold-header[data-v-a6a7d43b]:hover{background-color:var(--color-background-soft);transform:translateY(-2px);box-shadow:var(--shadow-sm)}.fold-icon[data-v-a6a7d43b]{display:inline-flex;align-items:center;justify-content:center;transition:transform .3s ease}.fold-icon.rotated[data-v-a6a7d43b]{transform:rotate(180deg)}.fold-content[data-v-a6a7d43b]{overflow:hidden}.fold-enter-active[data-v-a6a7d43b],.fold-leave-active[data-v-a6a7d43b]{transition:all .3s ease;max-height:1000px;opacity:1;overflow:hidden}.fold-enter-from[data-v-a6a7d43b],.fold-leave-to[data-v-a6a7d43b]{max-height:0;opacity:0;overflow:hidden}.total-tokens[data-v-a6a7d43b]{margin-top:6px;padding:8px 12px;background-color:var(--color-background-mute);border-radius:var(--radius-md);display:flex;align-items:center;gap:6px;transition:all .3s ease;border:1px solid var(--card-border)}.total-tokens[data-v-a6a7d43b]:hover{background-color:var(--color-background-soft);transform:translateY(-2px);box-shadow:var(--shadow-sm);border-color:var(--button-primary)}.total-tokens-label[data-v-a6a7d43b]{font-size:11px;color:var(--color-text);opacity:.8;white-space:nowrap;transition:all .3s ease}.total-tokens-value[data-v-a6a7d43b]{font-size:13px;font-weight:600;color:var(--button-primary);transition:all .3s ease}.pagination[data-v-a6a7d43b]{display:flex;justify-content:center;align-items:center;margin-top:20px;gap:15px}.pagination-button[data-v-a6a7d43b]{background-color:var(--button-secondary);color:var(--button-secondary-text);border:none;border-radius:var(--radius-md);padding:8px 16px;cursor:pointer;transition:all .3s ease;font-weight:500}.pagination-button[data-v-a6a7d43b]:hover:not(:disabled){background-color:var(--button-secondary-hover);transform:translateY(-2px);box-shadow:var(--shadow-sm)}.pagination-button[data-v-a6a7d43b]:disabled{opacity:.5;cursor:not-allowed}.pagination-info[data-v-a6a7d43b]{font-size:14px;color:var(--color-text)}@media (max-width: 768px){.header-section[data-v-a6a7d43b]{flex-direction:column;align-items:flex-start;gap:15px}.header-buttons[data-v-a6a7d43b]{display:grid;grid-template-columns:repeat(2,1fr);gap:8px;width:100%}.add-api-key-button[data-v-a6a7d43b],.test-api-key-button[data-v-a6a7d43b],.clear-invalid-keys-button[data-v-a6a7d43b],.export-valid-keys-button[data-v-a6a7d43b]{padding:8px 12px;font-size:12px;justify-content:center;min-height:36px;gap:6px}.add-api-key-button svg[data-v-a6a7d43b],.test-api-key-button svg[data-v-a6a7d43b],.clear-invalid-keys-button svg[data-v-a6a7d43b],.export-valid-keys-button svg[data-v-a6a7d43b]{width:14px;height:14px;flex-shrink:0}.api-key-stats-list[data-v-a6a7d43b]{grid-template-columns:1fr}.api-key-item[data-v-a6a7d43b]{padding:10px}.api-key-name[data-v-a6a7d43b]{font-size:13px;max-width:100%;color:var(--button-primary)}.api-key-usage[data-v-a6a7d43b]{font-size:12px;gap:5px}.model-stats-container[data-v-a6a7d43b]{margin-top:10px}.model-info[data-v-a6a7d43b]{gap:3px}.total-tokens-label[data-v-a6a7d43b]{color:var(--color-heading);opacity:.9}.api-key-test-form[data-v-a6a7d43b],.clear-invalid-keys-form[data-v-a6a7d43b],.export-valid-keys-form[data-v-a6a7d43b]{padding:15px}.form-title h4[data-v-a6a7d43b]{font-size:14px}.form-description[data-v-a6a7d43b],.progress-text[data-v-a6a7d43b]{font-size:12px}.exported-keys-list[data-v-a6a7d43b]{max-height:250px}.exported-key-item[data-v-a6a7d43b]{padding:8px 10px}.key-text[data-v-a6a7d43b]{font-size:12px}.copy-key-button[data-v-a6a7d43b]{min-width:24px;height:24px;padding:3px}.copy-all-button[data-v-a6a7d43b]{padding:5px 10px;font-size:11px}}@media (max-width: 480px){.header-buttons[data-v-a6a7d43b]{grid-template-columns:1fr;gap:6px}.add-api-key-button[data-v-a6a7d43b],.test-api-key-button[data-v-a6a7d43b],.clear-invalid-keys-button[data-v-a6a7d43b],.export-valid-keys-button[data-v-a6a7d43b]{padding:10px 12px;font-size:13px;min-height:40px;gap:8px}.add-api-key-button svg[data-v-a6a7d43b],.test-api-key-button svg[data-v-a6a7d43b],.clear-invalid-keys-button svg[data-v-a6a7d43b],.export-valid-keys-button svg[data-v-a6a7d43b]{width:16px;height:16px;flex-shrink:0}.api-key-test-form[data-v-a6a7d43b],.clear-invalid-keys-form[data-v-a6a7d43b],.export-valid-keys-form[data-v-a6a7d43b]{padding:12px;margin-bottom:15px}.form-title h4[data-v-a6a7d43b]{font-size:15px}.form-description[data-v-a6a7d43b]{font-size:13px}.form-actions[data-v-a6a7d43b]{flex-direction:column;gap:8px}.submit-api-key[data-v-a6a7d43b],.cancel-api-key[data-v-a6a7d43b]{width:100%;padding:10px;font-size:14px}.exported-keys-header[data-v-a6a7d43b]{flex-direction:column;align-items:flex-start;gap:8px}.copy-all-button[data-v-a6a7d43b]{align-self:flex-end}}@media (max-width: 360px){.header-buttons[data-v-a6a7d43b]{gap:4px}.add-api-key-button[data-v-a6a7d43b],.test-api-key-button[data-v-a6a7d43b],.clear-invalid-keys-button[data-v-a6a7d43b],.export-valid-keys-button[data-v-a6a7d43b]{padding:8px 10px;font-size:12px;min-height:36px;gap:6px}.add-api-key-button svg[data-v-a6a7d43b],.test-api-key-button svg[data-v-a6a7d43b],.clear-invalid-keys-button svg[data-v-a6a7d43b],.export-valid-keys-button svg[data-v-a6a7d43b]{width:14px;height:14px}.api-key-test-form[data-v-a6a7d43b],.clear-invalid-keys-form[data-v-a6a7d43b],.export-valid-keys-form[data-v-a6a7d43b]{padding:10px}.form-title h4[data-v-a6a7d43b]{font-size:14px}.form-description[data-v-a6a7d43b]{font-size:12px}.submit-api-key[data-v-a6a7d43b],.cancel-api-key[data-v-a6a7d43b]{padding:8px;font-size:13px}}@media (max-width: 992px){.api-key-stats-list[data-v-a6a7d43b]{grid-template-columns:repeat(2,1fr)}}@media (max-width: 576px){.api-key-stats-list[data-v-a6a7d43b]{grid-template-columns:1fr}}.api-key-input-form[data-v-a6a7d43b]{background-color:var(--color-background-mute);border-radius:var(--radius-lg);padding:20px;margin-bottom:20px;border:1px solid var(--card-border);box-shadow:var(--shadow-md)}.form-group[data-v-a6a7d43b]{margin-bottom:15px}.form-group label[data-v-a6a7d43b]{display:block;margin-bottom:8px;font-size:14px;font-weight:500;color:var(--color-heading)}.api-key-textarea[data-v-a6a7d43b]{width:100%;padding:10px;border:1px solid var(--color-border);border-radius:var(--radius-md);background-color:var(--color-background);color:var(--color-text);font-family:inherit;font-size:14px;resize:vertical;transition:all .3s ease}.api-key-textarea[data-v-a6a7d43b]:focus{outline:none;border-color:var(--button-primary);box-shadow:0 0 0 2px #4f46e51a}.api-key-password[data-v-a6a7d43b]{width:100%;padding:10px;border:1px solid var(--color-border);border-radius:var(--radius-md);background-color:var(--color-background);color:var(--color-text);font-family:inherit;font-size:14px;transition:all .3s ease}.api-key-password[data-v-a6a7d43b]:focus{outline:none;border-color:var(--button-primary);box-shadow:0 0 0 2px #4f46e51a}.api-key-error[data-v-a6a7d43b]{color:var(--color-danger);font-size:14px;margin-bottom:15px;padding:10px;background-color:#ef44441a;border-radius:var(--radius-md);border-left:3px solid var(--color-danger)}.api-key-success[data-v-a6a7d43b]{color:var(--color-success);font-size:14px;margin-bottom:15px;padding:10px;background-color:#22c55e1a;border-radius:var(--radius-md);border-left:3px solid var(--color-success)}.form-actions[data-v-a6a7d43b]{display:flex;gap:10px}.submit-api-key[data-v-a6a7d43b]{padding:8px 20px;background-color:var(--button-primary);color:#fff;border:none;border-radius:var(--radius-md);font-weight:500;cursor:pointer;transition:all .3s ease}.submit-api-key[data-v-a6a7d43b]:hover:not(:disabled){background-color:var(--button-primary-hover);transform:translateY(-2px);box-shadow:var(--shadow-sm)}.submit-api-key[data-v-a6a7d43b]:disabled{opacity:.5;cursor:not-allowed}.cancel-api-key[data-v-a6a7d43b]{padding:8px 20px;background-color:var(--button-secondary);color:var(--button-secondary-text);border:none;border-radius:var(--radius-md);font-weight:500;cursor:pointer;transition:all .3s ease}.cancel-api-key[data-v-a6a7d43b]:hover:not(:disabled){background-color:var(--button-secondary-hover);transform:translateY(-2px);box-shadow:var(--shadow-sm)}.cancel-api-key[data-v-a6a7d43b]:disabled{opacity:.5;cursor:not-allowed}.info-box[data-v-257ff3a7]{background-color:var(--card-background);border:1px solid var(--card-border);border-radius:var(--radius-xl);padding:20px;margin-bottom:20px;box-shadow:var(--shadow-md);transition:all .3s ease;position:relative;overflow:hidden}.info-box[data-v-257ff3a7]:before{content:"";position:absolute;top:0;left:0;width:4px;height:100%;background:var(--gradient-success);opacity:.8}@media (max-width: 768px){.info-box[data-v-257ff3a7]{margin-bottom:12px;padding:15px 10px;border-radius:var(--radius-lg)}}@media (max-width: 480px){.info-box[data-v-257ff3a7]{margin-bottom:8px;padding:12px 8px;border-radius:var(--radius-md)}}.section-header[data-v-257ff3a7]{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;position:relative}.status-container[data-v-257ff3a7]{display:flex;align-items:center;justify-content:center;position:absolute;left:50%;transform:translate(-50%)}.reset-button[data-v-257ff3a7]{display:flex;align-items:center;gap:5px;background-color:var(--button-secondary);color:var(--button-secondary-text);border:none;border-radius:var(--radius-md);padding:8px 12px;font-size:14px;cursor:pointer;transition:all .3s ease;position:relative;overflow:hidden;box-shadow:var(--shadow-sm);height:100%;z-index:1}.reset-button[data-v-257ff3a7]:before{content:"";position:absolute;top:0;left:0;width:100%;height:100%;background:linear-gradient(90deg,transparent,rgba(255,255,255,.1),transparent);transform:translate(-100%);transition:transform .6s ease}.reset-button[data-v-257ff3a7]:hover:before{transform:translate(100%)}.reset-button[data-v-257ff3a7]:hover{background-color:var(--button-secondary-hover);transform:translateY(-2px);box-shadow:var(--shadow-md)}.reset-button svg[data-v-257ff3a7]{transition:transform .3s}.reset-button:hover svg[data-v-257ff3a7]{transform:rotate(180deg)}.dialog-overlay[data-v-257ff3a7]{position:fixed;top:0;left:0;right:0;bottom:0;background-color:#00000080;display:flex;justify-content:center;align-items:flex-start;z-index:1000;padding-top:20px;-webkit-backdrop-filter:blur(5px);backdrop-filter:blur(5px)}.dialog[data-v-257ff3a7]{background-color:var(--card-background);border-radius:var(--radius-xl);padding:20px;width:90%;max-width:400px;box-shadow:var(--shadow-xl);margin-top:20px;position:relative;overflow:hidden;animation:dialogAppear-257ff3a7 .3s ease forwards}@keyframes dialogAppear-257ff3a7{0%{opacity:0;transform:translateY(-20px) scale(.95)}to{opacity:1;transform:translateY(0) scale(1)}}.dialog[data-v-257ff3a7]:before{content:"";position:absolute;top:0;left:0;width:100%;height:4px;background:var(--gradient-primary)}.dialog h3[data-v-257ff3a7]{margin-top:0;margin-bottom:10px;color:var(--color-heading);font-size:1.2rem;font-weight:600}.dialog p[data-v-257ff3a7]{margin-bottom:15px;color:var(--color-text);font-size:14px;line-height:1.5}.dialog input[data-v-257ff3a7]{width:100%;padding:12px 16px;border:1px solid var(--color-border);border-radius:var(--radius-md);margin-bottom:15px;background-color:var(--color-background);color:var(--color-text);transition:all .3s ease;font-size:14px}.dialog input[data-v-257ff3a7]:focus{outline:none;border-color:var(--button-primary);box-shadow:0 0 0 3px #4f46e533}.error-message[data-v-257ff3a7]{color:#ef4444;margin-bottom:15px;font-size:14px;padding:8px 12px;background-color:#ef44441a;border-radius:var(--radius-md);border-left:3px solid #ef4444}.dialog-buttons[data-v-257ff3a7]{display:flex;justify-content:flex-end;gap:10px}.cancel-button[data-v-257ff3a7]{background-color:var(--button-secondary);color:var(--button-secondary-text);border:none;border-radius:var(--radius-md);padding:10px 18px;cursor:pointer;transition:all .3s ease;font-weight:500}.cancel-button[data-v-257ff3a7]:hover{background-color:var(--button-secondary-hover);transform:translateY(-2px);box-shadow:var(--shadow-sm)}.confirm-button[data-v-257ff3a7]{background:var(--gradient-primary);color:#fff;border:none;border-radius:var(--radius-md);padding:10px 18px;cursor:pointer;transition:all .3s ease;font-weight:500;box-shadow:var(--shadow-sm)}.confirm-button[data-v-257ff3a7]:hover:not(:disabled){transform:translateY(-2px);box-shadow:var(--shadow-md)}.confirm-button[data-v-257ff3a7]:disabled{opacity:.7;cursor:not-allowed;transform:none;box-shadow:none}.status[data-v-257ff3a7]{color:#10b981;font-weight:700;font-size:16px;padding:8px 12px;background-color:#10b9811a;border-radius:var(--radius-md);border-left:none;transition:all .3s ease;animation:pulse-257ff3a7 2s infinite;margin:0;white-space:nowrap}@keyframes pulse-257ff3a7{0%{box-shadow:0 0 #10b98166}70%{box-shadow:0 0 0 10px #10b98100}to{box-shadow:0 0 #10b98100}}.section-title[data-v-257ff3a7]{color:var(--color-heading);border-bottom:1px solid var(--color-border);padding-bottom:10px;transition:all .3s ease;position:relative;font-weight:600;margin:0}.section-title[data-v-257ff3a7]:after{content:"";position:absolute;bottom:-1px;left:0;width:50px;height:2px;background:var(--gradient-primary)}.vertex-notice[data-v-257ff3a7]{background-color:var(--color-background-soft);border-radius:var(--radius-lg);padding:16px;margin:20px 0;display:flex;gap:16px;align-items:flex-start;border:1px solid var(--color-border);transition:all .3s ease;position:relative;overflow:hidden}.vertex-notice[data-v-257ff3a7]:before{content:"";position:absolute;top:0;left:0;width:4px;height:100%;background:var(--gradient-info);opacity:.8}.vertex-notice[data-v-257ff3a7]:hover{transform:translateY(-3px);box-shadow:var(--shadow-md);border-color:var(--button-primary)}.notice-icon[data-v-257ff3a7]{font-size:24px;background-color:var(--color-background-mute);padding:8px;border-radius:50%;display:flex;align-items:center;justify-content:center;min-width:40px;height:40px;transition:all .3s ease;box-shadow:var(--shadow-sm)}.notice-content[data-v-257ff3a7]{flex:1}.notice-title[data-v-257ff3a7]{color:var(--color-heading);font-size:16px;font-weight:600;margin:0 0 8px;transition:all .3s ease}.notice-text[data-v-257ff3a7]{color:var(--color-text);font-size:14px;line-height:1.5;margin:0;transition:all .3s ease}@media (max-width: 768px){.vertex-notice[data-v-257ff3a7]{padding:12px;gap:12px}.notice-icon[data-v-257ff3a7]{font-size:20px;min-width:32px;height:32px;padding:6px}.notice-title[data-v-257ff3a7]{font-size:14px;margin-bottom:6px}.notice-text[data-v-257ff3a7]{font-size:12px}.status[data-v-257ff3a7]{font-size:14px;padding:6px 10px}.reset-button[data-v-257ff3a7]{font-size:12px;padding:6px 10px}}@media (max-width: 480px){.status[data-v-257ff3a7]{font-size:12px;padding:4px 8px}.reset-button[data-v-257ff3a7]{font-size:11px;padding:4px 8px}.section-header[data-v-257ff3a7]{flex-direction:row;align-items:center;justify-content:space-between;gap:8px;position:relative;padding-top:0}.section-title[data-v-257ff3a7]{font-size:14px;margin-right:auto}.status-container[data-v-257ff3a7]{position:static;transform:none;margin:0}.reset-button[data-v-257ff3a7]{align-self:center}}.section-title[data-v-bf7ce7b9]{color:var(--color-heading);border-bottom:1px solid var(--color-border);padding-bottom:10px;margin-bottom:20px;transition:all .3s ease;position:relative;font-weight:600}.section-title[data-v-bf7ce7b9]:after{content:"";position:absolute;bottom:-1px;left:0;width:50px;height:2px;background:var(--gradient-primary)}.basic-config[data-v-bf7ce7b9]{margin-bottom:25px}.config-form[data-v-bf7ce7b9]{background-color:var(--stats-item-bg);border-radius:var(--radius-lg);padding:20px;box-shadow:var(--shadow-sm);border:1px solid var(--card-border)}.config-row[data-v-bf7ce7b9]{display:flex;gap:15px;margin-bottom:15px;flex-wrap:wrap}.config-group[data-v-bf7ce7b9]{flex:1;min-width:120px}.config-label[data-v-bf7ce7b9]{display:block;font-size:14px;margin-bottom:5px;color:var(--color-text);font-weight:500}.config-input[data-v-bf7ce7b9]{width:100%;padding:8px 12px;border:1px solid var(--color-border);border-radius:var(--radius-md);background-color:var(--color-background);color:var(--color-text);font-size:14px;transition:all .3s ease}.config-input[data-v-bf7ce7b9]:focus{outline:none;border-color:var(--button-primary);box-shadow:0 0 0 2px #4f46e533}@media (max-width: 768px){.config-row[data-v-bf7ce7b9]{gap:10px}.config-group[data-v-bf7ce7b9]{min-width:100px}}@media (max-width: 480px){.config-row[data-v-bf7ce7b9]{flex-direction:column;gap:10px}.config-group[data-v-bf7ce7b9]{width:100%}.config-form[data-v-bf7ce7b9]{padding:15px}}.section-title[data-v-dc697ea9]{color:var(--color-heading);border-bottom:1px solid var(--color-border);padding-bottom:10px;margin-bottom:20px;transition:all .3s ease;position:relative;font-weight:600}.section-title[data-v-dc697ea9]:after{content:"";position:absolute;bottom:-1px;left:0;width:50px;height:2px;background:var(--gradient-primary)}.features-config[data-v-dc697ea9]{margin-bottom:25px}.config-form[data-v-dc697ea9]{background-color:var(--stats-item-bg);border-radius:var(--radius-lg);padding:20px;box-shadow:var(--shadow-sm);border:1px solid var(--card-border)}.config-row[data-v-dc697ea9]{display:flex;gap:15px;margin-bottom:15px;flex-wrap:wrap}.config-group[data-v-dc697ea9]{flex:1;min-width:120px}.full-width[data-v-dc697ea9]{flex-basis:100%}.config-label[data-v-dc697ea9]{display:block;font-size:14px;margin-bottom:5px;color:var(--color-text);font-weight:500}.config-input[data-v-dc697ea9]{width:100%;padding:8px 12px;border:1px solid var(--color-border);border-radius:var(--radius-md);background-color:var(--color-background);color:var(--color-text);font-size:14px;transition:all .3s ease}.config-input[data-v-dc697ea9]:focus{outline:none;border-color:var(--button-primary);box-shadow:0 0 0 2px #4f46e533}.toggle-wrapper[data-v-dc697ea9]{position:relative}.toggle[data-v-dc697ea9]{position:absolute;opacity:0;width:0;height:0}.toggle-label[data-v-dc697ea9]{display:flex;align-items:center;cursor:pointer;-webkit-user-select:none;user-select:none}.toggle-label[data-v-dc697ea9]:before{content:"";display:inline-block;width:36px;height:20px;background-color:var(--color-border);border-radius:10px;margin-right:8px;position:relative;transition:all .3s ease}.toggle-label[data-v-dc697ea9]:after{content:"";position:absolute;left:3px;width:14px;height:14px;background-color:#fff;border-radius:50%;transition:all .3s ease}.toggle:checked+.toggle-label[data-v-dc697ea9]:before{background-color:var(--button-primary)}.toggle:checked+.toggle-label[data-v-dc697ea9]:after{left:19px}.toggle-text[data-v-dc697ea9]{font-size:14px;color:var(--color-text)}@media (max-width: 768px){.config-row[data-v-dc697ea9]{gap:10px}.config-group[data-v-dc697ea9]{min-width:100px}}@media (max-width: 480px){.config-row[data-v-dc697ea9]{flex-direction:column;gap:10px}.config-group[data-v-dc697ea9]{width:100%}.config-form[data-v-dc697ea9]{padding:15px}}.section-title[data-v-e1a72b08]{color:var(--color-heading);border-bottom:1px solid var(--color-border);padding-bottom:10px;margin-bottom:20px;transition:all .3s ease;position:relative;font-weight:600}.section-title[data-v-e1a72b08]:after{content:"";position:absolute;bottom:-1px;left:0;width:50px;height:2px;background:var(--gradient-primary)}.stats-grid[data-v-e1a72b08]{display:grid;grid-template-columns:repeat(3,1fr);gap:15px;margin-top:15px;margin-bottom:20px}@media (max-width: 768px){.stats-grid[data-v-e1a72b08]{gap:8px}}.stat-card[data-v-e1a72b08]{background-color:var(--stats-item-bg);padding:10px 15px;border-radius:var(--radius-lg);text-align:center;box-shadow:var(--shadow-sm);transition:all .3s ease;position:relative;overflow:hidden;border:1px solid var(--card-border)}.stat-card[data-v-e1a72b08]:before{content:"";position:absolute;top:0;left:0;width:100%;height:3px;background:var(--gradient-secondary);opacity:0;transition:opacity .3s ease}.stat-card[data-v-e1a72b08]:hover{transform:translateY(-3px);box-shadow:var(--shadow-md);border-color:var(--button-primary)}.stat-card[data-v-e1a72b08]:hover:before{opacity:1}.stat-value[data-v-e1a72b08]{font-size:24px;font-weight:700;color:var(--button-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;transition:all .3s ease;margin-bottom:5px;position:relative;display:inline-block}.stat-label[data-v-e1a72b08]{font-size:14px;color:var(--color-text);margin-top:5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;transition:all .3s ease;opacity:.8}.stat-card:hover .stat-label[data-v-e1a72b08]{opacity:1;color:var(--color-heading)}.update-status-container[data-v-e1a72b08]{display:flex;align-items:center;justify-content:center;min-height:40px;width:100%}.update-status[data-v-e1a72b08]{display:flex;align-items:center;justify-content:center;gap:8px;padding:8px 12px;border-radius:var(--radius-lg);transition:all .3s ease;width:100%;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.update-status .status-icon[data-v-e1a72b08]{font-size:1.2em;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0}.update-status .status-text[data-v-e1a72b08]{font-size:1em;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.update-status .status-icon.update-needed[data-v-e1a72b08],.update-status .status-text.update-needed[data-v-e1a72b08]{color:#ef4444}.update-status .status-icon.up-to-date[data-v-e1a72b08],.update-status .status-text.up-to-date[data-v-e1a72b08]{color:#10b981}.project-link-container[data-v-e1a72b08]{display:flex;justify-content:center;align-items:center;padding:15px;margin-top:20px;transition:all .3s ease}.project-link[data-v-e1a72b08]{display:flex;align-items:center;gap:10px;color:var(--button-primary);text-decoration:none;font-size:14px;padding:10px 18px;border-radius:var(--radius-full);background-color:var(--stats-item-bg);transition:all .3s ease;box-shadow:var(--shadow-sm);border:1px solid var(--card-border);position:relative;overflow:hidden}.project-link[data-v-e1a72b08]:before{content:"";position:absolute;top:0;left:0;width:100%;height:100%;background:linear-gradient(90deg,transparent,rgba(255,255,255,.1),transparent);transform:translate(-100%);transition:transform .6s ease}.project-link[data-v-e1a72b08]:hover{transform:translateY(-3px);box-shadow:var(--shadow-md);background-color:var(--color-background-mute);border-color:var(--button-primary)}.project-link[data-v-e1a72b08]:hover:before{transform:translate(100%)}.github-icon[data-v-e1a72b08]{font-size:18px;opacity:.8;transition:all .3s ease}.project-link:hover .github-icon[data-v-e1a72b08]{opacity:1;transform:scale(1.2) rotate(10deg)}.project-text[data-v-e1a72b08]{font-weight:500;position:relative}.project-text[data-v-e1a72b08]:after{content:"";position:absolute;bottom:-2px;left:0;width:0;height:1px;background:var(--gradient-primary);transition:width .3s ease}.project-link:hover .project-text[data-v-e1a72b08]:after{width:100%}@media (max-width: 768px){.stat-card[data-v-e1a72b08]{padding:8px}.stat-value[data-v-e1a72b08]{font-size:16px}.stat-label[data-v-e1a72b08]{font-size:12px;margin-top:2px}.update-status[data-v-e1a72b08]{padding:6px 10px}.update-status .status-icon[data-v-e1a72b08]{font-size:1.1em}.update-status .status-text[data-v-e1a72b08]{font-size:.9em}.project-link[data-v-e1a72b08]{font-size:12px;padding:8px 14px}.github-icon[data-v-e1a72b08]{font-size:16px}}@media (max-width: 480px){.stat-card[data-v-e1a72b08]{padding:6px}.stat-value[data-v-e1a72b08]{font-size:14px}.stat-label[data-v-e1a72b08]{font-size:11px;margin-top:1px}.update-status[data-v-e1a72b08]{padding:4px 8px}.update-status .status-icon[data-v-e1a72b08]{font-size:1em}.update-status .status-text[data-v-e1a72b08]{font-size:.85em}.project-link[data-v-e1a72b08]{font-size:11px;padding:6px 12px}.github-icon[data-v-e1a72b08]{font-size:14px}}.section-title[data-v-87fa5cbc]{color:var(--color-heading);border-bottom:1px solid var(--color-border);padding-bottom:10px;margin-bottom:20px;transition:all .3s ease;position:relative;font-weight:600}.section-title[data-v-87fa5cbc]:after{content:"";position:absolute;bottom:-1px;left:0;width:50px;height:2px;background:var(--gradient-primary)}.vertex-config[data-v-87fa5cbc]{margin-bottom:25px}.config-form[data-v-87fa5cbc]{background-color:var(--stats-item-bg);border-radius:var(--radius-lg);padding:20px;box-shadow:var(--shadow-sm);border:1px solid var(--card-border)}.config-row[data-v-87fa5cbc]{display:flex;gap:15px;margin-bottom:15px;flex-wrap:wrap}.config-group[data-v-87fa5cbc]{flex:1;min-width:120px}.full-width[data-v-87fa5cbc]{flex-basis:100%}.config-label[data-v-87fa5cbc]{display:block;font-size:14px;margin-bottom:5px;color:var(--color-text);font-weight:500}.config-input[data-v-87fa5cbc]{width:100%;padding:8px 12px;border:1px solid var(--color-border);border-radius:var(--radius-md);background-color:var(--color-background);color:var(--color-text);font-size:14px;transition:all .3s ease}.config-input[data-v-87fa5cbc]:focus{outline:none;border-color:var(--button-primary);box-shadow:0 0 0 2px #4f46e533}.text-area[data-v-87fa5cbc]{resize:vertical;min-height:80px;font-family:inherit;line-height:1.5}.toggle-wrapper[data-v-87fa5cbc]{position:relative}.toggle[data-v-87fa5cbc]{position:absolute;opacity:0;width:0;height:0}.toggle-label[data-v-87fa5cbc]{display:flex;align-items:center;cursor:pointer;-webkit-user-select:none;user-select:none}.toggle-label[data-v-87fa5cbc]:before{content:"";display:inline-block;width:36px;height:20px;background-color:var(--color-border);border-radius:10px;margin-right:8px;position:relative;transition:all .3s ease}.toggle-label[data-v-87fa5cbc]:after{content:"";position:absolute;left:3px;width:14px;height:14px;background-color:#fff;border-radius:50%;transition:all .3s ease}.toggle:checked+.toggle-label[data-v-87fa5cbc]:before{background-color:var(--button-primary)}.toggle:checked+.toggle-label[data-v-87fa5cbc]:after{left:19px}.toggle-text[data-v-87fa5cbc]{font-size:14px;color:var(--color-text)}.save-section[data-v-87fa5cbc]{display:flex;gap:10px;margin-top:20px;align-items:center}.password-input[data-v-87fa5cbc]{flex:1}.save-button[data-v-87fa5cbc]{padding:8px 16px;background:var(--button-primary);color:#fff;border:none;border-radius:var(--radius-md);cursor:pointer;font-weight:500;transition:all .3s ease}.save-button[data-v-87fa5cbc]:hover{background:var(--button-primary-hover);transform:translateY(-2px)}.save-button[data-v-87fa5cbc]:disabled{opacity:.7;cursor:not-allowed;transform:none}.error-message[data-v-87fa5cbc]{color:var(--color-error);margin-top:10px;font-size:14px;padding:8px;background-color:var(--color-error-bg);border-radius:var(--radius-md)}.success-message[data-v-87fa5cbc]{color:var(--color-success);margin-top:10px;font-size:14px;padding:8px;background-color:var(--color-success-bg);border-radius:var(--radius-md)}@media (max-width: 768px){.config-row[data-v-87fa5cbc]{gap:10px}.config-group[data-v-87fa5cbc]{min-width:100px}.save-section[data-v-87fa5cbc]{flex-direction:column}.password-input[data-v-87fa5cbc]{width:100%;margin-bottom:10px}.save-button[data-v-87fa5cbc]{width:100%}}@media (max-width: 480px){.config-row[data-v-87fa5cbc]{flex-direction:column;gap:10px}.config-group[data-v-87fa5cbc]{width:100%}.config-form[data-v-87fa5cbc]{padding:15px}}.info-box[data-v-d8cf8b0b]{background-color:var(--card-background);border:1px solid var(--card-border);border-radius:var(--radius-xl);padding:20px;margin-bottom:20px;box-shadow:var(--shadow-md);transition:all .3s ease;position:relative;overflow:hidden}.info-box[data-v-d8cf8b0b]:before{content:"";position:absolute;top:0;left:0;width:4px;height:100%;background:var(--gradient-primary);opacity:.8}@media (max-width: 768px){.info-box[data-v-d8cf8b0b]{margin-bottom:12px;padding:15px 10px;border-radius:var(--radius-lg)}}@media (max-width: 480px){.info-box[data-v-d8cf8b0b]{margin-bottom:8px;padding:12px 8px;border-radius:var(--radius-md)}}.section-title[data-v-d8cf8b0b]{color:var(--color-heading);border-bottom:1px solid var(--color-border);padding-bottom:10px;margin-bottom:20px;transition:all .3s ease;position:relative;font-weight:600}.section-title[data-v-d8cf8b0b]:after{content:"";position:absolute;bottom:-1px;left:0;width:50px;height:2px;background:var(--gradient-primary)}.stats-grid[data-v-d8cf8b0b]{display:grid;grid-template-columns:repeat(3,1fr);gap:15px;margin-top:15px;margin-bottom:20px}@media (max-width: 768px){.stats-grid[data-v-d8cf8b0b]{gap:8px}}.stat-card[data-v-d8cf8b0b]{background-color:var(--stats-item-bg);padding:10px 15px;border-radius:var(--radius-lg);text-align:center;box-shadow:var(--shadow-sm);transition:all .3s ease;position:relative;overflow:hidden;border:1px solid var(--card-border)}.stat-card[data-v-d8cf8b0b]:before{content:"";position:absolute;top:0;left:0;width:100%;height:3px;background:var(--gradient-secondary);opacity:0;transition:opacity .3s ease}.stat-card[data-v-d8cf8b0b]:hover{transform:translateY(-3px);box-shadow:var(--shadow-md);border-color:var(--button-primary)}.stat-card[data-v-d8cf8b0b]:hover:before{opacity:1}.stat-value[data-v-d8cf8b0b]{font-size:24px;font-weight:700;color:var(--button-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;transition:all .3s ease;margin-bottom:5px;position:relative;display:inline-block}.stat-label[data-v-d8cf8b0b]{font-size:14px;color:var(--color-text);margin-top:5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;transition:all .3s ease;opacity:.8}.stat-card:hover .stat-label[data-v-d8cf8b0b]{opacity:1;color:var(--color-heading)}.edit-btn[data-v-d8cf8b0b]{position:absolute;top:5px;right:5px;background:none;border:none;color:var(--color-text-muted);cursor:pointer;opacity:.5;transition:all .3s ease;padding:4px;border-radius:var(--radius-md);display:flex;align-items:center;justify-content:center;z-index:2}.edit-btn[data-v-d8cf8b0b]:hover{opacity:1;transform:scale(1.1) rotate(15deg);background-color:var(--color-background-mute);color:var(--button-primary)}.edit-dialog[data-v-d8cf8b0b]{position:fixed;top:0;left:0;right:0;bottom:0;background-color:#00000080;display:flex;align-items:center;justify-content:center;z-index:1000;-webkit-backdrop-filter:blur(5px);backdrop-filter:blur(5px)}.edit-dialog-content[data-v-d8cf8b0b]{background-color:var(--card-background);border-radius:var(--radius-xl);padding:25px;width:90%;max-width:400px;box-shadow:var(--shadow-xl);position:relative;overflow:hidden;animation:dialogAppear-d8cf8b0b .3s cubic-bezier(.34,1.56,.64,1)}@keyframes dialogAppear-d8cf8b0b{0%{opacity:0;transform:scale(.9) translateY(20px)}to{opacity:1;transform:scale(1) translateY(0)}}.edit-dialog-content[data-v-d8cf8b0b]:before{content:"";position:absolute;top:0;left:0;width:100%;height:4px;background:var(--gradient-primary)}.edit-dialog-content h3[data-v-d8cf8b0b]{margin-top:0;margin-bottom:15px;color:var(--color-heading);font-size:1.3rem;position:relative;padding-bottom:10px}.edit-dialog-content h3[data-v-d8cf8b0b]:after{content:"";position:absolute;bottom:0;left:0;width:40px;height:2px;background:var(--gradient-primary)}.edit-field[data-v-d8cf8b0b]{margin-bottom:20px}.edit-field label[data-v-d8cf8b0b]{display:block;margin-bottom:8px;color:var(--color-text);font-size:14px;line-height:1.5}.edit-input[data-v-d8cf8b0b]{width:100%;padding:12px 16px;border:1px solid var(--color-border);border-radius:var(--radius-md);background-color:var(--color-background);color:var(--color-text);font-size:14px;transition:all .3s ease}.edit-input[data-v-d8cf8b0b]:focus{outline:none;border-color:var(--button-primary);box-shadow:0 0 0 3px #4f46e533}.text-area[data-v-d8cf8b0b]{resize:vertical;min-height:80px;font-family:inherit;line-height:1.5}.boolean-selector[data-v-d8cf8b0b]{display:flex;gap:15px;margin-top:12px}.boolean-option[data-v-d8cf8b0b]{display:flex;align-items:center;gap:8px;cursor:pointer;padding:8px 12px;border-radius:var(--radius-md);background-color:var(--stats-item-bg);transition:all .3s ease;border:1px solid var(--color-border)}.boolean-option[data-v-d8cf8b0b]:hover{background-color:var(--color-background-mute);transform:translateY(-2px)}.boolean-option input[type=radio][data-v-d8cf8b0b]{accent-color:var(--button-primary)}.password-field[data-v-d8cf8b0b]{margin-top:15px;position:relative}.password-field label[data-v-d8cf8b0b]{margin-bottom:8px;display:block}.edit-error[data-v-d8cf8b0b]{color:#ef4444;font-size:12px;margin-top:8px;padding-left:5px;display:flex;align-items:center;gap:5px}.edit-error[data-v-d8cf8b0b]:before{content:"⚠️";font-size:14px}.edit-actions[data-v-d8cf8b0b]{display:flex;justify-content:flex-end;gap:10px;margin-top:20px}.cancel-btn[data-v-d8cf8b0b],.save-btn[data-v-d8cf8b0b]{padding:10px 18px;border-radius:var(--radius-md);font-size:14px;cursor:pointer;transition:all .3s ease;font-weight:500}.cancel-btn[data-v-d8cf8b0b]{background-color:var(--button-secondary);border:1px solid var(--color-border);color:var(--button-secondary-text)}.save-btn[data-v-d8cf8b0b]{background:var(--gradient-primary);border:none;color:#fff;box-shadow:var(--shadow-sm)}.cancel-btn[data-v-d8cf8b0b]:hover{background-color:var(--button-secondary-hover);transform:translateY(-2px)}.save-btn[data-v-d8cf8b0b]:hover{transform:translateY(-2px);box-shadow:var(--shadow-md)}.tooltip[data-v-d8cf8b0b]{position:fixed;background-color:#000c;color:#fff;padding:8px 12px;border-radius:var(--radius-md);font-size:12px;max-width:250px;z-index:1000;pointer-events:none;transform:translate(-50%,-100%);margin-top:-10px;box-shadow:var(--shadow-lg);-webkit-backdrop-filter:blur(5px);backdrop-filter:blur(5px);border:1px solid rgba(255,255,255,.1);animation:tooltipAppear-d8cf8b0b .2s ease}@keyframes tooltipAppear-d8cf8b0b{0%{opacity:0;transform:translate(-50%,-90%)}to{opacity:1;transform:translate(-50%,-100%)}}@media (max-width: 768px){.stat-card[data-v-d8cf8b0b]{padding:8px}.stat-value[data-v-d8cf8b0b]{font-size:16px}.stat-label[data-v-d8cf8b0b]{font-size:12px;margin-top:2px}.edit-btn[data-v-d8cf8b0b]{top:3px;right:3px;padding:2px}.edit-dialog-content[data-v-d8cf8b0b]{padding:20px}.boolean-selector[data-v-d8cf8b0b]{flex-direction:column;gap:8px}}@media (max-width: 480px){.stat-card[data-v-d8cf8b0b]{padding:6px}.stat-value[data-v-d8cf8b0b]{font-size:14px}.stat-label[data-v-d8cf8b0b]{font-size:11px;margin-top:1px}.tooltip[data-v-d8cf8b0b]{max-width:200px;font-size:10px}.edit-dialog-content[data-v-d8cf8b0b]{padding:15px}.edit-dialog-content h3[data-v-d8cf8b0b]{font-size:1.1rem}.edit-input[data-v-d8cf8b0b]{padding:10px 14px;font-size:13px}.cancel-btn[data-v-d8cf8b0b],.save-btn[data-v-d8cf8b0b]{padding:8px 14px;font-size:13px}}.fold-header[data-v-d8cf8b0b]{cursor:pointer;-webkit-user-select:none;user-select:none;display:flex;justify-content:space-between;align-items:center;transition:all .3s ease;border-radius:var(--radius-lg);padding:10px 15px;background-color:var(--stats-item-bg);border:1px solid var(--card-border);margin-bottom:15px}.fold-header[data-v-d8cf8b0b]:hover{background-color:var(--color-background-mute);transform:translateY(-2px);box-shadow:var(--shadow-sm)}.fold-icon[data-v-d8cf8b0b]{display:inline-flex;align-items:center;justify-content:center;transition:transform .3s ease;color:var(--button-primary)}.fold-icon.rotated[data-v-d8cf8b0b]{transform:rotate(180deg)}.fold-content[data-v-d8cf8b0b]{overflow:hidden}.fold-enter-active[data-v-d8cf8b0b],.fold-leave-active[data-v-d8cf8b0b]{transition:all .3s ease;max-height:1000px;opacity:1;overflow:hidden}.fold-enter-from[data-v-d8cf8b0b],.fold-leave-to[data-v-d8cf8b0b]{max-height:0;opacity:0;overflow:hidden}@media (max-width: 768px){.fold-header[data-v-d8cf8b0b]{padding:8px 12px}}@media (max-width: 480px){.fold-header[data-v-d8cf8b0b]{padding:6px 10px}}.shared-save-section[data-v-d8cf8b0b]{margin-top:30px;padding-top:20px;border-top:1px solid var(--color-border);display:flex;flex-direction:column;gap:15px}.password-input-group[data-v-d8cf8b0b]{display:flex;flex-direction:column;gap:5px}.shared-password-label[data-v-d8cf8b0b]{font-size:14px;color:var(--color-text);font-weight:500}.config-input[data-v-d8cf8b0b]{width:100%;padding:10px 14px;border:1px solid var(--color-border);border-radius:var(--radius-md);background-color:var(--color-background);color:var(--color-text);font-size:14px;transition:all .3s ease}.config-input[data-v-d8cf8b0b]:focus{outline:none;border-color:var(--button-primary);box-shadow:0 0 0 2px #4f46e533}.save-all-button[data-v-d8cf8b0b]{padding:10px 18px;background:var(--button-primary);color:#fff;border:none;border-radius:var(--radius-md);cursor:pointer;font-weight:500;transition:all .3s ease;text-align:center}.save-all-button[data-v-d8cf8b0b]:hover{background:var(--button-primary-hover);transform:translateY(-2px);box-shadow:var(--shadow-md)}.save-all-button[data-v-d8cf8b0b]:disabled{opacity:.7;cursor:not-allowed;transform:none}.overall-error-message[data-v-d8cf8b0b]{color:var(--color-error);margin-top:10px;font-size:14px;padding:10px;background-color:var(--color-error-bg);border-radius:var(--radius-md);border:1px solid var(--color-error)}.overall-success-message[data-v-d8cf8b0b]{color:var(--color-success);margin-top:10px;font-size:14px;padding:10px;background-color:var(--color-success-bg);border-radius:var(--radius-md);border:1px solid var(--color-success)}.info-box[data-v-d38f4a7d]{background-color:var(--card-background);border:1px solid var(--card-border);border-radius:var(--radius-xl);padding:20px;margin-bottom:20px;box-shadow:var(--shadow-md);transition:all .3s ease;position:relative;overflow:hidden}.info-box[data-v-d38f4a7d]:before{content:"";position:absolute;top:0;left:0;width:4px;height:100%;background:var(--gradient-info);opacity:.8}@media (max-width: 768px){.info-box[data-v-d38f4a7d]{margin-bottom:12px;padding:15px 10px;border-radius:var(--radius-lg)}}@media (max-width: 480px){.info-box[data-v-d38f4a7d]{margin-bottom:8px;padding:12px 8px;border-radius:var(--radius-md)}}.section-title[data-v-d38f4a7d]{color:var(--color-heading);border-bottom:1px solid var(--color-border);padding-bottom:10px;margin-bottom:20px;transition:all .3s ease;position:relative;font-weight:600}.section-title[data-v-d38f4a7d]:after{content:"";position:absolute;bottom:-1px;left:0;width:50px;height:2px;background:var(--gradient-info)}.log-filter[data-v-d38f4a7d]{display:flex;justify-content:center;margin-bottom:15px;gap:10px;flex-wrap:wrap}.log-filter button[data-v-d38f4a7d]{padding:8px 12px;border:1px solid var(--card-border);border-radius:var(--radius-md);background-color:var(--stats-item-bg);color:var(--color-text);cursor:pointer;min-width:70px;transition:all .3s ease;font-weight:500;position:relative;overflow:hidden}.log-filter button[data-v-d38f4a7d]:before{content:"";position:absolute;top:0;left:0;width:100%;height:100%;background:linear-gradient(90deg,transparent,rgba(255,255,255,.1),transparent);transform:translate(-100%);transition:transform .6s ease}.log-filter button[data-v-d38f4a7d]:hover:before{transform:translate(100%)}.log-filter button.active[data-v-d38f4a7d]{background:var(--gradient-info);color:#fff;border-color:transparent;box-shadow:var(--shadow-sm);transform:translateY(-2px)}.log-filter button[data-v-d38f4a7d]:not(.active):hover{background-color:var(--color-background-mute);transform:translateY(-2px);box-shadow:var(--shadow-sm)}@media (max-width: 768px){.log-filter[data-v-d38f4a7d]{gap:6px;margin-bottom:12px}.log-filter button[data-v-d38f4a7d]{padding:6px 10px;font-size:12px;min-width:60px}}@media (max-width: 480px){.log-filter[data-v-d38f4a7d]{gap:4px;margin-bottom:10px}.log-filter button[data-v-d38f4a7d]{padding:5px 8px;font-size:11px;min-width:50px}}.log-container[data-v-d38f4a7d]{background-color:var(--log-entry-bg);border:1px solid var(--log-entry-border);border-radius:var(--radius-lg);padding:15px;margin-top:20px;max-height:500px;overflow-y:auto;font-family:JetBrains Mono,Fira Code,monospace;font-size:14px;line-height:1.5;transition:all .3s ease;box-shadow:var(--shadow-sm);position:relative}.log-container[data-v-d38f4a7d]::-webkit-scrollbar{width:8px}.log-container[data-v-d38f4a7d]::-webkit-scrollbar-track{background:var(--color-background-mute);border-radius:4px}.log-container[data-v-d38f4a7d]::-webkit-scrollbar-thumb{background:var(--button-primary);border-radius:4px;opacity:.7}.log-container[data-v-d38f4a7d]::-webkit-scrollbar-thumb:hover{background:var(--button-primary-hover)}.log-entry[data-v-d38f4a7d]{margin-bottom:8px;padding:10px;border-radius:var(--radius-md);word-break:break-word;transition:all .3s ease;position:relative;overflow:hidden;border-left:4px solid transparent;animation:logEntryAppear-d38f4a7d .3s ease forwards;opacity:0;transform:translateY(10px)}@keyframes logEntryAppear-d38f4a7d{0%{opacity:0;transform:translateY(10px)}to{opacity:1;transform:translateY(0)}}.log-entry[data-v-d38f4a7d]:after{content:"";position:absolute;top:0;left:0;width:100%;height:100%;background:linear-gradient(90deg,transparent,rgba(255,255,255,.05),transparent);transform:translate(-100%);transition:transform .6s ease}.log-entry[data-v-d38f4a7d]:hover:after{transform:translate(100%)}.log-entry.INFO[data-v-d38f4a7d]{background-color:#3b82f61a;border-left:4px solid #3b82f6}.log-entry.WARNING[data-v-d38f4a7d]{background-color:#f59e0b1a;border-left:4px solid #f59e0b}.log-entry.ERROR[data-v-d38f4a7d]{background-color:#ef44441a;border-left:4px solid #ef4444}.log-entry.DEBUG[data-v-d38f4a7d]{background-color:#10b9811a;border-left:4px solid #10b981}.log-timestamp[data-v-d38f4a7d]{color:var(--color-text);font-size:12px;margin-right:10px;opacity:.8;transition:all .3s ease;font-weight:500}.log-level[data-v-d38f4a7d]{font-weight:700;margin-right:10px;padding:2px 6px;border-radius:var(--radius-sm);font-size:12px;text-transform:uppercase;letter-spacing:.5px}.log-level.INFO[data-v-d38f4a7d]{color:#3b82f6;background-color:#3b82f61a}.log-level.WARNING[data-v-d38f4a7d]{color:#f59e0b;background-color:#f59e0b1a}.log-level.ERROR[data-v-d38f4a7d]{color:#ef4444;background-color:#ef44441a}.log-level.DEBUG[data-v-d38f4a7d]{color:#10b981;background-color:#10b9811a}.log-message[data-v-d38f4a7d]{color:var(--color-text);transition:all .3s ease;line-height:1.6}.log-entry[data-v-d38f4a7d]:hover{transform:translate(5px);box-shadow:var(--shadow-sm)}.log-entry:hover .log-timestamp[data-v-d38f4a7d]{opacity:1;color:var(--button-primary)}.log-entry:hover .log-message[data-v-d38f4a7d]{color:var(--color-heading)}@media (max-width: 768px){.log-container[data-v-d38f4a7d]{padding:12px;font-size:13px;max-height:400px}.log-entry[data-v-d38f4a7d]{padding:8px;margin-bottom:6px}.log-timestamp[data-v-d38f4a7d]{font-size:11px;display:block;margin-bottom:3px}.log-level[data-v-d38f4a7d]{font-size:11px;padding:1px 4px}}@media (max-width: 480px){.log-container[data-v-d38f4a7d]{padding:10px;font-size:12px;max-height:350px}.log-entry[data-v-d38f4a7d]{padding:6px;margin-bottom:5px}.log-timestamp[data-v-d38f4a7d]{font-size:10px}.log-level[data-v-d38f4a7d]{font-size:10px;padding:1px 3px;margin-right:5px}.log-message[data-v-d38f4a7d]{font-size:11px}}body{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,sans-serif;line-height:1.6;background-color:var(--color-background);color:var(--color-text);margin:0;padding:0;transition:background-color .3s,color .3s}.dashboard{max-width:1200px;margin:0 auto;padding:20px;opacity:0;transform:translateY(20px);transition:opacity .5s cubic-bezier(.25,.46,.45,.94),transform .5s cubic-bezier(.25,.46,.45,.94)}.dashboard.page-loaded{opacity:1;transform:translateY(0)}.header-container{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px;opacity:0;transform:translateY(20px) scale(.95);transition:opacity .4s cubic-bezier(.34,1.56,.64,1),transform .4s cubic-bezier(.34,1.56,.64,1);background:var(--gradient-primary);padding:20px;border-radius:var(--radius-xl);box-shadow:var(--shadow-lg);position:relative;overflow:hidden}.header-container:before{content:"";position:absolute;top:0;left:0;right:0;bottom:0;background:radial-gradient(circle at top right,rgba(255,255,255,.1),transparent 70%);z-index:0}.header-container:after{content:"";position:absolute;top:0;left:0;right:0;bottom:0;background:radial-gradient(circle at bottom left,rgba(255,255,255,.1),transparent 70%);z-index:0}.header-container.animate-in{opacity:1;transform:translateY(0) scale(1)}.title-container{display:flex;align-items:center;gap:15px;flex-wrap:wrap;position:relative;z-index:1}.toggle-container{display:flex;align-items:center;gap:15px;position:relative;z-index:1}.vertex-button,.theme-button{display:flex;align-items:center;justify-content:center;background-color:#ffffff26;padding:8px 16px;border-radius:var(--radius-full);border:1px solid rgba(255,255,255,.2);transition:all .3s ease;box-shadow:var(--shadow-md);cursor:pointer;font-size:.9rem;color:#fff;font-weight:500;min-width:90px;-webkit-backdrop-filter:blur(5px);backdrop-filter:blur(5px)}.vertex-button.active,.theme-button.active{background-color:#ffffff40;border-color:#ffffff4d;color:#fff}.vertex-button:hover,.theme-button:hover{background-color:#ffffff4d;transform:translateY(-2px);box-shadow:var(--shadow-lg)}h1{color:#fff;margin:0;font-size:1.8rem;text-shadow:0 2px 4px rgba(0,0,0,.2)}.vertex-toggle,.theme-toggle,.switch,.slider{display:none}.sections-row{display:flex;flex-direction:column;gap:20px;margin-bottom:20px}.status-section,.config-section{width:100%}@media (max-width: 768px){.dashboard{padding:10px 8px}.header-container{flex-direction:row;align-items:center;margin-bottom:15px;padding:15px}.title-container{width:auto;justify-content:flex-start;margin-bottom:0;flex:1}h1{font-size:1.4rem;text-align:left;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}h1:before{content:"🤖Gemini代理"}h1 span{display:none}.toggle-container{width:auto;justify-content:flex-end;flex-direction:row;gap:8px;margin-top:0;align-self:center}.vertex-button,.theme-button{padding:6px 12px;font-size:.8rem;min-width:80px}.sections-row{flex-direction:column;gap:15px}}@media (max-width: 480px){.dashboard{padding:6px 4px}.header-container{flex-direction:row;align-items:center;margin-bottom:15px;padding:12px}.title-container{width:auto;justify-content:flex-start;margin-bottom:0;flex:1}h1{font-size:1.1rem;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.toggle-container{width:auto;justify-content:flex-end;flex-direction:row;gap:4px;margin-top:0;align-self:center}.vertex-button,.theme-button{padding:4px 8px;font-size:.65rem;min-width:70px}.sections-row{flex-direction:column;gap:10px}}.refresh-button{display:block;margin:20px auto;padding:12px 24px;background:var(--gradient-secondary);color:#fff;border:none;border-radius:var(--radius-lg);font-size:16px;cursor:pointer;transition:all .3s ease;opacity:0;transform:translateY(20px) scale(.95);box-shadow:var(--shadow-md);position:relative;overflow:hidden}.refresh-button:before{content:"";position:absolute;top:0;left:0;right:0;bottom:0;background:linear-gradient(to right,transparent,rgba(255,255,255,.2),transparent);transform:translate(-100%);transition:transform .6s ease}.refresh-button:hover:before{transform:translate(100%)}.refresh-button.animate-in{opacity:1;transform:translateY(0) scale(1)}.refresh-button:hover{transform:translateY(-3px);box-shadow:var(--shadow-lg)}@media (max-width: 768px){:deep(.info-box){padding:15px 10px;margin-bottom:15px;border-radius:var(--radius-lg);background-color:var(--card-background);border:1px solid var(--card-border);box-shadow:var(--shadow-md);position:relative;overflow:hidden}:deep(.info-box):before{content:"";position:absolute;top:0;left:0;width:4px;height:100%;background:var(--gradient-primary)}:deep(.section-title){font-size:1.1rem;margin-bottom:15px;padding-bottom:8px;color:var(--color-heading);border-bottom:1px solid var(--color-border);position:relative}:deep(.section-title):after{content:"";position:absolute;bottom:-1px;left:0;width:50px;height:2px;background:var(--gradient-primary)}:deep(.stats-grid){gap:10px;margin-top:15px;margin-bottom:20px}.refresh-button{margin:20px auto;padding:10px 20px;font-size:14px}}@media (max-width: 480px){:deep(.info-box){padding:12px 8px;margin-bottom:10px;border-radius:var(--radius-md)}:deep(.section-title){font-size:1rem;margin-bottom:10px;padding-bottom:6px}:deep(.stats-grid){gap:8px;margin-top:10px;margin-bottom:15px}.refresh-button{margin:15px auto;padding:8px 16px;font-size:13px}}.section-animate{opacity:0;transform:translateY(20px) scale(.95);transition:opacity .4s cubic-bezier(.34,1.56,.64,1),transform .4s cubic-bezier(.34,1.56,.64,1)}.section-animate.animate-in{opacity:1;transform:translateY(0) scale(1)}:deep(.stats-grid){opacity:0;transform:translateY(10px) scale(.98);transition:opacity .3s cubic-bezier(.34,1.56,.64,1),transform .3s cubic-bezier(.34,1.56,.64,1)}.animate-in :deep(.stats-grid){opacity:1;transform:translateY(0) scale(1);transition-delay:.1s}:deep(.stat-card){opacity:0;transform:scale(.9) translateY(10px);transition:opacity .3s cubic-bezier(.34,1.56,.64,1),transform .3s cubic-bezier(.34,1.56,.64,1),box-shadow .3s,background-color .3s;position:relative;overflow:hidden}:deep(.stat-card):before{content:"";position:absolute;top:0;left:0;width:100%;height:4px;background:var(--gradient-secondary);opacity:0;transition:opacity .3s ease}:deep(.stat-card:hover):before{opacity:1}.animate-in :deep(.stat-card){opacity:1;transform:scale(1) translateY(0)}.animate-in :deep(.stat-card:nth-child(1)){transition-delay:.15s}.animate-in :deep(.stat-card:nth-child(2)){transition-delay:.2s}.animate-in :deep(.stat-card:nth-child(3)){transition-delay:.25s}.animate-in :deep(.stat-card:nth-child(4)){transition-delay:.3s}.animate-in :deep(.stat-card:nth-child(5)){transition-delay:.35s}.animate-in :deep(.stat-card:nth-child(6)){transition-delay:.4s}.animate-in :deep(.stat-card:nth-child(7)){transition-delay:.45s}.animate-in :deep(.stat-card:nth-child(8)){transition-delay:.5s}:deep(.log-entry){opacity:0;transform:translate(-10px) scale(.98);transition:opacity .3s cubic-bezier(.34,1.56,.64,1),transform .3s cubic-bezier(.34,1.56,.64,1);position:relative;overflow:hidden}:deep(.log-entry):after{content:"";position:absolute;top:0;left:0;width:100%;height:100%;background:linear-gradient(90deg,transparent,rgba(255,255,255,.05),transparent);transform:translate(-100%);transition:transform .6s ease}:deep(.log-entry:hover):after{transform:translate(100%)}.animate-in :deep(.log-entry){opacity:1;transform:translate(0) scale(1)}.animate-in :deep(.log-entry:nth-child(1)){transition-delay:.15s}.animate-in :deep(.log-entry:nth-child(2)){transition-delay:.2s}.animate-in :deep(.log-entry:nth-child(3)){transition-delay:.25s}.animate-in :deep(.log-entry:nth-child(4)){transition-delay:.3s}.animate-in :deep(.log-entry:nth-child(5)){transition-delay:.35s}.animate-in :deep(.log-entry:nth-child(n+6)){transition-delay:.4s}@keyframes flyIn{0%{opacity:0;transform:translateY(30px) scale(.9)}50%{opacity:.5;transform:translateY(15px) scale(.95)}to{opacity:1;transform:translateY(0) scale(1)}}@keyframes flyInFromLeft{0%{opacity:0;transform:translate(-20px) scale(.9)}50%{opacity:.5;transform:translate(-10px) scale(.95)}to{opacity:1;transform:translate(0) scale(1)}}@keyframes flyInFromRight{0%{opacity:0;transform:translate(20px) scale(.9)}50%{opacity:.5;transform:translate(10px) scale(.95)}to{opacity:1;transform:translate(0) scale(1)}}.header-container.animate-in,.section-animate.animate-in{animation:flyIn .5s cubic-bezier(.34,1.56,.64,1) forwards}.animate-in :deep(.stat-card:nth-child(odd)){animation:flyInFromLeft .4s cubic-bezier(.34,1.56,.64,1) forwards}.animate-in :deep(.stat-card:nth-child(even)){animation:flyInFromRight .4s cubic-bezier(.34,1.56,.64,1) forwards}.animate-in :deep(.log-entry){animation:flyInFromLeft .3s cubic-bezier(.34,1.56,.64,1) forwards}.refresh-button.animate-in{animation:flyIn .5s cubic-bezier(.34,1.56,.64,1) forwards}.password-dialog{position:fixed;top:0;left:0;right:0;bottom:0;background-color:#00000080;display:flex;align-items:flex-start;justify-content:center;z-index:1000;padding-top:100px;-webkit-backdrop-filter:blur(5px);backdrop-filter:blur(5px)}.password-dialog-content{background-color:var(--card-background);border-radius:var(--radius-xl);padding:25px;width:90%;max-width:400px;box-shadow:var(--shadow-xl);position:relative;overflow:hidden}.password-dialog-content:before{content:"";position:absolute;top:0;left:0;width:100%;height:5px;background:var(--gradient-primary)}.password-dialog-content h3{margin-top:0;margin-bottom:10px;color:var(--color-heading);font-size:1.3rem}.password-dialog-content p{margin-bottom:15px;color:var(--color-text);font-size:14px}.password-input-container{margin-bottom:20px;position:relative}.password-input{width:100%;padding:12px 16px;border:1px solid var(--color-border);border-radius:var(--radius-md);background-color:var(--color-background);color:var(--color-text);font-size:14px;transition:all .3s ease}.password-input:focus{outline:none;border-color:var(--button-primary);box-shadow:0 0 0 3px #4f46e533}.password-error{color:#ef4444;font-size:12px;margin-top:8px;padding-left:5px}.password-actions{display:flex;justify-content:flex-end;gap:10px}.cancel-btn,.confirm-btn{padding:10px 18px;border-radius:var(--radius-md);font-size:14px;cursor:pointer;transition:all .2s}.cancel-btn{background-color:var(--button-secondary);border:1px solid var(--color-border);color:var(--button-secondary-text)}.confirm-btn{background:var(--gradient-primary);border:none;color:#fff;box-shadow:var(--shadow-sm)}.cancel-btn:hover{background-color:var(--button-secondary-hover);transform:translateY(-2px)}.confirm-btn:hover{transform:translateY(-2px);box-shadow:var(--shadow-md)}@media (max-width: 768px){.password-dialog{padding-top:80px}.password-dialog-content{padding:20px}}@media (max-width: 480px){.password-dialog{padding-top:60px}.password-dialog-content{padding:15px}.password-dialog-content h3{font-size:1.1rem}.password-dialog-content p{font-size:12px}.password-input{font-size:12px;padding:10px 14px}.cancel-btn,.confirm-btn{padding:8px 14px;font-size:12px}}
app/templates/assets/aafbaf642c01961ff24ddb8941d1bf59.html ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <link rel="icon" href="/favicon.ico">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>HAJIMI</title>
8
+ <script type="module" crossorigin src="/main.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/index.css">
10
+ </head>
11
+ <body>
12
+ <div id="app"></div>
13
+ </body>
14
+ </html>
app/templates/assets/favicon.ico ADDED
app/templates/index.html ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ <!DOCTYPE html>
3
+ <html lang="zh-CN">
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <link rel="icon" href="/assets/favicon.ico">
7
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
8
+ <title>Gemini API 代理服务</title>
9
+ <script type="module" crossorigin src="/assets/0506c607efda914c9388132c9cbb0c53.js"></script>
10
+ <link rel="stylesheet" href="/assets/9a4f356975f1a7b8b7bad9e93c1becba.css">
11
+ </head>
12
+ <body>
13
+ <div id="app"></div>
14
+ </body>
15
+ </html>
app/utils/__init__.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Utils package initialization
2
+
3
+ from app.utils.logging import logger, log_manager, format_log_message,log
4
+ from app.utils.api_key import APIKeyManager, test_api_key
5
+ from app.utils.error_handling import handle_gemini_error, translate_error, handle_api_error
6
+ from app.utils.rate_limiting import protect_from_abuse
7
+ from app.utils.cache import ResponseCacheManager, generate_cache_key
8
+ from app.utils.request import ActiveRequestsManager
9
+ from app.utils.stats import clean_expired_stats, update_api_call_stats
10
+ from app.utils.version import check_version
11
+ from app.utils.maintenance import handle_exception, schedule_cache_cleanup
12
+ from app.utils.response import openAI_from_text
app/utils/api_key.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import random
2
+ import re
3
+ import os
4
+ import logging
5
+ import asyncio
6
+ from datetime import datetime, timedelta
7
+ from apscheduler.schedulers.background import BackgroundScheduler
8
+ from app.utils.logging import format_log_message
9
+ import app.config.settings as settings
10
+ logger = logging.getLogger("my_logger")
11
+
12
+ class APIKeyManager:
13
+ def __init__(self):
14
+ self.api_keys = re.findall(
15
+ r"AIzaSy[a-zA-Z0-9_-]{33}", settings.GEMINI_API_KEYS)
16
+ # 加载更多 GEMINI_API_KEYS
17
+ for i in range(1, 99):
18
+ if keys := os.environ.get(f"GEMINI_API_KEYS_{i}", ""):
19
+ self.api_keys += re.findall(r"AIzaSy[a-zA-Z0-9_-]{33}", keys)
20
+ else:
21
+ break
22
+
23
+ self.key_stack = [] # 初始化密钥栈
24
+ self._reset_key_stack() # 初始化时创建随机密钥栈
25
+ self.scheduler = BackgroundScheduler()
26
+ self.scheduler.start()
27
+ self.lock = asyncio.Lock() # Added lock
28
+
29
+ def _reset_key_stack(self):
30
+ """创建并随机化密钥栈"""
31
+ shuffled_keys = self.api_keys[:] # 创建 api_keys 的副本以避免直接修改原列表
32
+ random.shuffle(shuffled_keys)
33
+ self.key_stack = shuffled_keys
34
+
35
+ async def get_available_key(self):
36
+ """从栈顶获取密钥,若栈空则重新生成
37
+
38
+ 实现负载均衡:
39
+ 1. 维护一个随机排序的栈存储apikey
40
+ 2. 每次调用从栈顶取出一个key返回
41
+ 3. 栈空时重新随机生成栈
42
+ 4. 确保异步和并发安全
43
+ """
44
+ async with self.lock:
45
+ # 如果栈为空,重新生成
46
+ if not self.key_stack:
47
+ self._reset_key_stack()
48
+
49
+ # 从栈顶取出key
50
+ if self.key_stack:
51
+ return self.key_stack.pop()
52
+
53
+ # 如果没有可用的API密钥,记录错误
54
+ if not self.api_keys:
55
+ log_msg = format_log_message('ERROR', "没有配置任何 API 密钥!")
56
+ logger.error(log_msg)
57
+ log_msg = format_log_message('ERROR', "没有可用的API密钥!")
58
+ logger.error(log_msg)
59
+ return None
60
+
61
+ def show_all_keys(self):
62
+ log_msg = format_log_message('INFO', f"当前可用API key个数: {len(self.api_keys)} ")
63
+ logger.info(log_msg)
64
+ for i, api_key in enumerate(self.api_keys):
65
+ log_msg = format_log_message('INFO', f"API Key{i}: {api_key[:8]}...{api_key[-3:]}")
66
+ logger.info(log_msg)
67
+
68
+ # def blacklist_key(self, key):
69
+ # log_msg = format_log_message('WARNING', f"{key[:8]} → 暂时禁用 {self.api_key_blacklist_duration} 秒")
70
+ # logger.warning(log_msg)
71
+ # self.api_key_blacklist.add(key)
72
+ # self.scheduler.add_job(lambda: self.api_key_blacklist.discard(key), 'date',
73
+ # run_date=datetime.now() + timedelta(seconds=self.api_key_blacklist_duration))
74
+
75
+ async def test_api_key(api_key: str) -> bool:
76
+ """
77
+ 测试 API 密钥是否有效。
78
+ """
79
+ try:
80
+ import httpx
81
+ url = "https://generativelanguage.googleapis.com/v1beta/models?key={}".format(api_key)
82
+ async with httpx.AsyncClient() as client:
83
+ response = await client.get(url)
84
+ response.raise_for_status()
85
+ return True
86
+ except Exception:
87
+ return False
app/utils/auth.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Optional
2
+ from fastapi import HTTPException, Header, Query
3
+ import app.config.settings as settings
4
+
5
+ # 自定义密码校验依赖函数
6
+ async def custom_verify_password(
7
+ authorization: Optional[str] = Header(None, description="OpenAI 格式请求 Key, 格式: Bearer sk-xxxx"),
8
+ x_goog_api_key: Optional[str] = Header(None, description="Gemini 格式请求 Key, 从请求头 x-goog-api-key 获取"),
9
+ key: Optional[str] = Query(None, description="Gemini 格式请求 Key, 从查询参数 key 获取"),
10
+ alt: Optional[str] = None
11
+ ):
12
+ """
13
+ @@ -15,22 +18,79 @@
14
+ 2. 根据类型,与项目配置的密钥进行比对。
15
+ 3. 如果 Key 无效、缺失或不匹配,则抛出 HTTPException。
16
+ """
17
+ client_provided_api_key: Optional[str] = None
18
+
19
+ # 提取客户端提供的 Key
20
+ if x_goog_api_key:
21
+ client_provided_api_key = x_goog_api_key
22
+ elif key:
23
+ client_provided_api_key = key
24
+ elif authorization and authorization.startswith("Bearer "):
25
+ token = authorization.split(" ", 1)[1]
26
+ client_provided_api_key = token
27
+
28
+ # 进行校验和比对
29
+ if (not client_provided_api_key) or (client_provided_api_key != settings.PASSWORD) :
30
+ raise HTTPException(
31
+ status_code=401, detail="Unauthorized: Invalid token")
32
+
33
+ def verify_web_password(password:str):
34
+ if password != settings.WEB_PASSWORD:
35
+ return False
36
+ return True
app/utils/cache.py ADDED
@@ -0,0 +1,291 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ import xxhash
3
+ import asyncio
4
+ from typing import Dict, Any, Optional, Tuple
5
+ import logging
6
+ from collections import deque
7
+ from app.utils.logging import log
8
+ logger = logging.getLogger("my_logger")
9
+ import heapq
10
+
11
+ # 定义缓存项的结构
12
+ CacheItem = Dict[str, Any]
13
+
14
+ class ResponseCacheManager:
15
+ """管理API响应缓存的类,一个键可以对应多个缓存项(使用deque)"""
16
+
17
+ def __init__(self, expiry_time: int, max_entries: int,
18
+ cache_dict: Dict[str, deque[CacheItem]] = None):
19
+ """
20
+ 初始化缓存管理器。
21
+
22
+ Args:
23
+ expiry_time (int): 缓存项的过期时间(秒)。
24
+ max_entries (int): 缓存中允许的最大总条目数。
25
+ cache_dict (Dict[str, deque[CacheItem]], optional): 初始缓存字典。默认为 None。
26
+ """
27
+ self.cache: Dict[str, deque[CacheItem]] = cache_dict if cache_dict is not None else {}
28
+ self.expiry_time = expiry_time
29
+ self.max_entries = max_entries # 总条目数限制
30
+ self.cur_cache_num = 0 # 当前条目数
31
+ self.lock = asyncio.Lock() # Added lock
32
+
33
+ async def get(self, cache_key: str) -> Tuple[Optional[Any], bool]: # Made async
34
+ """获取指定键的第一个有效缓存项(不删除)"""
35
+ now = time.time()
36
+ async with self.lock:
37
+ if cache_key in self.cache:
38
+ cache_deque = self.cache[cache_key]
39
+ # 查找第一个未过期的项,且不删除
40
+ for item in cache_deque:
41
+ if now < item.get('expiry_time', 0):
42
+ response = item.get('response',None)
43
+ return response, True
44
+
45
+ return None, False
46
+
47
+ async def get_and_remove(self, cache_key: str) -> Tuple[Optional[Any], bool]:
48
+ """获取并删除指定键的第一个有效缓存项。"""
49
+ now = time.time()
50
+ async with self.lock:
51
+ if cache_key in self.cache:
52
+ cache_deque = self.cache[cache_key]
53
+
54
+ # 查找第一个有效项并收集过期项
55
+ valid_item_to_remove = None
56
+ response_to_return = None
57
+ new_deque = deque()
58
+ items_removed_count = 0
59
+
60
+ for item in cache_deque:
61
+ if now < item.get('expiry_time', 0):
62
+ if valid_item_to_remove is None: # 找到第一个有效项
63
+ valid_item_to_remove = item
64
+ response_to_return = item.get('response', None)
65
+ items_removed_count += 1 # 计数此项为移除
66
+
67
+ else:
68
+ new_deque.append(item) # 保留后续有效项
69
+ else:
70
+ items_removed_count += 1 # 计数过期项为移除
71
+
72
+ # 更新缓存状态
73
+ if items_removed_count > 0:
74
+ self.cur_cache_num = max(0, self.cur_cache_num - items_removed_count)
75
+ if not new_deque:
76
+ # 如果所有项都被移除(过期或我们取的那个)
77
+ del self.cache[cache_key]
78
+ else:
79
+ self.cache[cache_key] = new_deque
80
+
81
+ if valid_item_to_remove:
82
+ return response_to_return, True # 返回找到的有效项
83
+
84
+ # 如果键不存在或未找到有效项
85
+ return None, False
86
+
87
+ async def store(self, cache_key: str, response: Any):
88
+ """存储响应到缓存(追加到键对应的deque)"""
89
+ now = time.time()
90
+ new_item: CacheItem = {
91
+ 'response': response,
92
+ 'expiry_time': now + self.expiry_time,
93
+ 'created_at': now,
94
+ }
95
+
96
+ needs_cleaning = False
97
+ async with self.lock:
98
+ if cache_key not in self.cache:
99
+ self.cache[cache_key] = deque()
100
+
101
+ self.cache[cache_key].append(new_item) # 追加到deque末尾
102
+ self.cur_cache_num += 1
103
+ needs_cleaning = self.cur_cache_num > self.max_entries
104
+
105
+ if needs_cleaning:
106
+ # 在锁外调用清理,避免长时间持有锁
107
+ await self.clean_if_needed()
108
+
109
+ async def clean_expired(self):
110
+ """清理所有缓存项中已过期的项。"""
111
+ now = time.time()
112
+ keys_to_remove = []
113
+ total_cleaned = 0
114
+ async with self.lock:
115
+ # 迭代 cache 的副本以允许在循环中安全地修改 cache
116
+ for key, cache_deque in list(self.cache.items()):
117
+ original_len = len(cache_deque)
118
+ # 创建一个新的 deque,只包含未过期的项
119
+ valid_items = deque(item for item in cache_deque if now < item.get('expiry_time', 0))
120
+ cleaned_count = original_len - len(valid_items)
121
+
122
+ if cleaned_count > 0:
123
+ log('info', f"清理键 {key[:8]}... 的过期缓存项 {cleaned_count} 个。")
124
+ total_cleaned += cleaned_count
125
+
126
+ if not valid_items:
127
+ keys_to_remove.append(key) # 标记此键以便稍后删除
128
+ # 在持有锁时直接删除键
129
+ if key in self.cache:
130
+ del self.cache[key]
131
+ log('info', f"缓存键 {key[:8]}... 的所有项均已过期,移除该键。")
132
+ elif cleaned_count > 0:
133
+ # 替换为只包含有效项的 deque
134
+ self.cache[key] = valid_items
135
+
136
+ # 统一更新缓存计数
137
+ if total_cleaned > 0:
138
+ self.cur_cache_num = max(0, self.cur_cache_num - total_cleaned)
139
+
140
+ async def clean_if_needed(self):
141
+ """如果缓存总条目数超过限制,清理全局最旧的项目。"""
142
+
143
+ async with self.lock:
144
+ if self.cur_cache_num <= self.max_entries:
145
+ return
146
+
147
+ # 计算目标大小和需要移除的数量
148
+ target_size = max(self.max_entries - 10, 10)
149
+ if self.cur_cache_num <= target_size:
150
+ return
151
+
152
+ items_to_remove_count = self.cur_cache_num - target_size
153
+ log('info', f"缓存总数 {self.cur_cache_num} 超过限制 {self.max_entries},需要清理 {items_to_remove_count} 个")
154
+
155
+ # 收集所有缓存项及其元数据
156
+ all_items_meta = []
157
+ for key, cache_deque in self.cache.items():
158
+ for item in cache_deque:
159
+ all_items_meta.append({'key': key, 'created_at': item.get('created_at', 0), 'item': item})
160
+
161
+ # 找出最旧的 N 项
162
+ actual_remove_count = min(items_to_remove_count, len(all_items_meta))
163
+ if actual_remove_count <= 0:
164
+ return # 没有项目可移除或无需移除
165
+
166
+ items_to_remove = heapq.nsmallest(actual_remove_count, all_items_meta, key=lambda x: x['created_at'])
167
+
168
+ # 执行移除
169
+ items_actually_removed = 0
170
+ keys_potentially_empty = set()
171
+ for item_meta in items_to_remove:
172
+ key_to_clean = item_meta['key']
173
+ item_to_clean = item_meta['item']
174
+
175
+ if key_to_clean in self.cache:
176
+ try:
177
+ # 直接从 deque 中移除指定的 item 对象
178
+ self.cache[key_to_clean].remove(item_to_clean)
179
+ items_actually_removed += 1
180
+ # 计数器在最后统一更新
181
+ log('info', f"因容量限制,删除键 {key_to_clean[:8]}... 的旧缓存项 (创建于 {item_meta['created_at']})。")
182
+ keys_potentially_empty.add(key_to_clean)
183
+ except (KeyError, ValueError):
184
+ log('warning', f"尝试因容量限制删除缓存项时未找到 (可能已被提前移除): {key_to_clean[:8]}...")
185
+ pass
186
+
187
+ # 检查是否有 deque 因本次清理变空
188
+ for key in keys_potentially_empty:
189
+ if key in self.cache and not self.cache[key]:
190
+ del self.cache[key]
191
+ log('info', f"因容量限制清理后,键 {key[:8]}... 的deque已空,移除该键。")
192
+
193
+ # 统一更新缓存计数
194
+ if items_actually_removed > 0:
195
+ self.cur_cache_num = max(0, self.cur_cache_num - items_actually_removed)
196
+ log('info', f"因容量限制,共清理了 {items_actually_removed} 个旧缓存项。清理后缓存数: {self.cur_cache_num}")
197
+
198
+ def generate_cache_key(chat_request, last_n_messages: int = 65536, is_gemini=False) -> str:
199
+ """
200
+ 根据模型名称和最后 N 条消息生成请求的唯一缓存键。
201
+ Args:
202
+ chat_request: 包含模型和消息列表的请求对象 (符合OpenAI格式)。
203
+ last_n_messages: 需要包含在缓存键计算中的最后消息的数量。
204
+ Returns:
205
+ 一个代表该请求的唯一缓存键字符串 (xxhash64哈希值)。
206
+ """
207
+ h = xxhash.xxh64()
208
+
209
+ # 1. 哈希模型名称
210
+ h.update(chat_request.model.encode('utf-8'))
211
+
212
+ if last_n_messages <= 0:
213
+ # 如果不考虑消息,直接返回基于模型的哈希
214
+ return h.hexdigest()
215
+
216
+ messages_processed = 0
217
+
218
+ # 2. 增量哈希最后 N 条消息 (从后往前)
219
+ if is_gemini:
220
+ # log('INFO', f"开启增量哈希gemini格式内容")
221
+ for content_item in reversed(chat_request.payload.contents):
222
+ if messages_processed >= last_n_messages:
223
+ break
224
+ role = content_item.get('role')
225
+ if role is not None and isinstance(role, str):
226
+ h.update(b'role:')
227
+ h.update(role.encode('utf-8'))
228
+ # log('INFO', f"哈希gemini格式角��{role}")
229
+ parts = content_item.get('parts', [])
230
+ if not isinstance(parts, list):
231
+ parts = []
232
+ for part in parts:
233
+ text_content = part.get('text')
234
+ if text_content is not None and isinstance(text_content, str):
235
+ h.update(b'text:')
236
+ h.update(text_content.encode('utf-8'))
237
+ # log('INFO', f"哈希gemini格式文本内容{text_content}")
238
+
239
+ inline_data_obj = part.get('inline_data')
240
+ if inline_data_obj is not None and isinstance(inline_data_obj, dict):
241
+ h.update(b'inline_data:')
242
+ data_payload = inline_data_obj.get('data', '')
243
+ # log('INFO', f"哈希gemini格式非文本内容{data_payload[:32]}")
244
+ if isinstance(data_payload, str):
245
+ h.update(b'data_prefix:')
246
+ h.update(data_payload[:32].encode('utf-8'))
247
+
248
+ file_data_obj = part.get('file_data')
249
+ if file_data_obj is not None and isinstance(file_data_obj, dict):
250
+ h.update(b'file_data:')
251
+ file_uri = file_data_obj.get('file_uri', '')
252
+ if isinstance(file_uri, str):
253
+ h.update(b'file_uri:')
254
+ h.update(file_uri.encode('utf-8'))
255
+ messages_processed += 1
256
+
257
+ else :
258
+ for msg in reversed(chat_request.messages):
259
+ if messages_processed >= last_n_messages:
260
+ break
261
+
262
+ # 哈希角色
263
+ h.update(b'role:')
264
+ h.update(msg.get('role', '').encode('utf-8'))
265
+
266
+ # 哈希内容
267
+ content = msg.get('content')
268
+ if isinstance(content, str):
269
+ h.update(b'text:')
270
+ h.update(content.encode('utf-8'))
271
+ elif isinstance(content, list):
272
+ # 处理图文混合内容
273
+ for item in content:
274
+ item_type = item.get('type') if hasattr(item, 'get') else None
275
+ if item_type == 'text':
276
+ text = item.get('text', '') if hasattr(item, 'get') else ''
277
+ h.update(b'text:')
278
+ h.update(text.encode('utf-8'))
279
+ elif item_type == 'image_url':
280
+ image_url = item.get('image_url', {}) if hasattr(item, 'get') else {}
281
+ image_data = image_url.get('url', '') if hasattr(image_url, 'get') else ''
282
+
283
+ h.update(b'image_url:') # 加入类型标识符
284
+ if image_data.startswith('data:image/'):
285
+ # 对于base64图像,使用前32字符作为标识符
286
+ h.update(image_data[:32].encode('utf-8'))
287
+ else:
288
+ h.update(image_data.encode('utf-8'))
289
+
290
+ messages_processed += 1
291
+ return h.hexdigest()
app/utils/error_handling.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import requests
2
+ import httpx # 添加 httpx 导入
3
+ import logging
4
+ import asyncio
5
+ from fastapi import HTTPException, status
6
+ from app.utils.logging import format_log_message
7
+ from app.utils.logging import log
8
+
9
+ logger = logging.getLogger("my_logger")
10
+
11
+ def handle_gemini_error(error, current_api_key) -> str:
12
+ # 同时检查 requests 和 httpx 的 HTTPError
13
+ if isinstance(error, (requests.exceptions.HTTPError, httpx.HTTPStatusError)):
14
+ status_code = error.response.status_code
15
+ if status_code == 400:
16
+ try:
17
+ error_data = error.response.json()
18
+ if 'error' in error_data:
19
+ if error_data['error'].get('code') == "invalid_argument":
20
+ error_message = "无效的 API 密钥"
21
+ log('ERROR', f"{current_api_key[:8]} ... {current_api_key[-3:]} → 无效,可能已过期或被删除",
22
+ extra={'key': current_api_key[:8], 'status_code': status_code, 'error_message': error_message})
23
+ # key_manager.blacklist_key(current_api_key)
24
+
25
+ return error_message
26
+ error_message = error_data['error'].get('message', 'Bad Request')
27
+
28
+ log('WARNING', f"400 错误请求: {error_message}",
29
+ extra={'key': current_api_key[:8], 'status_code': status_code, 'error_message': error_message})
30
+ return f"400 错误请求: {error_message}"
31
+ except ValueError:
32
+ error_message = "400 错误请求:响应不是有效的JSON格式"
33
+ extra_log_400_json = {'key': current_api_key[:8], 'status_code': status_code, 'error_message': error_message}
34
+ log('WARNING', error_message, extra=extra_log_400_json)
35
+ return error_message
36
+
37
+ elif status_code == 403:
38
+ error_message = f"权限被拒绝"
39
+ log('ERROR', error_message,
40
+ extra={'key': current_api_key[:8], 'status_code': status_code})
41
+ # key_manager.blacklist_key(current_api_key)
42
+
43
+ return error_message
44
+
45
+ elif status_code == 429:
46
+ error_message = f"API 密钥配额已用尽或其他原因"
47
+ log('WARNING', error_message,
48
+ extra={'key': current_api_key[:8], 'status_code': status_code})
49
+ # key_manager.blacklist_key(current_api_key)
50
+
51
+ return error_message
52
+
53
+ if status_code == 500:
54
+ error_message = f'Gemini API 内部错误'
55
+ log('WARNING', error_message,
56
+ extra={'key': current_api_key[:8], 'status_code': status_code})
57
+ return error_message
58
+
59
+ if status_code == 503:
60
+ error_message = f"Gemini API 服务繁忙"
61
+ log('WARNING', error_message,
62
+ extra={'key': current_api_key[:8], 'status_code': status_code})
63
+ return error_message
64
+
65
+ else:
66
+ error_message = f"未知错误: {status_code}"
67
+ log('WARNING', f"{status_code} 未知错误",
68
+ extra={'key': current_api_key[:8], 'status_code': status_code, 'error_message': error_message})
69
+
70
+ return f"未知错误/模型不可用: {status_code}"
71
+
72
+ elif isinstance(error, requests.exceptions.ConnectionError):
73
+ error_message = "连接错误"
74
+ log('WARNING', error_message, extra={'error_message': error_message})
75
+ return error_message
76
+
77
+ elif isinstance(error, requests.exceptions.Timeout):
78
+ error_message = "请求超时"
79
+ log('WARNING', error_message, extra={'error_message': error_message})
80
+ return error_message
81
+ else:
82
+ error_message = f"发生未知错误: {error}"
83
+ log('ERROR', error_message, extra={'error_message': error_message})
84
+ return error_message
85
+
86
+ def translate_error(message: str) -> str:
87
+ if "quota exceeded" in message.lower():
88
+ return "API 密钥配额已用尽"
89
+ if "invalid argument" in message.lower():
90
+ return "无效参数"
91
+ if "internal server error" in message.lower():
92
+ return "服务器内部错误"
93
+ if "service unavailable" in message.lower():
94
+ return "服务不可用"
95
+ return message
96
+
97
+ async def handle_api_error(e: Exception, api_key: str, key_manager, request_type: str, model: str, retry_count: int = 0):
98
+ """统一处理API错误"""
99
+
100
+ # 同时检查 requests 和 httpx 的 HTTPError
101
+ if isinstance(e, (requests.exceptions.HTTPError, httpx.HTTPStatusError)):
102
+ status_code = e.response.status_code
103
+ # 对500和503错误实现自动重试机制, 最多重试3次
104
+ if retry_count < 3 and (status_code == 500 or status_code == 503):
105
+ error_message = 'Gemini API 内部错误' if (status_code == 500) else "Gemini API 服务目前不可用"
106
+
107
+ # 等待时间 : MIN_RETRY_DELAY=1, MAX_RETRY_DELAY=16
108
+ wait_time = min(1 * (2 ** retry_count), 16)
109
+ log('warning', f"{error_message},将等待{wait_time}秒后重试 ({retry_count+1}/3)",
110
+ extra={'key': api_key[:8], 'request_type': request_type, 'model': model, 'status_code': int(status_code)})
111
+ # 等待后返回重试信号
112
+ await asyncio.sleep(wait_time)
113
+ return {'remove_cache': False}
114
+
115
+ elif status_code == 429:
116
+ error_message = "API 密钥配额已用尽或其他原因"
117
+ log('WARNING', f"429 官方资源耗尽或其他原因",
118
+ extra={'key': api_key[:8], 'status_code': status_code, 'error_message': error_message})
119
+ # key_manager.blacklist_key(api_key)
120
+
121
+ return {'remove_cache': False,'error': error_message, 'should_switch_key': True}
122
+
123
+ else:
124
+ error_detail = handle_gemini_error(e, api_key)
125
+
126
+ # # 重试次数用尽,在日志中输出错误状态码
127
+ # log('error', f"Gemini 服务器错误({status_code})",
128
+ # extra={'key': api_key[:8], 'request_type': request_type, 'model': model, 'status_code': int(status_code)})
129
+
130
+ # 不再切换密钥,直接向客户端抛出HTTP异常
131
+ raise HTTPException(status_code=int(status_code),
132
+ detail=f"Gemini API 服务器错误({status_code}),请稍后重试")
133
+
134
+ # 对于其他错误,返回切换密钥的信号,并输出错误信息到日志中
135
+ error_detail = handle_gemini_error(e, api_key)
136
+ return {'should_switch_key': True, 'error': error_detail, 'remove_cache': True}
app/utils/logging.py ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ from datetime import datetime
3
+ from collections import deque
4
+ from threading import Lock
5
+
6
+ DEBUG = False # 可以从环境变量中获取
7
+
8
+ LOG_FORMAT_DEBUG = '%(asctime)s - %(levelname)s - [%(key)s]-%(request_type)s-[%(model)s]-%(status_code)s: %(message)s - %(error_message)s'
9
+ LOG_FORMAT_NORMAL = '[%(asctime)s] [%(levelname)s] [%(key)s]-%(request_type)s-[%(model)s]-%(status_code)s: %(message)s'
10
+
11
+ # Vertex日志格式
12
+ VERTEX_LOG_FORMAT_DEBUG = '%(asctime)s - %(levelname)s - [%(vertex_id)s]-%(operation)s-[%(status)s]: %(message)s - %(error_message)s'
13
+ VERTEX_LOG_FORMAT_NORMAL = '[%(asctime)s] [%(levelname)s] [%(vertex_id)s]-%(operation)s-[%(status)s]: %(message)s'
14
+
15
+ # 配置 logger
16
+ logger = logging.getLogger("my_logger")
17
+ logger.setLevel(logging.DEBUG)
18
+
19
+ # 控制台处理器
20
+ console_handler = logging.StreamHandler()
21
+
22
+ # 设置日志格式
23
+ console_formatter = logging.Formatter('%(message)s')
24
+ console_handler.setFormatter(console_formatter)
25
+ logger.addHandler(console_handler)
26
+
27
+ # 日志缓存,用于在网页上显示最近的日志
28
+ class LogManager:
29
+ def __init__(self, max_logs=100):
30
+ self.logs = deque(maxlen=max_logs) # 使用双端队列存储最近的日志
31
+ self.lock = Lock()
32
+
33
+ def add_log(self, log_entry):
34
+ with self.lock:
35
+ self.logs.append(log_entry)
36
+
37
+ def get_recent_logs(self, count=50):
38
+ with self.lock:
39
+ return list(self.logs)[-count:]
40
+
41
+ # 创建日志管理器实例 (输出到前端)
42
+ log_manager = LogManager()
43
+
44
+ # Vertex日志缓存,用于在网页上显示最近的Vertex日志
45
+ class VertexLogManager:
46
+ def __init__(self, max_logs=100):
47
+ self.logs = deque(maxlen=max_logs) # 使用双端队列存储最近的Vertex日志
48
+ self.lock = Lock()
49
+
50
+ def add_log(self, log_entry):
51
+ with self.lock:
52
+ self.logs.append(log_entry)
53
+
54
+ def get_recent_logs(self, count=50):
55
+ with self.lock:
56
+ return list(self.logs)[-count:]
57
+
58
+ # 创建Vertex日志管理器实例 (输出到前端)
59
+ vertex_log_manager = VertexLogManager()
60
+
61
+ def format_log_message(level, message, extra=None):
62
+ extra = extra or {}
63
+ log_values = {
64
+ 'asctime': datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
65
+ 'levelname': level,
66
+ 'key': extra.get('key', ''),
67
+ 'request_type': extra.get('request_type', ''),
68
+ 'model': extra.get('model', ''),
69
+ 'status_code': extra.get('status_code', ''),
70
+ 'error_message': extra.get('error_message', ''),
71
+ 'message': message
72
+ }
73
+ log_format = LOG_FORMAT_DEBUG if DEBUG else LOG_FORMAT_NORMAL
74
+ formatted_log = log_format % log_values
75
+
76
+ # 将格式化后的日志添加到日志管理器
77
+ log_entry = {
78
+ 'timestamp': datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
79
+ 'level': level,
80
+ 'key': extra.get('key', ''),
81
+ 'request_type': extra.get('request_type', ''),
82
+ 'model': extra.get('model', ''),
83
+ 'status_code': extra.get('status_code', ''),
84
+ 'message': message,
85
+ 'error_message': extra.get('error_message', ''),
86
+ 'formatted': formatted_log
87
+ }
88
+ log_manager.add_log(log_entry)
89
+
90
+ return formatted_log
91
+
92
+ def vertex_format_log_message(level, message, extra=None):
93
+ extra = extra or {}
94
+ log_values = {
95
+ 'asctime': datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
96
+ 'levelname': level,
97
+ 'vertex_id': extra.get('vertex_id', ''),
98
+ 'operation': extra.get('operation', ''),
99
+ 'status': extra.get('status', ''),
100
+ 'error_message': extra.get('error_message', ''),
101
+ 'message': message
102
+ }
103
+ log_format = VERTEX_LOG_FORMAT_DEBUG if DEBUG else VERTEX_LOG_FORMAT_NORMAL
104
+ formatted_log = log_format % log_values
105
+
106
+ # 将格式化后的Vertex日志添加到Vertex日志管理器
107
+ log_entry = {
108
+ 'timestamp': datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
109
+ 'level': level,
110
+ 'vertex_id': extra.get('vertex_id', ''),
111
+ 'operation': extra.get('operation', ''),
112
+ 'status': extra.get('status', ''),
113
+ 'message': message,
114
+ 'error_message': extra.get('error_message', ''),
115
+ 'formatted': formatted_log
116
+ }
117
+ vertex_log_manager.add_log(log_entry)
118
+
119
+ return formatted_log
120
+
121
+
122
+ def log(level: str, message: str, extra: dict = None, **kwargs):
123
+ final_extra = {}
124
+
125
+ if extra is not None and isinstance(extra, dict):
126
+ final_extra.update(extra)
127
+
128
+ # 将 kwargs 中的其他关键字参数合并进来,kwargs 会覆盖 extra 中的同名键
129
+ final_extra.update(kwargs)
130
+
131
+ # 调用 format_log_message,传递合并后的 final_extra 字典
132
+ msg = format_log_message(level.upper(), message, extra=final_extra)
133
+
134
+ getattr(logger, level.lower())(msg)
135
+
136
+ def vertex_log(level: str, message: str, extra: dict = None, **kwargs):
137
+ final_extra = {}
138
+
139
+ if extra is not None and isinstance(extra, dict):
140
+ final_extra.update(extra)
141
+
142
+ # 将 kwargs 中的其他关键字参数合并进来,kwargs 会覆盖 extra 中的同名键
143
+ final_extra.update(kwargs)
144
+
145
+ # 调用 vertex_format_log_message,传递合并后的 final_extra 字典
146
+ msg = vertex_format_log_message(level.upper(), message, extra=final_extra)
147
+
148
+ getattr(logger, level.lower())(msg)
app/utils/maintenance.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys,asyncio
2
+ #from apscheduler.schedulers.background import BackgroundScheduler
3
+ from apscheduler.schedulers.asyncio import AsyncIOScheduler # 替换为异步调度器
4
+ from app.utils.logging import log
5
+ from app.utils.stats import api_stats_manager
6
+ from app.utils import check_version
7
+ from zoneinfo import ZoneInfo
8
+ from app.config import settings,persistence
9
+ import copy # 添加copy模块导入
10
+
11
+ def handle_exception(exc_type, exc_value, exc_traceback):
12
+ """
13
+ 全局异常处理函数
14
+
15
+ 处理未捕获的异常,并记录到日志中
16
+ """
17
+ if issubclass(exc_type, KeyboardInterrupt):
18
+ sys.excepthook(exc_type, exc_value, exc_traceback)
19
+ return
20
+ from app.utils.error_handling import translate_error
21
+ error_message = translate_error(str(exc_value))
22
+ log('error', f"未捕获的异常: {error_message}", status_code=500, error_message=error_message)
23
+
24
+
25
+ def schedule_cache_cleanup(response_cache_manager, active_requests_manager):
26
+ """
27
+ 设置定期清理缓存和活跃请求的定时任务
28
+ 顺便定时检查更新
29
+ Args:
30
+ response_cache_manager: 响应缓存管理器实例
31
+ active_requests_manager: 活跃请求管理器实例
32
+ """
33
+ beijing_tz = ZoneInfo("Asia/Shanghai")
34
+ scheduler = AsyncIOScheduler(timezone=beijing_tz) # 使用 AsyncIOScheduler 替代 BackgroundScheduler
35
+
36
+ # 添加任务时直接传递异步函数(无需额外包装)
37
+ scheduler.add_job(response_cache_manager.clean_expired, 'interval', minutes=1)
38
+ scheduler.add_job(active_requests_manager.clean_completed, 'interval', seconds=30)
39
+ scheduler.add_job(active_requests_manager.clean_long_running, 'interval', minutes=5, args=[300])
40
+
41
+ # 使用同步包装器调用异步函数
42
+ def run_cleanup():
43
+ try:
44
+ # 创建新的事件循环而不是获取现有的
45
+ loop = asyncio.new_event_loop()
46
+ asyncio.set_event_loop(loop)
47
+
48
+ # 在这个新循环中运行清理操作
49
+ loop.run_until_complete(api_stats_manager.cleanup())
50
+ except Exception as e:
51
+ log('error', f"清理统计数据时出错: {str(e)}")
52
+ finally:
53
+ # 确保关闭循环以释放资源
54
+ loop.close()
55
+
56
+ # 添加同步的清理任务
57
+ scheduler.add_job(run_cleanup, 'interval', minutes=5)
58
+
59
+ # 同样修改定时重置函数
60
+ def run_reset():
61
+ try:
62
+ loop = asyncio.new_event_loop()
63
+ asyncio.set_event_loop(loop)
64
+ loop.run_until_complete(api_call_stats_clean())
65
+ except Exception as e:
66
+ log('error', f"重置统计数据时出错: {str(e)}")
67
+ finally:
68
+ loop.close()
69
+
70
+ scheduler.add_job(check_version, 'interval', hours=4)
71
+ scheduler.add_job(run_reset, 'cron', hour=15, minute=0)
72
+ scheduler.start()
73
+ return scheduler
74
+
75
+ async def api_call_stats_clean():
76
+ """
77
+ 每天定时重置API调用统计数据
78
+
79
+ 使用新的统计系统重置
80
+ """
81
+ from app.utils.logging import log
82
+
83
+ try:
84
+ # 记录重置前的状态
85
+ log('info', "开始重置API调用统计数据")
86
+
87
+ # 使用新的统计系统重置
88
+ await api_stats_manager.reset()
89
+
90
+ log('info', "API调用统计数据已成功重置")
91
+ persistence.save_settings()
92
+
93
+ except Exception as e:
94
+ log('error', f"重置API调用统计数据时发生错误: {str(e)}")
95
+ raise
app/utils/rate_limiting.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ import asyncio
3
+ from fastapi import HTTPException, Request
4
+
5
+ rate_limit_data = {}
6
+ rate_limit_lock = asyncio.Lock()
7
+
8
+ async def protect_from_abuse(request: Request, max_requests_per_minute: int = 30, max_requests_per_day_per_ip: int = 600):
9
+ now = int(time.time())
10
+ minute = now // 60
11
+ day = now // (60 * 60 * 24)
12
+
13
+ minute_key = f"{request.url.path}:{minute}"
14
+ day_key = f"{request.client.host}:{day}"
15
+
16
+ async with rate_limit_lock:
17
+ minute_count, minute_timestamp = rate_limit_data.get(minute_key, (0, now))
18
+
19
+ if now - minute_timestamp >= 60:
20
+ minute_count = 0
21
+ minute_timestamp = now
22
+ minute_count += 1
23
+ rate_limit_data[minute_key] = (minute_count, minute_timestamp)
24
+
25
+ day_count, day_timestamp = rate_limit_data.get(day_key, (0, now))
26
+ if now - day_timestamp >= 86400:
27
+ day_count = 0
28
+ day_timestamp = now
29
+ day_count += 1
30
+ rate_limit_data[day_key] = (day_count, day_timestamp)
31
+
32
+ if minute_count > max_requests_per_minute:
33
+ raise HTTPException(status_code=429, detail={
34
+ "message": "Too many requests per minute", "limit": max_requests_per_minute})
35
+ if day_count > max_requests_per_day_per_ip:
36
+ raise HTTPException(status_code=429, detail={"message": "Too many requests per day from this IP", "limit": max_requests_per_day_per_ip})
app/utils/request.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import time
3
+ from typing import Dict, Any
4
+ from app.utils.logging import log
5
+
6
+ class ActiveRequestsManager:
7
+ """管理活跃API请求的类"""
8
+
9
+ def __init__(self, requests_pool: Dict[str, asyncio.Task] = None):
10
+ self.active_requests = requests_pool if requests_pool is not None else {} # 存储活跃请求
11
+
12
+ def add(self, key: str, task: asyncio.Task):
13
+ """添加新的活跃请求任务"""
14
+ task.creation_time = time.time() # 添加创建时间属性
15
+ self.active_requests[key] = task
16
+
17
+ def get(self, key: str):
18
+ """获取活跃请求任务"""
19
+ return self.active_requests.get(key)
20
+
21
+ def remove(self, key: str):
22
+ """移除活跃请求任务"""
23
+ if key in self.active_requests:
24
+ del self.active_requests[key]
25
+ return True
26
+ return False
27
+
28
+ def clean_completed(self):
29
+ """清理所有已完成或已取消的任务"""
30
+
31
+ for key, task in self.active_requests.items():
32
+ if task.done() or task.cancelled():
33
+ del self.active_requests[key]
34
+
35
+ # if keys_to_remove:
36
+ # log('info', f"清理已完成请求任务: {len(keys_to_remove)}个", cleanup='active_requests')
37
+
38
+ def clean_long_running(self, max_age_seconds: int = 300):
39
+ """清理长时间运行的任务"""
40
+ now = time.time()
41
+ long_running_keys = []
42
+
43
+ for key, task in list(self.active_requests.items()):
44
+ if (hasattr(task, 'creation_time') and
45
+ task.creation_time < now - max_age_seconds and
46
+ not task.done() and not task.cancelled()):
47
+
48
+ long_running_keys.append(key)
49
+ task.cancel() # 取消长时间运行的任务
50
+
51
+ if long_running_keys:
52
+ log('warning', f"取消长时间运行的任务: {len(long_running_keys)}个", cleanup='long_running_tasks')
app/utils/response.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import time
3
+ from app.utils.logging import log
4
+
5
+ def openAI_from_text(model="gemini",content=None,finish_reason=None,total_token_count=0,stream=True):
6
+ """
7
+ 根据传入参数,创建 OpenAI 标准响应对象块
8
+ """
9
+
10
+ now_time = int(time.time())
11
+ content_chunk = {}
12
+ formatted_chunk = {
13
+ "id": f"chatcmpl-{now_time}",
14
+ "created": now_time,
15
+ "model": model,
16
+ "choices": [{"index": 0 , "finish_reason": finish_reason}]
17
+ }
18
+
19
+ if content:
20
+ content_chunk = {"role": "assistant", "content": content}
21
+
22
+ if finish_reason:
23
+ formatted_chunk["usage"]= {"total_tokens": total_token_count}
24
+
25
+ if stream:
26
+ formatted_chunk["choices"][0]["delta"] = content_chunk
27
+ formatted_chunk["object"] = "chat.completion.chunk"
28
+ return f"data: {json.dumps(formatted_chunk, ensure_ascii=False)}\n\n"
29
+ else:
30
+ formatted_chunk["choices"][0]["message"] = content_chunk
31
+ formatted_chunk["object"] = "chat.completion"
32
+ return formatted_chunk
33
+
34
+ def gemini_from_text(content=None, finish_reason=None, total_token_count=0, stream=True):
35
+ """
36
+ 根据传入参数,创建 Gemini API 标准响应对象块 (GenerateContentResponse 格式)。
37
+ """
38
+ gemini_response = {
39
+ "candidates": {
40
+ "index": 0,
41
+ "content": {
42
+ "parts": [],
43
+ "role": "model"
44
+ }
45
+ }
46
+ }
47
+ if content:
48
+ gemini_response["candidates"]["content"]["parts"].append({"text": content})
49
+
50
+ if finish_reason:
51
+ gemini_response["usageMetadata"]= {"totalTokenCount": total_token_count}
52
+
53
+ if stream:
54
+ return f"data: {json.dumps(gemini_response, ensure_ascii=False)}\n\n"
55
+ else:
56
+ return gemini_response
57
+
58
+
59
+ def openAI_from_Gemini(response,stream=True):
60
+ """
61
+ 根据 GeminiResponseWrapper 对象创建 OpenAI 标准响应对象块。
62
+
63
+ Args:
64
+ response: GeminiResponseWrapper 对象,包含响应数据。
65
+
66
+ Returns:
67
+ OpenAI 标准响应
68
+ """
69
+ now_time = int(time.time())
70
+ chunk_id = f"chatcmpl-{now_time}" # 使用时间戳生成唯一 ID
71
+ content_chunk = {}
72
+ formatted_chunk = {
73
+ "id": chunk_id,
74
+ "created": now_time,
75
+ "model": response.model,
76
+ "choices": [{"index": 0 , "finish_reason": response.finish_reason}]
77
+ }
78
+
79
+ # 准备 usage 数据,处理属性缺失或为 None 的情况
80
+ prompt_tokens_raw = getattr(response, 'prompt_token_count', None)
81
+ candidates_tokens_raw = getattr(response, 'candidates_token_count', None)
82
+ total_tokens_raw = getattr(response, 'total_token_count', None)
83
+
84
+ usage_data = {
85
+ "prompt_tokens": int(prompt_tokens_raw) if prompt_tokens_raw else 0,
86
+ "completion_tokens": int(candidates_tokens_raw) if candidates_tokens_raw else 0,
87
+ "total_tokens": int(total_tokens_raw) if total_tokens_raw else 0
88
+ }
89
+
90
+ if response.function_call:
91
+ tool_calls=[]
92
+ # 处理函数调用的每一部分
93
+ for part in response.function_call:
94
+ function_name = part.get("name")
95
+ # Gemini 的 args 是 dict, OpenAI 需要 string
96
+ function_args_str = json.dumps(part.get("args", {}), ensure_ascii=False)
97
+
98
+ tool_call_id = f"call_{function_name}" # 编码函数名到 ID
99
+ tool_calls.append({
100
+ "id": tool_call_id,
101
+ "type": "function",
102
+ "function": {
103
+ "name": function_name,
104
+ "arguments": function_args_str,
105
+ }
106
+ })
107
+
108
+ content_chunk = {
109
+ "role": "assistant",
110
+ "content": None, # 函数调用时 content 为 null
111
+ "tool_calls": tool_calls
112
+ }
113
+ elif response.text:
114
+ # 处理普通文本响应
115
+ content_chunk = {"role": "assistant", "content": response.text}
116
+
117
+ if stream:
118
+ formatted_chunk["choices"][0]["delta"] = content_chunk
119
+ formatted_chunk["object"] = "chat.completion.chunk"
120
+ # 仅在流结束时添加 usage 字段
121
+ if response.finish_reason:
122
+ formatted_chunk["usage"] = usage_data
123
+ return f"data: {json.dumps(formatted_chunk, ensure_ascii=False)}\n\n"
124
+
125
+ else:
126
+ formatted_chunk["choices"][0]["message"] = content_chunk
127
+ formatted_chunk["object"] = "chat.completion"
128
+ # 非流式响应总是包含 usage 字段,以满足 response_model 验证
129
+ formatted_chunk["usage"] = usage_data
130
+ return formatted_chunk
app/utils/stats.py ADDED
@@ -0,0 +1,299 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ from datetime import datetime, timedelta
3
+ from app.utils.logging import log
4
+ import app.config.settings as settings
5
+ from collections import defaultdict, Counter
6
+ import time
7
+ import threading
8
+ import queue
9
+ import functools
10
+
11
+ class ApiStatsManager:
12
+ """API调用统计管理器,优化性能的新实现"""
13
+
14
+ def __init__(self, enable_background=True, batch_interval=1.0):
15
+ # 使用Counter记录API密钥和模型的调用次数
16
+ self.api_key_counts = Counter() # 记录每个API密钥的调用次数
17
+ self.model_counts = Counter() # 记录每个模型的调用次数
18
+ self.api_model_counts = defaultdict(Counter) # 记录每个API密钥对每个模型的调用次数
19
+
20
+ # 记录token使用量
21
+ self.api_key_tokens = Counter() # 记录每个API密钥的token使用量
22
+ self.model_tokens = Counter() # 记录每个模型的token使用量
23
+ self.api_model_tokens = defaultdict(Counter) # 记录每个API密钥对每个模型的token使用量
24
+
25
+ # 用于时间序列分析的数据结构(最近24小时,按分钟分组)
26
+ self.time_buckets = {} # 格式: {timestamp_minute: {"calls": count, "tokens": count}}
27
+
28
+ # 保存与兼容格式相关的调用日志(最小化存储)
29
+ self.recent_calls = [] # 仅保存最近的少量调用,用于前端展示
30
+ self.max_recent_calls = 100 # 最大保存的最近调用记录数
31
+
32
+ # 当前时间分钟桶的时间戳(分钟级别)
33
+ self.current_minute = self._get_minute_timestamp(datetime.now())
34
+
35
+ # 清理间隔(小时)
36
+ self.cleanup_interval = 1
37
+ self.last_cleanup = time.time()
38
+
39
+ # 使用线程锁而不是asyncio锁
40
+ self._counters_lock = threading.Lock()
41
+ self._time_series_lock = threading.Lock()
42
+ self._recent_calls_lock = threading.Lock()
43
+
44
+ # 后台处理相关
45
+ self.enable_background = enable_background
46
+ self.batch_interval = batch_interval
47
+ self._update_queue = queue.Queue()
48
+ self._worker_thread = None
49
+ self._stop_event = threading.Event()
50
+
51
+ if enable_background:
52
+ self._start_worker()
53
+
54
+ def _start_worker(self):
55
+ """启动后台工作线程"""
56
+ if self._worker_thread is None or not self._worker_thread.is_alive():
57
+ self._stop_event.clear()
58
+ self._worker_thread = threading.Thread(
59
+ target=self._worker_loop,
60
+ daemon=True
61
+ )
62
+ self._worker_thread.start()
63
+
64
+ def _worker_loop(self):
65
+ """后台工作线程的主循环"""
66
+ batch = []
67
+ last_process = time.time()
68
+
69
+ while not self._stop_event.is_set():
70
+ try:
71
+ # 非阻塞获取更新
72
+ try:
73
+ update = self._update_queue.get_nowait()
74
+ batch.append(update)
75
+ except queue.Empty:
76
+ pass
77
+
78
+ # 处理批次或超时
79
+ current_time = time.time()
80
+ if batch and (current_time - last_process >= self.batch_interval):
81
+ self._process_batch(batch)
82
+ batch = []
83
+ last_process = current_time
84
+
85
+ # 短暂休眠以避免CPU占用过高
86
+ time.sleep(0.01)
87
+
88
+ except Exception as e:
89
+ log('error', f"后台处理线程错误: {str(e)}")
90
+ time.sleep(1) # 发生错误时短暂休眠
91
+
92
+ def _process_batch(self, batch):
93
+ """处理一批更新"""
94
+ with self._counters_lock:
95
+ for api_key, model, tokens in batch:
96
+ self.api_key_counts[api_key] += 1
97
+ self.model_counts[model] += 1
98
+ self.api_model_counts[api_key][model] += 1
99
+ self.api_key_tokens[api_key] += tokens
100
+ self.model_tokens[model] += tokens
101
+ self.api_model_tokens[api_key][model] += tokens
102
+
103
+ async def update_stats(self, api_key, model, tokens=0):
104
+ """更新API调用统计"""
105
+ if self.enable_background:
106
+ # 将更新放入队列
107
+ self._update_queue.put((api_key, model, tokens))
108
+ else:
109
+ # 同步更新
110
+ with self._counters_lock:
111
+ self.api_key_counts[api_key] += 1
112
+ self.model_counts[model] += 1
113
+ self.api_model_counts[api_key][model] += 1
114
+ self.api_key_tokens[api_key] += tokens
115
+ self.model_tokens[model] += tokens
116
+ self.api_model_tokens[api_key][model] += tokens
117
+
118
+ # 更新时间序列数据
119
+ now = datetime.now()
120
+ minute_ts = self._get_minute_timestamp(now)
121
+
122
+ with self._time_series_lock:
123
+ if minute_ts not in self.time_buckets:
124
+ self.time_buckets[minute_ts] = {"calls": 0, "tokens": 0}
125
+
126
+ self.time_buckets[minute_ts]["calls"] += 1
127
+ self.time_buckets[minute_ts]["tokens"] += tokens
128
+ self.current_minute = minute_ts
129
+
130
+ # 更新最近调用记录
131
+ with self._recent_calls_lock:
132
+ compact_call = {
133
+ 'api_key': api_key,
134
+ 'model': model,
135
+ 'timestamp': now,
136
+ 'tokens': tokens
137
+ }
138
+
139
+ self.recent_calls.append(compact_call)
140
+ if len(self.recent_calls) > self.max_recent_calls:
141
+ self.recent_calls.pop(0)
142
+
143
+ # 记录日志
144
+ log_message = f"API调用已记录: 秘钥 '{api_key[:8]}', 模型 '{model}', 令牌: {tokens if tokens is not None else 0}"
145
+ log('info', log_message)
146
+
147
+ async def cleanup(self):
148
+ """清理超过24小时的时间桶数据"""
149
+ now = datetime.now()
150
+ day_ago_ts = self._get_minute_timestamp(now - timedelta(days=1))
151
+
152
+ with self._time_series_lock:
153
+ # 直接删除旧的时间桶
154
+ for ts in list(self.time_buckets.keys()):
155
+ if ts < day_ago_ts:
156
+ del self.time_buckets[ts]
157
+
158
+ self.last_cleanup = time.time()
159
+
160
+ async def maybe_cleanup(self, force=False):
161
+ """根据需要清理旧数据"""
162
+ now = time.time()
163
+ if force or (now - self.last_cleanup > self.cleanup_interval * 3600):
164
+ await self.cleanup()
165
+ self.last_cleanup = now
166
+
167
+ async def get_api_key_usage(self, api_key, model=None):
168
+ """获取API密钥的使用统计"""
169
+ with self._counters_lock:
170
+ if model:
171
+ return self.api_model_counts[api_key][model]
172
+ else:
173
+ return self.api_key_counts[api_key]
174
+
175
+ def get_calls_last_24h(self):
176
+ """获取过去24小时的总调用次数"""
177
+ with self._counters_lock:
178
+ return sum(self.api_key_counts.values())
179
+
180
+ def get_calls_last_hour(self, now=None):
181
+ """获取过去一小时的总调用次数"""
182
+ if now is None:
183
+ now = datetime.now()
184
+
185
+ hour_ago_ts = self._get_minute_timestamp(now - timedelta(hours=1))
186
+
187
+ with self._time_series_lock:
188
+ return sum(data["calls"] for ts, data in self.time_buckets.items()
189
+ if ts >= hour_ago_ts)
190
+
191
+ def get_calls_last_minute(self, now=None):
192
+ """获取过去一分钟的总调用次数"""
193
+ if now is None:
194
+ now = datetime.now()
195
+
196
+ minute_ago_ts = self._get_minute_timestamp(now - timedelta(minutes=1))
197
+
198
+ with self._time_series_lock:
199
+ return sum(data["calls"] for ts, data in self.time_buckets.items()
200
+ if ts >= minute_ago_ts)
201
+
202
+ def get_time_series_data(self, minutes=30, now=None):
203
+ """获取过去N分钟的时间序列数据"""
204
+ if now is None:
205
+ now = datetime.now()
206
+
207
+ calls_series = []
208
+ tokens_series = []
209
+
210
+ with self._time_series_lock:
211
+ for i in range(minutes, -1, -1):
212
+ minute_dt = now - timedelta(minutes=i)
213
+ minute_ts = self._get_minute_timestamp(minute_dt)
214
+
215
+ bucket = self.time_buckets.get(minute_ts, {"calls": 0, "tokens": 0})
216
+
217
+ calls_series.append({
218
+ 'time': minute_dt.strftime('%H:%M'),
219
+ 'value': bucket["calls"]
220
+ })
221
+
222
+ tokens_series.append({
223
+ 'time': minute_dt.strftime('%H:%M'),
224
+ 'value': bucket["tokens"]
225
+ })
226
+
227
+ return calls_series, tokens_series
228
+
229
+ def get_api_key_stats(self, api_keys):
230
+ """获取API密钥的详细统计信息"""
231
+ stats = []
232
+
233
+ with self._counters_lock:
234
+ for api_key in api_keys:
235
+ api_key_id = api_key[:8]
236
+ calls_24h = self.api_key_counts[api_key]
237
+ total_tokens = self.api_key_tokens[api_key]
238
+
239
+ model_stats = {}
240
+ for model, count in self.api_model_counts[api_key].items():
241
+ tokens = self.api_model_tokens[api_key][model]
242
+ model_stats[model] = {
243
+ 'calls': count,
244
+ 'tokens': tokens
245
+ }
246
+
247
+ usage_percent = (calls_24h / settings.API_KEY_DAILY_LIMIT) * 100 if settings.API_KEY_DAILY_LIMIT > 0 else 0
248
+
249
+ stats.append({
250
+ 'api_key': api_key_id,
251
+ 'calls_24h': calls_24h,
252
+ 'total_tokens': total_tokens,
253
+ 'limit': settings.API_KEY_DAILY_LIMIT,
254
+ 'usage_percent': round(usage_percent, 2),
255
+ 'model_stats': model_stats
256
+ })
257
+
258
+ stats.sort(key=lambda x: x['usage_percent'], reverse=True)
259
+ return stats
260
+
261
+ async def reset(self):
262
+ """重置所有统计数据"""
263
+ with self._counters_lock:
264
+ self.api_key_counts.clear()
265
+ self.model_counts.clear()
266
+ self.api_model_counts.clear()
267
+ self.api_key_tokens.clear()
268
+ self.model_tokens.clear()
269
+ self.api_model_tokens.clear()
270
+
271
+ with self._time_series_lock:
272
+ self.time_buckets.clear()
273
+
274
+ with self._recent_calls_lock:
275
+ self.recent_calls.clear()
276
+
277
+ self.current_minute = self._get_minute_timestamp(datetime.now())
278
+ self.last_cleanup = time.time()
279
+
280
+ def _get_minute_timestamp(self, dt):
281
+ """将时间戳转换为分钟级别的时间戳(按分钟取整)"""
282
+ return int(dt.timestamp() // 60 * 60)
283
+
284
+ # 创建全局单例实例
285
+ api_stats_manager = ApiStatsManager()
286
+
287
+ # 兼容现有代码的函数
288
+ def clean_expired_stats(api_call_stats):
289
+ """清理过期统计数据的函数 (兼容旧接口)"""
290
+ asyncio.create_task(api_stats_manager.cleanup())
291
+
292
+ async def update_api_call_stats(api_call_stats, endpoint=None, model=None, token=None):
293
+ """更新API调用统计的函数 (兼容旧接口)"""
294
+ if endpoint and model:
295
+ await api_stats_manager.update_stats(endpoint, model, token if token is not None else 0)
296
+
297
+ async def get_api_key_usage(api_call_stats, api_key, model=None):
298
+ """获取API密钥的调用次数 (兼容旧接口)"""
299
+ return await api_stats_manager.get_api_key_usage(api_key, model)
app/utils/version.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import requests
2
+ from app.utils.logging import log
3
+ import app.config.settings as settings
4
+ async def check_version():
5
+ """
6
+ 检查应用程序版本更新
7
+
8
+ 从本地和远程获取版本信息,并比较版本号以确定是否有更新
9
+ """
10
+ # 导入全局变量
11
+ try:
12
+ # 读取本地版本
13
+ with open("./version.txt", "r") as f:
14
+ version_line = f.read().strip()
15
+ settings.version['local_version'] = version_line.split("=")[1] if "=" in version_line else "0.0.0"
16
+
17
+ # 获取远程版本
18
+ github_url = "https://raw.githubusercontent.com/wyeeeee/hajimi/refs/heads/main/version.txt"
19
+ response = requests.get(github_url, timeout=5)
20
+ if response.status_code == 200:
21
+ version_line = response.text.strip()
22
+ settings.version['remote_version']= version_line.split("=")[1] if "=" in version_line else "0.0.0"
23
+ # 比较版本号
24
+ local_parts = [int(x) for x in settings.version['local_version'].split(".")]
25
+ remote_parts = [int(x) for x in settings.version['remote_version'].split(".")]
26
+
27
+ # 确保两个列表长度相同
28
+ while len(local_parts) < len(remote_parts):
29
+ local_parts.append(0)
30
+ while len(remote_parts) < len(local_parts):
31
+ remote_parts.append(0)
32
+
33
+ # 比较版本号
34
+ settings.version['has_update'] = False
35
+ for i in range(len(local_parts)):
36
+ if remote_parts[i] > local_parts[i]:
37
+ settings.version['has_update'] = True
38
+ break
39
+ elif remote_parts[i] < local_parts[i]:
40
+ break
41
+
42
+ log('info', f"版本检查: 本地版本 {settings.version['local_version']}, 远程版本 {settings.version['remote_version']}, 有更新: {settings.version['has_update']}")
43
+ else:
44
+ log('warning', f"无法获取远程版本信息,HTTP状态码: {response.status_code}")
45
+ except Exception as e:
46
+ log('error', f"版本检查失败: {str(e)}")
47
+
48
+ return settings.version['has_update']
app/vertex/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # This file makes the 'app' directory a Python package.
app/vertex/api_helpers.py ADDED
@@ -0,0 +1,317 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import time
3
+ import math
4
+ import asyncio
5
+ from typing import List, Dict, Any, Callable, Union, Optional
6
+ from fastapi.responses import JSONResponse, StreamingResponse
7
+
8
+ from google.auth.transport.requests import Request as AuthRequest
9
+ from google.genai import types
10
+ from google import genai # Needed if _execute_gemini_call uses genai.Client directly
11
+ from app.vertex.message_processing import parse_gemini_response_for_reasoning_and_content
12
+ # Local module imports
13
+ from app.vertex.models import OpenAIRequest, OpenAIMessage # Changed from relative
14
+ from app.vertex.message_processing import deobfuscate_text, convert_to_openai_format, convert_chunk_to_openai, create_final_chunk # Changed from relative
15
+ import app.vertex.config as app_config # Changed from relative
16
+ from app.config import settings # 导入settings模块
17
+
18
+ def create_openai_error_response(status_code: int, message: str, error_type: str) -> Dict[str, Any]:
19
+ return {
20
+ "error": {
21
+ "message": message,
22
+ "type": error_type,
23
+ "code": status_code,
24
+ "param": None,
25
+ }
26
+ }
27
+
28
+ def create_generation_config(request: OpenAIRequest) -> Dict[str, Any]:
29
+ config = {}
30
+ if request.temperature is not None: config["temperature"] = request.temperature
31
+ if request.max_tokens is not None: config["max_output_tokens"] = request.max_tokens
32
+ if request.top_p is not None: config["top_p"] = request.top_p
33
+ if request.top_k is not None: config["top_k"] = request.top_k
34
+ if request.stop is not None: config["stop_sequences"] = request.stop
35
+ if request.seed is not None: config["seed"] = request.seed
36
+ if request.presence_penalty is not None: config["presence_penalty"] = request.presence_penalty
37
+ if request.frequency_penalty is not None: config["frequency_penalty"] = request.frequency_penalty
38
+ if request.n is not None: config["candidate_count"] = request.n
39
+ config["safety_settings"] = [
40
+ types.SafetySetting(category="HARM_CATEGORY_HATE_SPEECH", threshold="OFF"),
41
+ types.SafetySetting(category="HARM_CATEGORY_DANGEROUS_CONTENT", threshold="OFF"),
42
+ types.SafetySetting(category="HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold="OFF"),
43
+ types.SafetySetting(category="HARM_CATEGORY_HARASSMENT", threshold="OFF"),
44
+ types.SafetySetting(category="HARM_CATEGORY_CIVIC_INTEGRITY", threshold="OFF")
45
+ ]
46
+ return config
47
+
48
+ def is_response_valid(response):
49
+ if response is None:
50
+ print("DEBUG: Response is None, therefore invalid.")
51
+ return False
52
+
53
+ # Check for direct text attribute
54
+ if hasattr(response, 'text') and isinstance(response.text, str) and response.text.strip():
55
+ # print("DEBUG: Response valid due to response.text")
56
+ return True
57
+
58
+ # Check candidates for text content
59
+ if hasattr(response, 'candidates') and response.candidates:
60
+ for candidate in response.candidates: # Iterate through all candidates
61
+ if hasattr(candidate, 'text') and isinstance(candidate.text, str) and candidate.text.strip():
62
+ # print(f"DEBUG: Response valid due to candidate.text in candidate")
63
+ return True
64
+ if hasattr(candidate, 'content') and hasattr(candidate.content, 'parts') and candidate.content.parts:
65
+ for part in candidate.content.parts:
66
+ if hasattr(part, 'text') and isinstance(part.text, str) and part.text.strip():
67
+ # print(f"DEBUG: Response valid due to part.text in candidate's content part")
68
+ return True
69
+
70
+ # Removed prompt_feedback as a sole criterion for validity.
71
+ # It should only be valid if actual text content is found.
72
+ # Block reasons will be checked explicitly by callers if they need to treat it as an error for retries.
73
+ print("DEBUG: Response is invalid, no usable text content found by is_response_valid.")
74
+ return False
75
+
76
+ async def _base_fake_stream_engine(
77
+ api_call_task_creator: Callable[[], asyncio.Task],
78
+ extract_text_from_response_func: Callable[[Any], str],
79
+ response_id: str,
80
+ sse_model_name: str,
81
+ is_auto_attempt: bool,
82
+ is_valid_response_func: Callable[[Any], bool],
83
+ keep_alive_interval_seconds: float,
84
+ process_text_func: Optional[Callable[[str, str], str]] = None,
85
+ check_block_reason_func: Optional[Callable[[Any], None]] = None,
86
+ reasoning_text_to_yield: Optional[str] = None,
87
+ actual_content_text_to_yield: Optional[str] = None
88
+ ):
89
+ api_call_task = api_call_task_creator()
90
+
91
+ if keep_alive_interval_seconds > 0:
92
+ while not api_call_task.done():
93
+ keep_alive_data = {"id": "chatcmpl-keepalive", "object": "chat.completion.chunk", "created": int(time.time()), "model": sse_model_name, "choices": [{"delta": {"reasoning_content": ""}, "index": 0, "finish_reason": None}]}
94
+ yield f"data: {json.dumps(keep_alive_data)}\n\n"
95
+ await asyncio.sleep(keep_alive_interval_seconds)
96
+
97
+ try:
98
+ full_api_response = await api_call_task
99
+
100
+ if check_block_reason_func:
101
+ check_block_reason_func(full_api_response)
102
+
103
+ if not is_valid_response_func(full_api_response):
104
+ raise ValueError(f"Invalid/empty API response in fake stream for model {sse_model_name}: {str(full_api_response)[:200]}")
105
+
106
+ final_reasoning_text = reasoning_text_to_yield
107
+ final_actual_content_text = actual_content_text_to_yield
108
+
109
+ if final_reasoning_text is None and final_actual_content_text is None:
110
+ extracted_full_text = extract_text_from_response_func(full_api_response)
111
+ if process_text_func:
112
+ final_actual_content_text = process_text_func(extracted_full_text, sse_model_name)
113
+ else:
114
+ final_actual_content_text = extracted_full_text
115
+ else:
116
+ if process_text_func:
117
+ if final_reasoning_text is not None:
118
+ final_reasoning_text = process_text_func(final_reasoning_text, sse_model_name)
119
+ if final_actual_content_text is not None:
120
+ final_actual_content_text = process_text_func(final_actual_content_text, sse_model_name)
121
+
122
+ if final_reasoning_text:
123
+ reasoning_delta_data = {
124
+ "id": response_id, "object": "chat.completion.chunk", "created": int(time.time()),
125
+ "model": sse_model_name, "choices": [{"index": 0, "delta": {"reasoning_content": final_reasoning_text}, "finish_reason": None}]
126
+ }
127
+ yield f"data: {json.dumps(reasoning_delta_data)}\n\n"
128
+ if final_actual_content_text:
129
+ await asyncio.sleep(0.05)
130
+
131
+ content_to_chunk = final_actual_content_text or ""
132
+ chunk_size = max(20, math.ceil(len(content_to_chunk) / 10)) if content_to_chunk else 0
133
+
134
+ if not content_to_chunk and content_to_chunk != "":
135
+ empty_delta_data = {"id": response_id, "object": "chat.completion.chunk", "created": int(time.time()), "model": sse_model_name, "choices": [{"index": 0, "delta": {"content": ""}, "finish_reason": None}]}
136
+ yield f"data: {json.dumps(empty_delta_data)}\n\n"
137
+ else:
138
+ for i in range(0, len(content_to_chunk), chunk_size):
139
+ chunk_text = content_to_chunk[i:i+chunk_size]
140
+ content_delta_data = {"id": response_id, "object": "chat.completion.chunk", "created": int(time.time()), "model": sse_model_name, "choices": [{"index": 0, "delta": {"content": chunk_text}, "finish_reason": None}]}
141
+ yield f"data: {json.dumps(content_delta_data)}\n\n"
142
+ if len(content_to_chunk) > chunk_size: await asyncio.sleep(0.05)
143
+
144
+ yield create_final_chunk(sse_model_name, response_id)
145
+ yield "data: [DONE]\n\n"
146
+
147
+ except Exception as e:
148
+ err_msg_detail = f"Error in _base_fake_stream_engine (model: '{sse_model_name}'): {type(e).__name__} - {str(e)}"
149
+ print(f"ERROR: {err_msg_detail}")
150
+ sse_err_msg_display = str(e)
151
+ if len(sse_err_msg_display) > 512: sse_err_msg_display = sse_err_msg_display[:512] + "..."
152
+ err_resp_for_sse = create_openai_error_response(500, sse_err_msg_display, "server_error")
153
+ json_payload_for_fake_stream_error = json.dumps(err_resp_for_sse)
154
+ if not is_auto_attempt:
155
+ yield f"data: {json_payload_for_fake_stream_error}\n\n"
156
+ yield "data: [DONE]\n\n"
157
+ raise
158
+
159
+ async def gemini_fake_stream_generator(
160
+ gemini_client_instance: Any,
161
+ model_for_api_call: str,
162
+ prompt_for_api_call: Union[types.Content, List[types.Content]],
163
+ gen_config_for_api_call: Dict[str, Any],
164
+ request_obj: OpenAIRequest,
165
+ is_auto_attempt: bool
166
+ ):
167
+ model_name_for_log = getattr(gemini_client_instance, 'model_name', 'unknown_gemini_model_object')
168
+ print(f"FAKE STREAMING (Gemini): Prep for '{request_obj.model}' (API model string: '{model_for_api_call}', client obj: '{model_name_for_log}') with reasoning separation.")
169
+ response_id = f"chatcmpl-{int(time.time())}"
170
+
171
+ # 1. Create and await the API call task
172
+ api_call_task = asyncio.create_task(
173
+ gemini_client_instance.aio.models.generate_content(
174
+ model=model_for_api_call,
175
+ contents=prompt_for_api_call,
176
+ config=gen_config_for_api_call
177
+ )
178
+ )
179
+
180
+ # Keep-alive loop while the main API call is in progress
181
+ outer_keep_alive_interval = app_config.FAKE_STREAMING_INTERVAL_SECONDS
182
+ if outer_keep_alive_interval > 0:
183
+ while not api_call_task.done():
184
+ keep_alive_data = {"id": "chatcmpl-keepalive", "object": "chat.completion.chunk", "created": int(time.time()), "model": request_obj.model, "choices": [{"delta": {"reasoning_content": ""}, "index": 0, "finish_reason": None}]}
185
+ yield f"data: {json.dumps(keep_alive_data)}\n\n"
186
+ await asyncio.sleep(outer_keep_alive_interval)
187
+
188
+ try:
189
+ raw_response = await api_call_task # Get the full Gemini response
190
+
191
+ # 2. Parse the response for reasoning and content using the centralized parser
192
+ separated_reasoning_text = ""
193
+ separated_actual_content_text = ""
194
+ if hasattr(raw_response, 'candidates') and raw_response.candidates:
195
+ # Typically, fake streaming would focus on the first candidate
196
+ separated_reasoning_text, separated_actual_content_text = parse_gemini_response_for_reasoning_and_content(raw_response.candidates[0])
197
+ elif hasattr(raw_response, 'text') and raw_response.text is not None: # Fallback for simpler response structures
198
+ separated_actual_content_text = raw_response.text
199
+
200
+ # 3. Define a text processing function (e.g., for deobfuscation)
201
+ def _process_gemini_text_if_needed(text: str, model_name: str) -> str:
202
+ if model_name.endswith("-encrypt-full"):
203
+ return deobfuscate_text(text)
204
+ return text
205
+
206
+ final_reasoning_text = _process_gemini_text_if_needed(separated_reasoning_text, request_obj.model)
207
+ final_actual_content_text = _process_gemini_text_if_needed(separated_actual_content_text, request_obj.model)
208
+
209
+ # Define block checking for the raw response
210
+ def _check_gemini_block_wrapper(response_to_check: Any):
211
+ if hasattr(response_to_check, 'prompt_feedback') and hasattr(response_to_check.prompt_feedback, 'block_reason') and response_to_check.prompt_feedback.block_reason:
212
+ block_message = f"Response blocked by Gemini safety filter: {response_to_check.prompt_feedback.block_reason}"
213
+ if hasattr(response_to_check.prompt_feedback, 'block_reason_message') and response_to_check.prompt_feedback.block_reason_message:
214
+ block_message += f" (Message: {response_to_check.prompt_feedback.block_reason_message})"
215
+ raise ValueError(block_message)
216
+
217
+ # Call _base_fake_stream_engine with pre-split and processed texts
218
+ async for chunk in _base_fake_stream_engine(
219
+ api_call_task_creator=lambda: asyncio.create_task(asyncio.sleep(0, result=raw_response)), # Dummy task
220
+ extract_text_from_response_func=lambda r: "", # Not directly used as text is pre-split
221
+ is_valid_response_func=is_response_valid, # Validates raw_response
222
+ check_block_reason_func=_check_gemini_block_wrapper, # Checks raw_response
223
+ process_text_func=None, # Text processing already done above
224
+ response_id=response_id,
225
+ sse_model_name=request_obj.model,
226
+ keep_alive_interval_seconds=0, # Keep-alive for this inner call is 0
227
+ is_auto_attempt=is_auto_attempt,
228
+ reasoning_text_to_yield=final_reasoning_text,
229
+ actual_content_text_to_yield=final_actual_content_text
230
+ ):
231
+ yield chunk
232
+
233
+ except Exception as e_outer_gemini:
234
+ err_msg_detail = f"Error in gemini_fake_stream_generator (model: '{request_obj.model}'): {type(e_outer_gemini).__name__} - {str(e_outer_gemini)}"
235
+ print(f"ERROR: {err_msg_detail}")
236
+ sse_err_msg_display = str(e_outer_gemini)
237
+ if len(sse_err_msg_display) > 512: sse_err_msg_display = sse_err_msg_display[:512] + "..."
238
+ err_resp_sse = create_openai_error_response(500, sse_err_msg_display, "server_error")
239
+ json_payload_error = json.dumps(err_resp_sse)
240
+ if not is_auto_attempt:
241
+ yield f"data: {json_payload_error}\n\n"
242
+ yield "data: [DONE]\n\n"
243
+
244
+ async def execute_gemini_call(
245
+ current_client: Any,
246
+ model_to_call: str,
247
+ prompt_func: Callable[[List[OpenAIMessage]], Union[types.Content, List[types.Content]]],
248
+ gen_config_for_call: Dict[str, Any],
249
+ request_obj: OpenAIRequest,
250
+ is_auto_attempt: bool = False
251
+ ):
252
+ actual_prompt_for_call = prompt_func(request_obj.messages)
253
+ client_model_name_for_log = getattr(current_client, 'model_name', 'unknown_direct_client_object')
254
+ print(f"INFO: execute_gemini_call for requested API model '{model_to_call}', using client object with internal name '{client_model_name_for_log}'. Original request model: '{request_obj.model}'")
255
+
256
+ # 每次调用时直接从settings获取最新的FAKE_STREAMING值
257
+ fake_streaming_enabled = False
258
+ if hasattr(settings, 'FAKE_STREAMING'):
259
+ fake_streaming_enabled = settings.FAKE_STREAMING
260
+ else:
261
+ fake_streaming_enabled = app_config.FAKE_STREAMING_ENABLED
262
+
263
+ print(f"DEBUG: FAKE_STREAMING setting is {fake_streaming_enabled} for model {request_obj.model}")
264
+
265
+ if request_obj.stream:
266
+ if fake_streaming_enabled:
267
+ return StreamingResponse(
268
+ gemini_fake_stream_generator(
269
+ current_client,
270
+ model_to_call,
271
+ actual_prompt_for_call,
272
+ gen_config_for_call,
273
+ request_obj,
274
+ is_auto_attempt
275
+ ),
276
+ media_type="text/event-stream"
277
+ )
278
+
279
+ response_id_for_stream = f"chatcmpl-{int(time.time())}"
280
+ cand_count_stream = request_obj.n or 1
281
+
282
+ async def _gemini_real_stream_generator_inner():
283
+ try:
284
+ async for chunk_item_call in await current_client.aio.models.generate_content_stream(
285
+ model=model_to_call,
286
+ contents=actual_prompt_for_call,
287
+ config=gen_config_for_call
288
+ ):
289
+ yield convert_chunk_to_openai(chunk_item_call, request_obj.model, response_id_for_stream, 0)
290
+ yield create_final_chunk(request_obj.model, response_id_for_stream, cand_count_stream)
291
+ yield "data: [DONE]\n\n"
292
+ except Exception as e_stream_call:
293
+ err_msg_detail_stream = f"Streaming Error (Gemini API, model string: '{model_to_call}'): {type(e_stream_call).__name__} - {str(e_stream_call)}"
294
+ print(f"ERROR: {err_msg_detail_stream}")
295
+ s_err = str(e_stream_call); s_err = s_err[:1024]+"..." if len(s_err)>1024 else s_err
296
+ err_resp = create_openai_error_response(500,s_err,"server_error")
297
+ j_err = json.dumps(err_resp)
298
+ if not is_auto_attempt:
299
+ yield f"data: {j_err}\n\n"
300
+ yield "data: [DONE]\n\n"
301
+ raise e_stream_call
302
+ return StreamingResponse(_gemini_real_stream_generator_inner(), media_type="text/event-stream")
303
+ else:
304
+ response_obj_call = await current_client.aio.models.generate_content(
305
+ model=model_to_call,
306
+ contents=actual_prompt_for_call,
307
+ config=gen_config_for_call
308
+ )
309
+ if hasattr(response_obj_call, 'prompt_feedback') and hasattr(response_obj_call.prompt_feedback, 'block_reason') and response_obj_call.prompt_feedback.block_reason:
310
+ block_msg = f"Blocked (Gemini): {response_obj_call.prompt_feedback.block_reason}"
311
+ if hasattr(response_obj_call.prompt_feedback,'block_reason_message') and response_obj_call.prompt_feedback.block_reason_message:
312
+ block_msg+=f" ({response_obj_call.prompt_feedback.block_reason_message})"
313
+ raise ValueError(block_msg)
314
+
315
+ if not is_response_valid(response_obj_call):
316
+ raise ValueError(f"Invalid non-streaming Gemini response for model string '{model_to_call}'. Resp: {str(response_obj_call)[:200]}")
317
+ return JSONResponse(content=convert_to_openai_format(response_obj_call, request_obj.model))
app/vertex/auth.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import HTTPException, Header, Depends
2
+ from fastapi.security import APIKeyHeader
3
+ from typing import Optional
4
+ from app.config import settings
5
+ import app.vertex.config as config
6
+ import os
7
+ import json
8
+ from app.utils.logging import vertex_log
9
+
10
+ # API Key security scheme
11
+ api_key_header = APIKeyHeader(name="Authorization", auto_error=False)
12
+
13
+ # Function to validate API key
14
+ def validate_api_key(api_key_to_validate: str) -> bool:
15
+
16
+ return True
17
+
18
+ # Dependency for API key validation
19
+ async def get_api_key(authorization: Optional[str] = Header(None)):
20
+ if authorization is None:
21
+ raise HTTPException(
22
+ status_code=401,
23
+ detail="Missing API key. Please include 'Authorization: Bearer YOUR_API_KEY' header."
24
+ )
25
+
26
+ # Check if the header starts with "Bearer "
27
+ if not authorization.startswith("Bearer "):
28
+ raise HTTPException(
29
+ status_code=401,
30
+ detail="Invalid API key format. Use 'Authorization: Bearer YOUR_API_KEY'"
31
+ )
32
+
33
+ # Extract the API key
34
+ api_key = authorization.replace("Bearer ", "")
35
+
36
+ # Validate the API key
37
+ if not validate_api_key(api_key): # Call local validate_api_key
38
+ raise HTTPException(
39
+ status_code=401,
40
+ detail="Invalid API key"
41
+ )
42
+
43
+ return api_key
44
+
45
+ def validate_settings():
46
+ """Validate settings for Vertex API access."""
47
+
48
+ # 检查API key
49
+ api_key = None
50
+ if hasattr(settings, 'API_KEY') and settings.API_KEY:
51
+ api_key = settings.API_KEY
52
+ else:
53
+ api_key = config.API_KEY
54
+
55
+ if not api_key:
56
+ vertex_log('warning', "API key is not set. Some functionality may be limited.")
57
+
58
+ # 检查Google credentials JSON
59
+ google_credentials_json = None
60
+ if hasattr(settings, 'GOOGLE_CREDENTIALS_JSON') and settings.GOOGLE_CREDENTIALS_JSON:
61
+ google_credentials_json = settings.GOOGLE_CREDENTIALS_JSON
62
+ else:
63
+ google_credentials_json = config.GOOGLE_CREDENTIALS_JSON
64
+
65
+ if google_credentials_json:
66
+ try:
67
+ # 尝试解析JSON确保其有效
68
+ json.loads(google_credentials_json)
69
+ vertex_log('info', "Google Credentials JSON is valid")
70
+ except json.JSONDecodeError:
71
+ vertex_log('error', "Google Credentials JSON is not valid JSON. Please check the format.")
72
+ return False
73
+
74
+ # 检查project ID
75
+ project_id = None
76
+ if hasattr(settings, 'PROJECT_ID') and settings.PROJECT_ID:
77
+ project_id = settings.PROJECT_ID
78
+ else:
79
+ project_id = config.PROJECT_ID
80
+
81
+ if not project_id:
82
+ vertex_log('warning', "Vertex AI Project ID is not set. Required for non-API key methods.")
83
+
84
+ # 检查location
85
+ location = None
86
+ if hasattr(settings, 'LOCATION') and settings.LOCATION:
87
+ location = settings.LOCATION
88
+ else:
89
+ location = config.LOCATION
90
+
91
+ if not location:
92
+ vertex_log('warning', "Vertex AI Location is not set, using default: us-central1")
93
+
94
+ # 验证凭证目录
95
+ credentials_dir = None
96
+ if hasattr(settings, 'CREDENTIALS_DIR') and settings.CREDENTIALS_DIR:
97
+ credentials_dir = settings.CREDENTIALS_DIR
98
+ else:
99
+ credentials_dir = config.CREDENTIALS_DIR
100
+
101
+ if not os.path.exists(credentials_dir):
102
+ try:
103
+ os.makedirs(credentials_dir, exist_ok=True)
104
+ vertex_log('info', f"Created credentials directory at: {credentials_dir}")
105
+ except Exception as e:
106
+ vertex_log('error', f"Failed to create credentials directory: {e}")
107
+ return False
108
+
109
+ return True
app/vertex/config.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import pathlib
3
+ from app.config import settings
4
+ from app.utils.logging import vertex_log
5
+
6
+ # 确保设置中存在所需的配置项,如果不存在则使用默认值
7
+ if not hasattr(settings, 'CREDENTIALS_DIR'):
8
+ # 设置默认目录为storage_dir下的credentials
9
+ settings.CREDENTIALS_DIR = os.path.join(settings.STORAGE_DIR, "credentials")
10
+
11
+ # 使用settings中的配置,保持原有变量名
12
+ CREDENTIALS_DIR = settings.CREDENTIALS_DIR
13
+ vertex_log('info', f"Using credentials directory: {CREDENTIALS_DIR}")
14
+
15
+ # API Key 配置
16
+ API_KEY = settings.PASSWORD if hasattr(settings, 'PASSWORD') else ""
17
+ if API_KEY:
18
+ vertex_log('info', "Using API Key authentication")
19
+ else:
20
+ vertex_log('info', "No API Key found, falling back to credentials file")
21
+
22
+ # Google Credentials JSON
23
+ GOOGLE_CREDENTIALS_JSON = settings.GOOGLE_CREDENTIALS_JSON if hasattr(settings, 'GOOGLE_CREDENTIALS_JSON') else ""
24
+ if GOOGLE_CREDENTIALS_JSON:
25
+ vertex_log('info', "Using GOOGLE_CREDENTIALS_JSON environment variable for authentication")
26
+
27
+ # 项目和位置配置
28
+ PROJECT_ID = os.environ.get('VERTEX_PROJECT_ID', '')
29
+ LOCATION = os.environ.get('VERTEX_LOCATION', 'us-central1')
30
+
31
+ # 模型配置URL
32
+ default_models_config_url = "https://raw.githubusercontent.com/gzzhongqi/vertex2openai/refs/heads/main/vertexModels.json"
33
+ MODELS_CONFIG_URL = os.environ.get('VERTEX_MODELS_CONFIG_URL', default_models_config_url)
34
+ vertex_log('info', f"Using models config URL: {MODELS_CONFIG_URL}")
35
+
36
+ # Vertex Express API Key 配置
37
+ VERTEX_EXPRESS_API_KEY_VAL = []
38
+ if hasattr(settings, 'VERTEX_EXPRESS_API_KEY') and settings.VERTEX_EXPRESS_API_KEY:
39
+ VERTEX_EXPRESS_API_KEY_VAL = [key.strip() for key in settings.VERTEX_EXPRESS_API_KEY.split(',') if key.strip()]
40
+ if VERTEX_EXPRESS_API_KEY_VAL:
41
+ vertex_log('info', f"Loaded {len(VERTEX_EXPRESS_API_KEY_VAL)} Vertex Express API keys from settings")
42
+
43
+ # 假流式响应配置
44
+ FAKE_STREAMING_ENABLED = settings.FAKE_STREAMING if hasattr(settings, 'FAKE_STREAMING') else False
45
+ FAKE_STREAMING_INTERVAL_SECONDS = settings.FAKE_STREAMING_INTERVAL if hasattr(settings, 'FAKE_STREAMING_INTERVAL') else 1.0
46
+ FAKE_STREAMING_CHUNK_SIZE = settings.FAKE_STREAMING_CHUNK_SIZE if hasattr(settings, 'FAKE_STREAMING_CHUNK_SIZE') else 10
47
+ FAKE_STREAMING_DELAY_PER_CHUNK = settings.FAKE_STREAMING_DELAY_PER_CHUNK if hasattr(settings, 'FAKE_STREAMING_DELAY_PER_CHUNK') else 0.1
48
+ vertex_log('info', f"Fake streaming is {'enabled' if FAKE_STREAMING_ENABLED else 'disabled'} with interval {FAKE_STREAMING_INTERVAL_SECONDS} seconds, chunk size {FAKE_STREAMING_CHUNK_SIZE}, delay per chunk {FAKE_STREAMING_DELAY_PER_CHUNK} seconds")
49
+
50
+ def update_env_var(name, value):
51
+ """Update environment variable in memory."""
52
+ os.environ[name] = value
53
+ vertex_log('info', f"Updated environment variable: {name}")
54
+
55
+ def reload_config():
56
+ """重新加载配置,通常在持久化设置加载后调用"""
57
+ global GOOGLE_CREDENTIALS_JSON, VERTEX_EXPRESS_API_KEY_VAL, API_KEY
58
+
59
+ # 重新加载Google Credentials JSON
60
+ GOOGLE_CREDENTIALS_JSON = settings.GOOGLE_CREDENTIALS_JSON if hasattr(settings, 'GOOGLE_CREDENTIALS_JSON') else ""
61
+ if GOOGLE_CREDENTIALS_JSON:
62
+ vertex_log('info', "重新加载了GOOGLE_CREDENTIALS_JSON配置")
63
+
64
+ # 重新加载Vertex Express API Key
65
+ VERTEX_EXPRESS_API_KEY_VAL = []
66
+ if hasattr(settings, 'VERTEX_EXPRESS_API_KEY') and settings.VERTEX_EXPRESS_API_KEY:
67
+ VERTEX_EXPRESS_API_KEY_VAL = [key.strip() for key in settings.VERTEX_EXPRESS_API_KEY.split(',') if key.strip()]
68
+ if VERTEX_EXPRESS_API_KEY_VAL:
69
+ vertex_log('info', f"重新加载了{len(VERTEX_EXPRESS_API_KEY_VAL)}个Vertex Express API keys")
70
+
71
+ # 重新加载API Key
72
+ API_KEY = settings.PASSWORD if hasattr(settings, 'PASSWORD') else ""
73
+ if API_KEY:
74
+ vertex_log('info', "重新加载了API Key配置")
75
+
76
+ def update_config(name, value):
77
+ """Update config variables in settings and environment variables."""
78
+ if name == 'VERTEX_API_KEY':
79
+ settings.PASSWORD = value # 更新settings中的值
80
+ global API_KEY
81
+ API_KEY = value # 更新本地变量
82
+ vertex_log('info', "Updated API Key")
83
+ elif name == 'GOOGLE_CREDENTIALS_JSON':
84
+ settings.GOOGLE_CREDENTIALS_JSON = value
85
+ global GOOGLE_CREDENTIALS_JSON
86
+ GOOGLE_CREDENTIALS_JSON = value
87
+ vertex_log('info', "Updated Google Credentials JSON")
88
+ elif name == 'VERTEX_PROJECT_ID':
89
+ os.environ['VERTEX_PROJECT_ID'] = value # 这个值只在环境变量中
90
+ global PROJECT_ID
91
+ PROJECT_ID = value
92
+ vertex_log('info', f"Updated Project ID to {value}")
93
+ elif name == 'VERTEX_LOCATION':
94
+ os.environ['VERTEX_LOCATION'] = value
95
+ global LOCATION
96
+ LOCATION = value
97
+ vertex_log('info', f"Updated Location to {value}")
98
+ elif name == 'VERTEX_MODELS_CONFIG_URL':
99
+ os.environ['VERTEX_MODELS_CONFIG_URL'] = value
100
+ global MODELS_CONFIG_URL
101
+ MODELS_CONFIG_URL = value
102
+ vertex_log('info', f"Updated Models Config URL to {value}")
103
+ elif name == 'VERTEX_EXPRESS_API_KEY':
104
+ settings.VERTEX_EXPRESS_API_KEY = value
105
+ global VERTEX_EXPRESS_API_KEY_VAL
106
+ VERTEX_EXPRESS_API_KEY_VAL = [key.strip() for key in value.split(',') if key.strip()]
107
+ vertex_log('info', f"Updated Vertex Express API Key, now have {len(VERTEX_EXPRESS_API_KEY_VAL)} keys")
108
+ elif name == 'FAKE_STREAMING':
109
+ # 更新FAKE_STREAMING配置
110
+ settings.FAKE_STREAMING = value
111
+ global FAKE_STREAMING_ENABLED
112
+ FAKE_STREAMING_ENABLED = value
113
+ vertex_log('info', f"Updated FAKE_STREAMING to {value}")
114
+ # 确保环境变量也被更新
115
+ os.environ['FAKE_STREAMING'] = str(value).lower()
116
+ elif name == 'FAKE_STREAMING_INTERVAL':
117
+ # 更新FAKE_STREAMING_INTERVAL配置
118
+ settings.FAKE_STREAMING_INTERVAL = value
119
+ global FAKE_STREAMING_INTERVAL_SECONDS
120
+ FAKE_STREAMING_INTERVAL_SECONDS = value
121
+ vertex_log('info', f"Updated FAKE_STREAMING_INTERVAL to {value}")
122
+ elif name == 'FAKE_STREAMING_CHUNK_SIZE':
123
+ settings.FAKE_STREAMING_CHUNK_SIZE = value
124
+ global FAKE_STREAMING_CHUNK_SIZE
125
+ FAKE_STREAMING_CHUNK_SIZE = value
126
+ vertex_log('info', f"Updated FAKE_STREAMING_CHUNK_SIZE to {value}")
127
+ elif name == 'FAKE_STREAMING_DELAY_PER_CHUNK':
128
+ settings.FAKE_STREAMING_DELAY_PER_CHUNK = value
129
+ global FAKE_STREAMING_DELAY_PER_CHUNK
130
+ FAKE_STREAMING_DELAY_PER_CHUNK = value
131
+ vertex_log('info', f"Updated FAKE_STREAMING_DELAY_PER_CHUNK to {value}")
132
+ else:
133
+ vertex_log('warning', f"Unknown config variable: {name}")
134
+ return
135
+
136
+ # 更新环境变量
137
+ update_env_var(name, value)
138
+
139
+ # Validation logic moved to app/auth.py
app/vertex/credentials_manager.py ADDED
@@ -0,0 +1,271 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import glob
3
+ import random
4
+ import json
5
+ from typing import List, Dict, Any
6
+ from google.auth.transport.requests import Request as AuthRequest
7
+ from google.oauth2 import service_account
8
+ import app.vertex.config as app_config # Changed from relative
9
+ from app.utils.logging import vertex_log
10
+
11
+ # Helper function to parse multiple JSONs from a string
12
+ def parse_multiple_json_credentials(json_str: str) -> List[Dict[str, Any]]:
13
+ """
14
+ Parse multiple JSON objects from a string separated by commas.
15
+ Format expected: {json_object1},{json_object2},...
16
+ Returns a list of parsed JSON objects.
17
+ """
18
+ # 检查输入是否为None或空字符串
19
+ if not json_str or not json_str.strip():
20
+ vertex_log('debug', "parse_multiple_json_credentials received empty or None input")
21
+ return []
22
+
23
+ credentials_list = []
24
+ nesting_level = 0
25
+ current_object_start = -1
26
+ str_length = len(json_str)
27
+
28
+ for i, char in enumerate(json_str):
29
+ if char == '{':
30
+ if nesting_level == 0:
31
+ current_object_start = i
32
+ nesting_level += 1
33
+ elif char == '}':
34
+ if nesting_level > 0:
35
+ nesting_level -= 1
36
+ if nesting_level == 0 and current_object_start != -1:
37
+ # Found a complete top-level JSON object
38
+ json_object_str = json_str[current_object_start : i + 1]
39
+ try:
40
+ credentials_info = json.loads(json_object_str)
41
+ # Basic validation for service account structure
42
+ required_fields = ["type", "project_id", "private_key_id", "private_key", "client_email"]
43
+ if all(field in credentials_info for field in required_fields):
44
+ credentials_list.append(credentials_info)
45
+ vertex_log('debug', "Successfully parsed a JSON credential object.")
46
+ else:
47
+ vertex_log('warning', f"Parsed JSON object missing required fields: {json_object_str[:100]}...")
48
+ except json.JSONDecodeError as e:
49
+ vertex_log('error', f"Failed to parse JSON object segment: {json_object_str[:100]}... Error: {e}")
50
+ current_object_start = -1 # Reset for the next object
51
+ else:
52
+ # Found a closing brace without a matching open brace in scope, might indicate malformed input
53
+ vertex_log('warning', f"Encountered unexpected '}}' at index {i}. Input might be malformed.")
54
+
55
+
56
+ if nesting_level != 0:
57
+ vertex_log('warning', f"JSON string parsing ended with non-zero nesting level ({nesting_level}). Check for unbalanced braces.")
58
+
59
+ vertex_log('debug', f"Parsed {len(credentials_list)} credential objects from the input string.")
60
+ return credentials_list
61
+
62
+ def _refresh_auth(credentials):
63
+ """Helper function to refresh GCP token."""
64
+ if not credentials:
65
+ vertex_log('error', "_refresh_auth called with no credentials.")
66
+ return None
67
+ try:
68
+ # Assuming credentials object has a project_id attribute for logging
69
+ project_id_for_log = getattr(credentials, 'project_id', 'Unknown')
70
+ vertex_log('info', f"Attempting to refresh token for project: {project_id_for_log}...")
71
+ credentials.refresh(AuthRequest())
72
+ vertex_log('info', f"Token refreshed successfully for project: {project_id_for_log}")
73
+ return credentials.token
74
+ except Exception as e:
75
+ project_id_for_log = getattr(credentials, 'project_id', 'Unknown')
76
+ vertex_log('error', f"Error refreshing GCP token for project {project_id_for_log}: {e}")
77
+ return None
78
+
79
+
80
+ # Credential Manager for handling multiple service accounts
81
+ class CredentialManager:
82
+ def __init__(self): # default_credentials_dir is now handled by config
83
+ # Use CREDENTIALS_DIR from config
84
+ self.credentials_dir = app_config.CREDENTIALS_DIR
85
+ self.credentials_files = []
86
+ self.current_index = 0
87
+ self.credentials = None
88
+ self.project_id = None
89
+ # New: Store credentials loaded directly from JSON objects
90
+ self.in_memory_credentials: List[Dict[str, Any]] = []
91
+ self.load_credentials_list() # Load file-based credentials initially
92
+
93
+ def clear_json_string_credentials(self) -> int:
94
+ """
95
+ 清除所有通过JSON字符串加载的内存凭证。
96
+ 返回清除的凭证数量。
97
+ """
98
+ count_before = len(self.in_memory_credentials)
99
+ # 只保留不是从json字符串加载的凭证
100
+ self.in_memory_credentials = [cred for cred in self.in_memory_credentials if cred.get('source') != 'json_string']
101
+ count_after = len(self.in_memory_credentials)
102
+ removed_count = count_before - count_after
103
+ vertex_log('debug', f"从CredentialManager中清除了{removed_count}个由JSON字符串加载的凭证")
104
+ return removed_count
105
+
106
+ def add_credential_from_json(self, credentials_info: Dict[str, Any]) -> bool:
107
+ """
108
+ Add a credential from a JSON object to the manager's in-memory list.
109
+
110
+ Args:
111
+ credentials_info: Dict containing service account credentials
112
+
113
+ Returns:
114
+ bool: True if credential was added successfully, False otherwise
115
+ """
116
+ try:
117
+ # Validate structure again before creating credentials object
118
+ required_fields = ["type", "project_id", "private_key_id", "private_key", "client_email"]
119
+ if not all(field in credentials_info for field in required_fields):
120
+ vertex_log('warning', "Skipping JSON credential due to missing required fields.")
121
+ return False
122
+
123
+ credentials = service_account.Credentials.from_service_account_info(
124
+ credentials_info,
125
+ scopes=['https://www.googleapis.com/auth/cloud-platform']
126
+ )
127
+ project_id = credentials.project_id
128
+ vertex_log('debug', f"Successfully created credentials object from JSON for project: {project_id}")
129
+
130
+ # Store the credentials object and project ID
131
+ self.in_memory_credentials.append({
132
+ 'credentials': credentials,
133
+ 'project_id': project_id,
134
+ 'source': 'json_string' # Add source for clarity
135
+ })
136
+ vertex_log('info', f"Added credential for project {project_id} from JSON string to Credential Manager.")
137
+ return True
138
+ except Exception as e:
139
+ vertex_log('error', f"Failed to create credentials from parsed JSON object: {e}")
140
+ return False
141
+
142
+ def load_credentials_from_json_list(self, json_list: List[Dict[str, Any]]) -> int:
143
+ """
144
+ Load multiple credentials from a list of JSON objects into memory.
145
+
146
+ Args:
147
+ json_list: List of dicts containing service account credentials
148
+
149
+ Returns:
150
+ int: Number of credentials successfully loaded
151
+ """
152
+ # Avoid duplicates if called multiple times
153
+ existing_projects = {cred['project_id'] for cred in self.in_memory_credentials}
154
+ success_count = 0
155
+ newly_added_projects = set()
156
+
157
+ for credentials_info in json_list:
158
+ project_id = credentials_info.get('project_id')
159
+ # Check if this project_id from JSON exists in files OR already added from JSON
160
+ is_duplicate_file = any(os.path.basename(f) == f"{project_id}.json" for f in self.credentials_files) # Basic check
161
+ is_duplicate_mem = project_id in existing_projects or project_id in newly_added_projects
162
+
163
+ if project_id and not is_duplicate_file and not is_duplicate_mem:
164
+ if self.add_credential_from_json(credentials_info):
165
+ success_count += 1
166
+ newly_added_projects.add(project_id)
167
+ elif project_id:
168
+ vertex_log('debug', f"Skipping duplicate credential for project {project_id} from JSON list.")
169
+
170
+
171
+ if success_count > 0:
172
+ vertex_log('info', f"Loaded {success_count} new credentials from JSON list into memory.")
173
+ return success_count
174
+
175
+ def load_credentials_list(self):
176
+ """Load the list of available credential files"""
177
+ # Look for all .json files in the credentials directory
178
+ pattern = os.path.join(self.credentials_dir, "*.json")
179
+ self.credentials_files = glob.glob(pattern)
180
+
181
+ if not self.credentials_files:
182
+ # vertex_log('info', f"No credential files found in {self.credentials_dir}")
183
+ pass # Don't return False yet, might have in-memory creds
184
+ else:
185
+ vertex_log('info', f"Found {len(self.credentials_files)} credential files: {[os.path.basename(f) for f in self.credentials_files]}")
186
+
187
+ # Check total credentials
188
+ return self.get_total_credentials() > 0
189
+
190
+ def refresh_credentials_list(self):
191
+ """Refresh the list of credential files and return if any credentials exist"""
192
+ old_file_count = len(self.credentials_files)
193
+ self.load_credentials_list() # Reloads file list
194
+ new_file_count = len(self.credentials_files)
195
+
196
+ if old_file_count != new_file_count:
197
+ vertex_log('info', f"Credential files updated: {old_file_count} -> {new_file_count}")
198
+
199
+ # Total credentials = files + in-memory
200
+ total_credentials = self.get_total_credentials()
201
+ vertex_log('debug', f"Refresh check - Total credentials available: {total_credentials}")
202
+ return total_credentials > 0
203
+
204
+ def get_total_credentials(self):
205
+ """Returns the total number of credentials (file + in-memory)."""
206
+ return len(self.credentials_files) + len(self.in_memory_credentials)
207
+
208
+
209
+ def get_random_credentials(self):
210
+ """
211
+ Get a random credential (file or in-memory) and load it.
212
+ Tries each available credential source at most once in a random order.
213
+ """
214
+ all_sources = []
215
+ # Add file paths (as type 'file')
216
+ for file_path in self.credentials_files:
217
+ all_sources.append({'type': 'file', 'value': file_path})
218
+
219
+ # Add in-memory credentials (as type 'memory_object')
220
+ # Assuming self.in_memory_credentials stores dicts like {'credentials': cred_obj, 'project_id': pid, 'source': 'json_string'}
221
+ for idx, mem_cred_info in enumerate(self.in_memory_credentials):
222
+ all_sources.append({'type': 'memory_object', 'value': mem_cred_info, 'original_index': idx})
223
+
224
+ if not all_sources:
225
+ vertex_log('warning', "No credentials available for random selection (no files or in-memory).")
226
+ return None, None
227
+
228
+ random.shuffle(all_sources) # Shuffle to try in a random order
229
+
230
+ for source_info in all_sources:
231
+ source_type = source_info['type']
232
+
233
+ if source_type == 'file':
234
+ file_path = source_info['value']
235
+ vertex_log('debug', f"Attempting to load credential from file: {os.path.basename(file_path)}")
236
+ try:
237
+ credentials = service_account.Credentials.from_service_account_file(
238
+ file_path,
239
+ scopes=['https://www.googleapis.com/auth/cloud-platform']
240
+ )
241
+ project_id = credentials.project_id
242
+ vertex_log('info', f"Successfully loaded credential from file {os.path.basename(file_path)} for project: {project_id}")
243
+ self.credentials = credentials # Cache last successfully loaded
244
+ self.project_id = project_id
245
+ return credentials, project_id
246
+ except Exception as e:
247
+ vertex_log('error', f"Failed loading credentials file {os.path.basename(file_path)}: {e}. Trying next available source.")
248
+ continue # Try next source
249
+
250
+ elif source_type == 'memory_object':
251
+ mem_cred_detail = source_info['value']
252
+ # The 'credentials' object is already a service_account.Credentials instance
253
+ credentials = mem_cred_detail.get('credentials')
254
+ project_id = mem_cred_detail.get('project_id')
255
+
256
+ if credentials and project_id:
257
+ vertex_log('info', f"Using in-memory credential for project: {project_id} (Source: {mem_cred_detail.get('source', 'unknown')})")
258
+ # Here, we might want to ensure the credential object is still valid if it can expire
259
+ # For service_account.Credentials from_service_account_info, they typically don't self-refresh
260
+ # in the same way as ADC, but are long-lived based on the private key.
261
+ # If validation/refresh were needed, it would be complex here.
262
+ # For now, assume it's usable if present.
263
+ self.credentials = credentials # Cache last successfully loaded/used
264
+ self.project_id = project_id
265
+ return credentials, project_id
266
+ else:
267
+ vertex_log('warning', f"In-memory credential entry missing 'credentials' or 'project_id' at original index {source_info.get('original_index', 'N/A')}. Skipping.")
268
+ continue # Try next source
269
+
270
+ vertex_log('warning', "All available credential sources failed to load.")
271
+ return None, None