bluewinliang commited on
Commit
6480add
·
verified ·
1 Parent(s): 270b54d

Upload 8 files

Browse files
Files changed (8) hide show
  1. Dockerfile +15 -0
  2. README.md +370 -10
  3. config.py +63 -0
  4. cookie_manager.py +151 -0
  5. main.py +140 -0
  6. models.py +66 -0
  7. proxy_handler.py +291 -0
  8. requirements.txt +13 -0
Dockerfile ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ VOLUME ["/data"]
9
+
10
+ COPY . .
11
+
12
+ ENV PORT=7860
13
+ EXPOSE 7860
14
+
15
+ CMD ["python", "main.py"]
README.md CHANGED
@@ -1,10 +1,370 @@
1
- ---
2
- title: Zai2api
3
- emoji: 🦀
4
- colorFrom: yellow
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
+ # Z2API
2
+
3
+ 一个为Z.AI API提供OpenAI兼容接口的代理服务器,支持cookie池管理、智能内容过滤和灵活的响应模式控制。
4
+
5
+ > **💡 核心特性:** 支持流式和非流式两种响应模式,非流式模式下可选择性隐藏AI思考过程,提供更简洁的API响应。
6
+
7
+ ## ✨ 特性
8
+
9
+ - 🔌 **OpenAI SDK完全兼容** - 无缝替换OpenAI API
10
+ - 🍪 **智能Cookie池管理** - 多token轮换,自动故障转移
11
+ - 🧠 **智能内容过滤** - 非流式响应可选择隐藏AI思考过程
12
+ - 🌊 **灵活响应模式** - 支持流式和非流式响应,可配置默认模式
13
+ - 🛡️ **安全认证** - 固定API Key验证
14
+ - 📊 **健康检查** - 自动监控和恢复
15
+ - 📝 **详细日志** - 完善的调试和监控信息
16
+
17
+ ## 🚀 快速开始
18
+
19
+ ### 环境要求
20
+
21
+ - Python 3.8+
22
+ - pip
23
+
24
+ ### 安装步骤
25
+
26
+ 1. **克隆项目**
27
+ ```bash
28
+ git clone https://github.com/LargeCupPanda/Z2API.git
29
+ cd Z2API
30
+ ```
31
+
32
+ 2. **安装依赖**
33
+ ```bash
34
+ pip install -r requirements.txt
35
+ ```
36
+
37
+ 3. **配置环境变量**
38
+ ```bash
39
+ cp .env.example .env
40
+ # 编辑 .env 文件,配置你的参数
41
+ ```
42
+
43
+ 4. **启动服务器**
44
+ ```bash
45
+ python main.py
46
+ ```
47
+
48
+ 服务器将在 `http://localhost:8000` 启动
49
+
50
+ ## ⚙️ 配置说明
51
+
52
+ 在 `.env` 文件中配置以下参数:
53
+
54
+ ```env
55
+ # 服务器设置
56
+ HOST=0.0.0.0
57
+ PORT=8000
58
+
59
+ # API Key (用于外部认证)
60
+ API_KEY=sk-z2api-key-2024
61
+
62
+ # 内容过滤设置 (仅适用于非流式响应)
63
+ # 是否显示AI思考过程 (true/false)
64
+ SHOW_THINK_TAGS=false
65
+
66
+ # 响应模式设置
67
+ # 默认是否使用流式响应 (true/false)
68
+ DEFAULT_STREAM=false
69
+
70
+ # Z.AI Token配置
71
+ # 从 https://chat.z.ai 获取的JWT token (不包含"Bearer "前缀),多个用`,`分隔,比如:token1,token2
72
+ Z_AI_COOKIES=eyJ9...
73
+
74
+ # 速率限制
75
+ MAX_REQUESTS_PER_MINUTE=60
76
+
77
+ # 日志级别 (DEBUG, INFO, WARNING, ERROR)
78
+ LOG_LEVEL=INFO
79
+ ```
80
+
81
+ ### 🔑 获取Z.AI Token
82
+
83
+ 1. 访问 [https://chat.z.ai](https://chat.z.ai) 并登录
84
+ 2. 打开浏览器开发者工具 (F12)
85
+ 3. 切换到 **Network** 标签
86
+ 4. 发送一条消息给AI
87
+ 5. 找到对 `chat/completions` 的请求
88
+ 6. 复制请求头中 `Authorization: Bearer xxx` 的token部分
89
+ 7. 将token值(不包括"Bearer "前缀)配置到 `Z_AI_COOKIES`
90
+
91
+ ## 📖 使用方法
92
+
93
+ ### OpenAI SDK (推荐)
94
+
95
+ ```python
96
+ import openai
97
+
98
+ # 配置客户端
99
+ client = openai.OpenAI(
100
+ base_url="http://localhost:8000/v1",
101
+ api_key="sk-z2api-key-2024" # 使用配置的API Key
102
+ )
103
+
104
+ # 发送请求
105
+ response = client.chat.completions.create(
106
+ model="GLM-4.5", # 固定模型名称
107
+ messages=[
108
+ {"role": "user", "content": "你好,请介绍一下自己"}
109
+ ],
110
+ max_tokens=1000,
111
+ temperature=0.7
112
+ )
113
+
114
+ print(response.choices[0].message.content)
115
+ ```
116
+
117
+ ### cURL
118
+
119
+ ```bash
120
+ curl -X POST http://localhost:8000/v1/chat/completions \
121
+ -H "Content-Type: application/json" \
122
+ -H "Authorization: Bearer sk-z2api-key-2024" \
123
+ -d '{
124
+ "model": "GLM-4.5",
125
+ "messages": [
126
+ {"role": "user", "content": "Hello, how are you?"}
127
+ ],
128
+ "max_tokens": 500
129
+ }'
130
+ ```
131
+
132
+ ### 不同响应模式示例
133
+
134
+ #### 非流式响应(默认,支持思考内容过滤)
135
+
136
+ ```python
137
+ import openai
138
+
139
+ client = openai.OpenAI(
140
+ base_url="http://localhost:8000/v1",
141
+ api_key="sk-z2api-key-2024"
142
+ )
143
+
144
+ # 非流式响应,会根据SHOW_THINK_TAGS设置过滤内容
145
+ response = client.chat.completions.create(
146
+ model="GLM-4.5",
147
+ messages=[{"role": "user", "content": "解释一下量子计算"}],
148
+ stream=False # 或者不设置此参数(使用DEFAULT_STREAM默认值)
149
+ )
150
+
151
+ print(response.choices[0].message.content)
152
+ ```
153
+
154
+ #### 流式响应(包含完整内容)
155
+
156
+ ```python
157
+ # 流式响应,始终包含完整内容(忽略SHOW_THINK_TAGS设置)
158
+ stream = client.chat.completions.create(
159
+ model="GLM-4.5",
160
+ messages=[{"role": "user", "content": "写一首关于春天的诗"}],
161
+ stream=True
162
+ )
163
+
164
+ for chunk in stream:
165
+ if chunk.choices[0].delta.content is not None:
166
+ print(chunk.choices[0].delta.content, end="")
167
+ ```
168
+
169
+ ## 🎛️ 高级配置
170
+
171
+ ### 响应模式控制
172
+
173
+ 系统支持两种响应模式,通过以下参数控制:
174
+
175
+ ```env
176
+ # 默认响应模式 (推荐设置为false,即非流式)
177
+ DEFAULT_STREAM=false
178
+
179
+ # 思考内容过滤 (仅对非流式响应生效)
180
+ SHOW_THINK_TAGS=false
181
+ ```
182
+
183
+ **响应模式说明:**
184
+
185
+ | 模式 | 参数设置 | 思考内容过滤 | 适用场景 |
186
+ |------|----------|--------------|----------|
187
+ | **非流式** | `stream=false` 或默认 | ✅ 支持 `SHOW_THINK_TAGS` | 简洁回答,API集成 |
188
+ | **流式** | `stream=true` | ❌ 忽略 `SHOW_THINK_TAGS` | 实时交互,聊天界面 |
189
+
190
+ **效果对比:**
191
+ - **非流式 + `SHOW_THINK_TAGS=false`**: 只返回答案(~80字符),简洁明了
192
+ - **非���式 + `SHOW_THINK_TAGS=true`**: 完整内容(~1300字符),包含思考过程
193
+ - **流式响应**: 始终包含完整内容,实时输出
194
+
195
+ **推荐配置:**
196
+ ```env
197
+ # 推荐配置:默认非流式,隐藏思考过程
198
+ DEFAULT_STREAM=false
199
+ SHOW_THINK_TAGS=false
200
+ ```
201
+
202
+ 这样配置可以:
203
+ - 提供简洁的API响应(适合大多数应用场景)
204
+ - 需要完整内容时可通过 `stream=true` 获取
205
+ - 需要思考过程时可通过 `SHOW_THINK_TAGS=true` 开启
206
+
207
+ ### Cookie池管理
208
+
209
+ 支持配置多个token以提高并发性和可靠性:
210
+
211
+ ```env
212
+ # 单个token
213
+ Z_AI_COOKIES=token1
214
+
215
+ # 多个token (逗号分隔)
216
+ Z_AI_COOKIES=token1,token2,token3
217
+ ```
218
+
219
+ 系统会自动:
220
+ - 轮换使用不同的token
221
+ - 检测失效的token并自动切换
222
+ - 定期进行健康检查和恢复
223
+
224
+ ## 🔍 API端点
225
+
226
+ | 端点 | 方法 | 描述 |
227
+ |------|------|------|
228
+ | `/v1/chat/completions` | POST | 聊天完成接口 (OpenAI兼容) |
229
+ | `/health` | GET | 健康检查 |
230
+ | `/` | GET | 服务状态 |
231
+
232
+ ## 🧪 测试
233
+
234
+ ### 基本测试
235
+
236
+ ```bash
237
+ # 运行示例测试
238
+ python example_usage.py
239
+
240
+ # 测试健康检查
241
+ curl http://localhost:8000/health
242
+ ```
243
+
244
+ ### API测试
245
+
246
+ ```bash
247
+ # 测试非流式响应
248
+ curl -X POST http://localhost:8000/v1/chat/completions \
249
+ -H "Content-Type: application/json" \
250
+ -H "Authorization: Bearer sk-z2api-key-2024" \
251
+ -d '{
252
+ "model": "GLM-4.5",
253
+ "messages": [{"role": "user", "content": "Hello"}],
254
+ "stream": false
255
+ }'
256
+
257
+ # 测试流式响应
258
+ curl -X POST http://localhost:8000/v1/chat/completions \
259
+ -H "Content-Type: application/json" \
260
+ -H "Authorization: Bearer sk-z2api-key-2024" \
261
+ -d '{
262
+ "model": "GLM-4.5",
263
+ "messages": [{"role": "user", "content": "Hello"}],
264
+ "stream": true
265
+ }'
266
+ ```
267
+
268
+ ## 📊 监控和日志
269
+
270
+ ### 日志级别
271
+
272
+ ```env
273
+ LOG_LEVEL=DEBUG # 详细调试信息
274
+ LOG_LEVEL=INFO # 一般信息 (推荐)
275
+ LOG_LEVEL=WARNING # 警告信息
276
+ LOG_LEVEL=ERROR # 仅错误信息
277
+ ```
278
+
279
+ ### 健康检查
280
+
281
+ 访问 `http://localhost:8000/health` 查看服务状态:
282
+
283
+ ```json
284
+ {
285
+ "status": "healthy",
286
+ "timestamp": "2025-08-04T17:30:00Z",
287
+ "version": "1.0.0"
288
+ }
289
+ ```
290
+
291
+ ## 🔧 故障排除
292
+
293
+ ### 常见问题
294
+
295
+ 1. **401 Unauthorized**
296
+ - 检查API Key是否正确配置
297
+ - 确认使用的是 `sk-z2api-key-2024`
298
+
299
+ 2. **Token失效**
300
+ - 重新从Z.AI网站获取新的token
301
+ - 更新 `.env` 文件中的 `Z_AI_COOKIES`
302
+
303
+ 3. **连接超时**
304
+ - 检查网络连接
305
+ - 确认Z.AI服务可访问
306
+
307
+ 4. **内容为空或不符合预期**
308
+ - 检查 `SHOW_THINK_TAGS` 和 `DEFAULT_STREAM` 设置
309
+ - 确认响应模式(流式 vs 非流式)
310
+ - 查看服务器日志获取详细信息
311
+
312
+ 5. **思考内容过滤不生效**
313
+ - 确认使用的是非流式响应(`stream=false`)
314
+ - 流式响应会忽略 `SHOW_THINK_TAGS` 设置
315
+
316
+ 6. **服务启动失败**
317
+ - 检查端口是否被占用:`netstat -tlnp | grep :8000`
318
+ - 查看详细错误:直接运行 `python main.py`
319
+ - 检查依赖是否安装:`pip list | grep fastapi`
320
+
321
+ ### 调试模式
322
+
323
+ ```bash
324
+ # 启用详细日志
325
+ export LOG_LEVEL=DEBUG
326
+ python main.py
327
+
328
+ # 或者直接在.env文件中设置
329
+ echo "LOG_LEVEL=DEBUG" >> .env
330
+ ```
331
+
332
+ ## 📋 配置参数
333
+
334
+ | 参数 | 描述 | 默认值 | 必需 |
335
+ |------|------|--------|------|
336
+ | `HOST` | 服务器监听地址 | `0.0.0.0` | 否 |
337
+ | `PORT` | 服务器端口 | `8000` | 否 |
338
+ | `API_KEY` | 外部认证密钥 | `sk-z2api-key-2024` | 否 |
339
+ | `SHOW_THINK_TAGS` | 显示思考内容 | `false` | 否 |
340
+ | `DEFAULT_STREAM` | 默认流式模式 | `false` | 否 |
341
+ | `Z_AI_COOKIES` | Z.AI JWT tokens | - | 是 |
342
+ | `LOG_LEVEL` | 日志级别 | `INFO` | 否 |
343
+
344
+ ## 🛠️ 服务管理
345
+
346
+ ### 基本操作
347
+
348
+ ```bash
349
+ # 启动服务(前台运行)
350
+ python main.py
351
+
352
+ # 后台运行
353
+ nohup python main.py > z2api.log 2>&1 &
354
+
355
+ # 查看日志
356
+ tail -f z2api.log
357
+
358
+ # 停止服务
359
+ # 找到进程ID并终止
360
+ ps aux | grep "python main.py"
361
+ kill <PID>
362
+ ```
363
+
364
+ ## 🤝 贡献
365
+
366
+ **特别说明:** 作者为非编程人士,此项目全程由AI开发,AI代码100%,人类代码0%。由于这种开发模式,更新维护起来非常费劲,所以特别欢迎大家提交Issue和Pull Request来帮助改进项目!
367
+
368
+ ## 📄 许可证
369
+
370
+ MIT License
config.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Configuration settings for Z.AI Proxy
3
+ """
4
+ import os
5
+ from typing import List
6
+ from dotenv import load_dotenv
7
+
8
+ load_dotenv()
9
+
10
+ class Settings:
11
+ # Server settings
12
+ HOST: str = os.getenv("HOST", "0.0.0.0")
13
+ PORT: int = int(os.getenv("PORT", "7860"))
14
+
15
+ # Z.AI settings
16
+ UPSTREAM_URL: str = "https://chat.z.ai/api/chat/completions"
17
+ UPSTREAM_MODEL: str = "0727-360B-API"
18
+
19
+ # Model settings (OpenAI SDK compatible)
20
+ MODEL_NAME: str = "GLM-4.5"
21
+ MODEL_ID: str = "GLM-4.5"
22
+
23
+ # API Key for external authentication
24
+ API_KEY: str = os.getenv("API_KEY", "sk-z2api-key-2024")
25
+
26
+ # Content filtering settings (only applies to non-streaming responses)
27
+ SHOW_THINK_TAGS: bool = os.getenv("SHOW_THINK_TAGS", "false").lower() in ("true", "1", "yes")
28
+
29
+ # Response mode settings
30
+ DEFAULT_STREAM: bool = os.getenv("DEFAULT_STREAM", "false").lower() in ("true", "1", "yes")
31
+
32
+ # Cookie settings
33
+ COOKIES: List[str] = []
34
+
35
+ # Auto refresh settings
36
+ AUTO_REFRESH_TOKENS: bool = os.getenv("AUTO_REFRESH_TOKENS", "false").lower() in ("true", "1", "yes")
37
+ REFRESH_CHECK_INTERVAL: int = int(os.getenv("REFRESH_CHECK_INTERVAL", "3600")) # 1 hour
38
+
39
+ def __init__(self):
40
+ # Load cookies from environment variable
41
+ cookies_str = os.getenv("Z_AI_COOKIES", "")
42
+ if cookies_str and cookies_str != "your_z_ai_cookie_here":
43
+ self.COOKIES = [cookie.strip() for cookie in cookies_str.split(",") if cookie.strip()]
44
+
45
+ # Don't raise error immediately, let the application handle it
46
+ if not self.COOKIES:
47
+ print("⚠️ Warning: No valid Z.AI cookies configured!")
48
+ print("Please set Z_AI_COOKIES environment variable with comma-separated cookie values.")
49
+ print("Example: Z_AI_COOKIES=cookie1,cookie2,cookie3")
50
+ print("The server will start but API calls will fail until cookies are configured.")
51
+
52
+ # Rate limiting
53
+ MAX_REQUESTS_PER_MINUTE: int = int(os.getenv("MAX_REQUESTS_PER_MINUTE", "60"))
54
+
55
+ # Logging
56
+ LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO")
57
+
58
+ # Create settings instance
59
+ try:
60
+ settings = Settings()
61
+ except Exception as e:
62
+ print(f"❌ Configuration error: {e}")
63
+ settings = None
cookie_manager.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Cookie pool manager for Z.AI tokens with round-robin rotation
3
+ """
4
+ import asyncio
5
+ import logging
6
+ from typing import List, Optional
7
+ from asyncio import Lock
8
+ import httpx
9
+ from config import settings
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ class CookieManager:
14
+ def __init__(self, cookies: List[str]):
15
+ self.cookies = cookies or []
16
+ self.current_index = 0
17
+ self.lock = Lock()
18
+ self.failed_cookies = set()
19
+
20
+ if self.cookies:
21
+ logger.info(f"Initialized CookieManager with {len(cookies)} cookies")
22
+ else:
23
+ logger.warning("CookieManager initialized with no cookies")
24
+
25
+ async def get_next_cookie(self) -> Optional[str]:
26
+ """Get the next available cookie using round-robin"""
27
+ if not self.cookies:
28
+ return None
29
+
30
+ async with self.lock:
31
+ attempts = 0
32
+ while attempts < len(self.cookies):
33
+ cookie = self.cookies[self.current_index]
34
+ self.current_index = (self.current_index + 1) % len(self.cookies)
35
+
36
+ # Skip failed cookies
37
+ if cookie not in self.failed_cookies:
38
+ return cookie
39
+
40
+ attempts += 1
41
+
42
+ # All cookies failed, reset failed set and try again
43
+ if self.failed_cookies:
44
+ logger.warning(f"All {len(self.cookies)} cookies failed, resetting failed set and retrying")
45
+ self.failed_cookies.clear()
46
+ return self.cookies[0]
47
+
48
+ return None
49
+
50
+ async def mark_cookie_failed(self, cookie: str):
51
+ """Mark a cookie as failed"""
52
+ async with self.lock:
53
+ self.failed_cookies.add(cookie)
54
+ logger.warning(f"Marked cookie as failed: {cookie[:20]}...")
55
+
56
+ async def mark_cookie_success(self, cookie: str):
57
+ """Mark a cookie as working (remove from failed set)"""
58
+ async with self.lock:
59
+ if cookie in self.failed_cookies:
60
+ self.failed_cookies.discard(cookie)
61
+ logger.info(f"Cookie recovered: {cookie[:20]}...")
62
+
63
+ async def health_check(self, cookie: str) -> bool:
64
+ """Check if a cookie is still valid"""
65
+ try:
66
+ async with httpx.AsyncClient() as client:
67
+ # Use the same payload format as actual requests
68
+ import uuid
69
+ test_payload = {
70
+ "stream": True,
71
+ "model": "0727-360B-API",
72
+ "messages": [{"role": "user", "content": "hi"}],
73
+ "background_tasks": {
74
+ "title_generation": False,
75
+ "tags_generation": False
76
+ },
77
+ "chat_id": str(uuid.uuid4()),
78
+ "features": {
79
+ "image_generation": False,
80
+ "code_interpreter": False,
81
+ "web_search": False,
82
+ "auto_web_search": False
83
+ },
84
+ "id": str(uuid.uuid4()),
85
+ "mcp_servers": [],
86
+ "model_item": {
87
+ "id": "0727-360B-API",
88
+ "name": "GLM-4.5",
89
+ "owned_by": "openai"
90
+ },
91
+ "params": {},
92
+ "tool_servers": [],
93
+ "variables": {
94
+ "{{USER_NAME}}": "User",
95
+ "{{USER_LOCATION}}": "Unknown",
96
+ "{{CURRENT_DATETIME}}": "2025-08-04 16:46:56"
97
+ }
98
+ }
99
+ response = await client.post(
100
+ "https://chat.z.ai/api/chat/completions",
101
+ headers={
102
+ "Authorization": f"Bearer {cookie}",
103
+ "Content-Type": "application/json",
104
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36",
105
+ "Accept": "application/json, text/event-stream",
106
+ "Accept-Language": "zh-CN",
107
+ "sec-ch-ua": '"Not)A;Brand";v="8", "Chromium";v="138", "Google Chrome";v="138"',
108
+ "sec-ch-ua-mobile": "?0",
109
+ "sec-ch-ua-platform": '"macOS"',
110
+ "x-fe-version": "prod-fe-1.0.53",
111
+ "Origin": "https://chat.z.ai",
112
+ "Referer": "https://chat.z.ai/c/069723d5-060b-404f-992c-4705f1554c4c"
113
+ },
114
+ json=test_payload,
115
+ timeout=10.0
116
+ )
117
+ # Consider 200 as success
118
+ is_healthy = response.status_code == 200
119
+ if not is_healthy:
120
+ logger.debug(f"Health check failed for cookie {cookie[:20]}...: HTTP {response.status_code}")
121
+ else:
122
+ logger.debug(f"Health check passed for cookie {cookie[:20]}...")
123
+
124
+ return is_healthy
125
+ except Exception as e:
126
+ logger.debug(f"Health check failed for cookie {cookie[:20]}...: {e}")
127
+ return False
128
+
129
+ async def periodic_health_check(self):
130
+ """Periodically check all cookies health"""
131
+ while True:
132
+ try:
133
+ # Only check if we have cookies and some are marked as failed
134
+ if self.cookies and self.failed_cookies:
135
+ logger.info(f"Running health check for {len(self.failed_cookies)} failed cookies")
136
+
137
+ for cookie in list(self.failed_cookies): # Create a copy to avoid modification during iteration
138
+ if await self.health_check(cookie):
139
+ await self.mark_cookie_success(cookie)
140
+ logger.info(f"Cookie recovered: {cookie[:20]}...")
141
+ else:
142
+ logger.debug(f"Cookie still failed: {cookie[:20]}...")
143
+
144
+ # Wait 10 minutes before next check (reduced frequency)
145
+ await asyncio.sleep(600)
146
+ except Exception as e:
147
+ logger.error(f"Error in periodic health check: {e}")
148
+ await asyncio.sleep(300) # Wait 5 minutes on error
149
+
150
+ # Global cookie manager instance
151
+ cookie_manager = CookieManager(settings.COOKIES if settings else [])
main.py ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Z.AI Proxy - OpenAI-compatible API for Z.AI
3
+ """
4
+ import asyncio
5
+ import logging
6
+ from contextlib import asynccontextmanager
7
+ from fastapi import FastAPI, HTTPException, Depends, Request
8
+ from fastapi.middleware.cors import CORSMiddleware
9
+ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
10
+
11
+ from config import settings
12
+ from models import ChatCompletionRequest, ModelsResponse, ModelInfo, ErrorResponse
13
+ from proxy_handler import ProxyHandler
14
+ from cookie_manager import cookie_manager
15
+
16
+ # Configure logging
17
+ logging.basicConfig(
18
+ level=getattr(logging, settings.LOG_LEVEL),
19
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
20
+ )
21
+ logger = logging.getLogger(__name__)
22
+
23
+ # Security
24
+ security = HTTPBearer(auto_error=False)
25
+
26
+ @asynccontextmanager
27
+ async def lifespan(app: FastAPI):
28
+ """Application lifespan manager"""
29
+ # Start background tasks
30
+ health_check_task = asyncio.create_task(cookie_manager.periodic_health_check())
31
+
32
+ try:
33
+ yield
34
+ finally:
35
+ # Cleanup
36
+ health_check_task.cancel()
37
+ try:
38
+ await health_check_task
39
+ except asyncio.CancelledError:
40
+ pass
41
+
42
+ # Create FastAPI app
43
+ app = FastAPI(
44
+ title="Z.AI Proxy",
45
+ description="OpenAI-compatible API proxy for Z.AI",
46
+ version="1.0.0",
47
+ lifespan=lifespan
48
+ )
49
+
50
+ # Add CORS middleware
51
+ app.add_middleware(
52
+ CORSMiddleware,
53
+ allow_origins=["*"],
54
+ allow_credentials=True,
55
+ allow_methods=["*"],
56
+ allow_headers=["*"],
57
+ )
58
+
59
+ async def verify_auth(credentials: HTTPAuthorizationCredentials = Depends(security)):
60
+ """Verify authentication with fixed API key"""
61
+ if not credentials:
62
+ raise HTTPException(status_code=401, detail="Authorization header required")
63
+
64
+ # Verify the API key matches our configured key
65
+ if credentials.credentials != settings.API_KEY:
66
+ raise HTTPException(status_code=401, detail="Invalid API key")
67
+
68
+ return credentials.credentials
69
+
70
+ @app.get("/v1/models", response_model=ModelsResponse)
71
+ async def list_models():
72
+ """List available models"""
73
+ models = [
74
+ ModelInfo(
75
+ id=settings.MODEL_ID,
76
+ object="model",
77
+ owned_by="z-ai"
78
+ )
79
+ ]
80
+ return ModelsResponse(data=models)
81
+
82
+ @app.post("/v1/chat/completions")
83
+ async def chat_completions(
84
+ request: ChatCompletionRequest,
85
+ auth_token: str = Depends(verify_auth)
86
+ ):
87
+ """Create chat completion"""
88
+ try:
89
+ # Check if cookies are configured
90
+ if not settings or not settings.COOKIES:
91
+ raise HTTPException(
92
+ status_code=503,
93
+ detail="Service unavailable: No Z.AI cookies configured. Please set Z_AI_COOKIES environment variable."
94
+ )
95
+
96
+ # Validate model
97
+ if request.model != settings.MODEL_NAME:
98
+ raise HTTPException(
99
+ status_code=400,
100
+ detail=f"Model '{request.model}' not supported. Use '{settings.MODEL_NAME}'"
101
+ )
102
+
103
+ async with ProxyHandler() as handler:
104
+ return await handler.handle_chat_completion(request)
105
+
106
+ except HTTPException:
107
+ raise
108
+ except Exception as e:
109
+ logger.error(f"Unexpected error: {e}")
110
+ raise HTTPException(status_code=500, detail="Internal server error")
111
+
112
+ @app.get("/health")
113
+ async def health_check():
114
+ """Health check endpoint"""
115
+ return {"status": "healthy", "model": settings.MODEL_NAME}
116
+
117
+ @app.exception_handler(HTTPException)
118
+ async def http_exception_handler(request: Request, exc: HTTPException):
119
+ """Custom HTTP exception handler"""
120
+ from fastapi.responses import JSONResponse
121
+ return JSONResponse(
122
+ status_code=exc.status_code,
123
+ content={
124
+ "error": {
125
+ "message": exc.detail,
126
+ "type": "invalid_request_error",
127
+ "code": exc.status_code
128
+ }
129
+ }
130
+ )
131
+
132
+ if __name__ == "__main__":
133
+ import uvicorn
134
+ uvicorn.run(
135
+ "main:app",
136
+ host=settings.HOST,
137
+ port=settings.PORT,
138
+ reload=False,
139
+ log_level=settings.LOG_LEVEL.lower()
140
+ )
models.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Pydantic models for OpenAI API compatibility
3
+ """
4
+ from typing import List, Optional, Dict, Any, Union, Literal
5
+ from pydantic import BaseModel, Field
6
+
7
+ class ChatMessage(BaseModel):
8
+ role: Literal["system", "user", "assistant"]
9
+ content: str
10
+
11
+ class ChatCompletionRequest(BaseModel):
12
+ model: str
13
+ messages: List[ChatMessage]
14
+ temperature: Optional[float] = 1.0
15
+ top_p: Optional[float] = 1.0
16
+ n: Optional[int] = 1
17
+ stream: Optional[bool] = False
18
+ stop: Optional[Union[str, List[str]]] = None
19
+ max_tokens: Optional[int] = None
20
+ presence_penalty: Optional[float] = 0.0
21
+ frequency_penalty: Optional[float] = 0.0
22
+ logit_bias: Optional[Dict[str, float]] = None
23
+ user: Optional[str] = None
24
+
25
+ class ChatCompletionChoice(BaseModel):
26
+ index: int
27
+ message: ChatMessage
28
+ finish_reason: Optional[str] = None
29
+
30
+ class ChatCompletionUsage(BaseModel):
31
+ prompt_tokens: int
32
+ completion_tokens: int
33
+ total_tokens: int
34
+
35
+ class ChatCompletionResponse(BaseModel):
36
+ id: str
37
+ object: str = "chat.completion"
38
+ created: int
39
+ model: str
40
+ choices: List[ChatCompletionChoice]
41
+ usage: Optional[ChatCompletionUsage] = None
42
+
43
+ class ChatCompletionStreamChoice(BaseModel):
44
+ index: int
45
+ delta: Dict[str, Any]
46
+ finish_reason: Optional[str] = None
47
+
48
+ class ChatCompletionStreamResponse(BaseModel):
49
+ id: str
50
+ object: str = "chat.completion.chunk"
51
+ created: int
52
+ model: str
53
+ choices: List[ChatCompletionStreamChoice]
54
+
55
+ class ModelInfo(BaseModel):
56
+ id: str
57
+ object: str = "model"
58
+ owned_by: str
59
+ permission: List[Any] = []
60
+
61
+ class ModelsResponse(BaseModel):
62
+ object: str = "list"
63
+ data: List[ModelInfo]
64
+
65
+ class ErrorResponse(BaseModel):
66
+ error: Dict[str, Any]
proxy_handler.py ADDED
@@ -0,0 +1,291 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Proxy handler for Z.AI API requests
3
+ """
4
+ import json
5
+ import logging
6
+ import re
7
+ import time
8
+ from typing import AsyncGenerator, Dict, Any, Optional
9
+ import httpx
10
+ from fastapi import HTTPException
11
+ from fastapi.responses import StreamingResponse
12
+
13
+ from config import settings
14
+ from cookie_manager import cookie_manager
15
+ from models import ChatCompletionRequest, ChatCompletionResponse, ChatCompletionStreamResponse
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ class ProxyHandler:
20
+ def __init__(self):
21
+ self.client = httpx.AsyncClient(timeout=60.0)
22
+
23
+ async def __aenter__(self):
24
+ return self
25
+
26
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
27
+ await self.client.aclose()
28
+
29
+ def transform_content(self, content: str) -> str:
30
+ """Transform content by replacing HTML tags and optionally removing think tags"""
31
+ if not content:
32
+ return content
33
+
34
+ logger.debug(f"SHOW_THINK_TAGS setting: {settings.SHOW_THINK_TAGS}")
35
+
36
+ # Optionally remove thinking content based on configuration
37
+ if not settings.SHOW_THINK_TAGS:
38
+ logger.debug("Removing thinking content from response")
39
+ original_length = len(content)
40
+
41
+ # Remove <details> blocks (thinking content) - handle both closed and unclosed tags
42
+ # First try to remove complete <details>...</details> blocks
43
+ content = re.sub(r'<details[^>]*>.*?</details>', '', content, flags=re.DOTALL)
44
+
45
+ # Then remove any remaining <details> opening tags and everything after them until we hit answer content
46
+ # Look for pattern: <details...><summary>...</summary>...content... and remove the thinking part
47
+ content = re.sub(r'<details[^>]*>.*?(?=\s*[A-Z]|\s*\d|\s*$)', '', content, flags=re.DOTALL)
48
+
49
+ content = content.strip()
50
+
51
+ logger.debug(f"Content length after removing thinking content: {original_length} -> {len(content)}")
52
+ else:
53
+ logger.debug("Keeping thinking content, converting to <think> tags")
54
+
55
+ # Replace <details> with <think>
56
+ content = re.sub(r'<details[^>]*>', '<think>', content)
57
+ content = content.replace('</details>', '</think>')
58
+
59
+ # Remove <summary> tags and their content
60
+ content = re.sub(r'<summary>.*?</summary>', '', content, flags=re.DOTALL)
61
+
62
+ # If there's no closing </think>, add it at the end of thinking content
63
+ if '<think>' in content and '</think>' not in content:
64
+ # Find where thinking ends and answer begins
65
+ think_start = content.find('<think>')
66
+ if think_start != -1:
67
+ # Look for the start of the actual answer (usually starts with a capital letter or number)
68
+ answer_match = re.search(r'\n\s*[A-Z0-9]', content[think_start:])
69
+ if answer_match:
70
+ insert_pos = think_start + answer_match.start()
71
+ content = content[:insert_pos] + '</think>\n' + content[insert_pos:]
72
+ else:
73
+ content += '</think>'
74
+
75
+ return content.strip()
76
+
77
+ async def proxy_request(self, request: ChatCompletionRequest) -> Dict[str, Any]:
78
+ """Proxy request to Z.AI API"""
79
+ cookie = await cookie_manager.get_next_cookie()
80
+ if not cookie:
81
+ raise HTTPException(status_code=503, detail="No available cookies")
82
+
83
+ # Transform model name
84
+ target_model = settings.UPSTREAM_MODEL if request.model == settings.MODEL_NAME else request.model
85
+
86
+ # Determine if this should be a streaming response
87
+ is_streaming = request.stream if request.stream is not None else settings.DEFAULT_STREAM
88
+
89
+ # Validate parameter compatibility
90
+ if is_streaming and not settings.SHOW_THINK_TAGS:
91
+ logger.warning("SHOW_THINK_TAGS=false is ignored for streaming responses")
92
+
93
+ # Prepare request data
94
+ request_data = request.model_dump(exclude_none=True)
95
+ request_data["model"] = target_model
96
+
97
+ # Build request data based on actual Z.AI format from zai-messages.md
98
+ import uuid
99
+
100
+ request_data = {
101
+ "stream": True, # Always request streaming from Z.AI for processing
102
+ "model": target_model,
103
+ "messages": request_data["messages"],
104
+ "background_tasks": {
105
+ "title_generation": True,
106
+ "tags_generation": True
107
+ },
108
+ "chat_id": str(uuid.uuid4()),
109
+ "features": {
110
+ "image_generation": False,
111
+ "code_interpreter": False,
112
+ "web_search": False,
113
+ "auto_web_search": False
114
+ },
115
+ "id": str(uuid.uuid4()),
116
+ "mcp_servers": ["deep-web-search"],
117
+ "model_item": {
118
+ "id": target_model,
119
+ "name": "GLM-4.5",
120
+ "owned_by": "openai"
121
+ },
122
+ "params": {},
123
+ "tool_servers": [],
124
+ "variables": {
125
+ "{{USER_NAME}}": "User",
126
+ "{{USER_LOCATION}}": "Unknown",
127
+ "{{CURRENT_DATETIME}}": "2025-08-04 16:46:56"
128
+ }
129
+ }
130
+
131
+
132
+
133
+ logger.debug(f"Sending request data: {request_data}")
134
+
135
+ headers = {
136
+ "Content-Type": "application/json",
137
+ "Authorization": f"Bearer {cookie}",
138
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36",
139
+ "Accept": "application/json, text/event-stream",
140
+ "Accept-Language": "zh-CN",
141
+ "sec-ch-ua": '"Not)A;Brand";v="8", "Chromium";v="138", "Google Chrome";v="138"',
142
+ "sec-ch-ua-mobile": "?0",
143
+ "sec-ch-ua-platform": '"macOS"',
144
+ "x-fe-version": "prod-fe-1.0.53",
145
+ "Origin": "https://chat.z.ai",
146
+ "Referer": "https://chat.z.ai/c/069723d5-060b-404f-992c-4705f1554c4c"
147
+ }
148
+
149
+ try:
150
+ response = await self.client.post(
151
+ settings.UPSTREAM_URL,
152
+ json=request_data,
153
+ headers=headers
154
+ )
155
+
156
+ if response.status_code == 401:
157
+ await cookie_manager.mark_cookie_failed(cookie)
158
+ raise HTTPException(status_code=401, detail="Invalid authentication")
159
+
160
+ if response.status_code != 200:
161
+ raise HTTPException(status_code=response.status_code, detail=f"Upstream error: {response.text}")
162
+
163
+ await cookie_manager.mark_cookie_success(cookie)
164
+ return {"response": response, "cookie": cookie}
165
+
166
+ except httpx.RequestError as e:
167
+ logger.error(f"Request error: {e}")
168
+ await cookie_manager.mark_cookie_failed(cookie)
169
+ raise HTTPException(status_code=503, detail="Upstream service unavailable")
170
+
171
+ async def process_streaming_response(self, response: httpx.Response) -> AsyncGenerator[Dict[str, Any], None]:
172
+ """Process streaming response from Z.AI"""
173
+ buffer = ""
174
+
175
+ async for chunk in response.aiter_text():
176
+ buffer += chunk
177
+ lines = buffer.split('\n')
178
+ buffer = lines[-1] # Keep incomplete line in buffer
179
+
180
+ for line in lines[:-1]:
181
+ line = line.strip()
182
+ if not line.startswith("data: "):
183
+ continue
184
+
185
+ payload = line[6:].strip()
186
+ if payload == "[DONE]":
187
+ return
188
+
189
+ try:
190
+ parsed = json.loads(payload)
191
+
192
+ # Check for errors first
193
+ if parsed.get("error") or (parsed.get("data", {}).get("error")):
194
+ error_detail = (parsed.get("error", {}).get("detail") or
195
+ parsed.get("data", {}).get("error", {}).get("detail") or
196
+ "Unknown error from upstream")
197
+ logger.error(f"Upstream error: {error_detail}")
198
+ raise HTTPException(status_code=400, detail=f"Upstream error: {error_detail}")
199
+
200
+ # Transform the response
201
+ if parsed.get("data"):
202
+ # Remove unwanted fields
203
+ parsed["data"].pop("edit_index", None)
204
+ parsed["data"].pop("edit_content", None)
205
+
206
+ # Note: We don't transform delta_content here because <think> tags
207
+ # might span multiple chunks. We'll transform the final aggregated content.
208
+
209
+ yield parsed
210
+
211
+ except json.JSONDecodeError:
212
+ continue # Skip non-JSON lines
213
+
214
+ async def handle_chat_completion(self, request: ChatCompletionRequest):
215
+ """Handle chat completion request"""
216
+ proxy_result = await self.proxy_request(request)
217
+ response = proxy_result["response"]
218
+
219
+ # Determine final streaming mode
220
+ is_streaming = request.stream if request.stream is not None else settings.DEFAULT_STREAM
221
+
222
+ if is_streaming:
223
+ # For streaming responses, SHOW_THINK_TAGS setting is ignored
224
+ return StreamingResponse(
225
+ self.stream_response(response, request.model),
226
+ media_type="text/event-stream",
227
+ headers={
228
+ "Cache-Control": "no-cache",
229
+ "Connection": "keep-alive",
230
+ }
231
+ )
232
+ else:
233
+ # For non-streaming responses, SHOW_THINK_TAGS setting applies
234
+ return await self.non_stream_response(response, request.model)
235
+
236
+ async def stream_response(self, response: httpx.Response, model: str) -> AsyncGenerator[str, None]:
237
+ """Generate streaming response"""
238
+ async for parsed in self.process_streaming_response(response):
239
+ yield f"data: {json.dumps(parsed)}\n\n"
240
+ yield "data: [DONE]\n\n"
241
+
242
+ async def non_stream_response(self, response: httpx.Response, model: str) -> ChatCompletionResponse:
243
+ """Generate non-streaming response"""
244
+ chunks = []
245
+ async for parsed in self.process_streaming_response(response):
246
+ chunks.append(parsed)
247
+ logger.debug(f"Received chunk: {parsed}") # Debug log
248
+
249
+ if not chunks:
250
+ raise HTTPException(status_code=500, detail="No response from upstream")
251
+
252
+ logger.info(f"Total chunks received: {len(chunks)}")
253
+ logger.debug(f"First chunk structure: {chunks[0] if chunks else 'None'}")
254
+
255
+ # Aggregate content based on SHOW_THINK_TAGS setting
256
+ if settings.SHOW_THINK_TAGS:
257
+ # Include all content
258
+ full_content = "".join(
259
+ chunk.get("data", {}).get("delta_content", "") for chunk in chunks
260
+ )
261
+ else:
262
+ # Only include answer phase content
263
+ full_content = "".join(
264
+ chunk.get("data", {}).get("delta_content", "")
265
+ for chunk in chunks
266
+ if chunk.get("data", {}).get("phase") == "answer"
267
+ )
268
+
269
+ logger.info(f"Aggregated content length: {len(full_content)}")
270
+ logger.debug(f"Full aggregated content: {full_content}") # Show full content for debugging
271
+
272
+ # Apply content transformation (including think tag filtering)
273
+ transformed_content = self.transform_content(full_content)
274
+
275
+ logger.info(f"Transformed content length: {len(transformed_content)}")
276
+ logger.debug(f"Transformed content: {transformed_content[:200]}...")
277
+
278
+ # Create OpenAI-compatible response
279
+ return ChatCompletionResponse(
280
+ id=chunks[0].get("data", {}).get("id", "chatcmpl-unknown"),
281
+ created=int(time.time()),
282
+ model=model,
283
+ choices=[{
284
+ "index": 0,
285
+ "message": {
286
+ "role": "assistant",
287
+ "content": transformed_content
288
+ },
289
+ "finish_reason": "stop"
290
+ }]
291
+ )
requirements.txt ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Core server dependencies
2
+ fastapi==0.104.1
3
+ uvicorn[standard]==0.24.0
4
+
5
+ # HTTP client for upstream requests
6
+ httpx==0.25.2
7
+
8
+ # Data validation and settings
9
+ pydantic==2.5.0
10
+ python-dotenv==1.0.0
11
+
12
+ # OpenAI SDK for testing and examples
13
+ openai>=1.0.0