openai-router 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openai_router-0.1.0/LICENSE.txt +21 -0
- openai_router-0.1.0/PKG-INFO +127 -0
- openai_router-0.1.0/README.md +112 -0
- openai_router-0.1.0/pyproject.toml +29 -0
- openai_router-0.1.0/setup.cfg +4 -0
- openai_router-0.1.0/src/openai_router/__init__.py +0 -0
- openai_router-0.1.0/src/openai_router/main.py +474 -0
- openai_router-0.1.0/src/openai_router.egg-info/PKG-INFO +127 -0
- openai_router-0.1.0/src/openai_router.egg-info/SOURCES.txt +11 -0
- openai_router-0.1.0/src/openai_router.egg-info/dependency_links.txt +1 -0
- openai_router-0.1.0/src/openai_router.egg-info/entry_points.txt +2 -0
- openai_router-0.1.0/src/openai_router.egg-info/requires.txt +6 -0
- openai_router-0.1.0/src/openai_router.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Astral Software Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: openai-router
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: openai-router
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
License-File: LICENSE.txt
|
|
8
|
+
Requires-Dist: fastapi>=0.120.3
|
|
9
|
+
Requires-Dist: gradio>=5.49.1
|
|
10
|
+
Requires-Dist: httpx>=0.28.1
|
|
11
|
+
Requires-Dist: loguru>=0.7.3
|
|
12
|
+
Requires-Dist: sqlmodel>=0.0.27
|
|
13
|
+
Requires-Dist: uvicorn>=0.38.0
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
<!-- markdownlint-disable MD033 -->
|
|
18
|
+
<h1 align="center">
|
|
19
|
+
🚀 OpenAI Router
|
|
20
|
+
</h1>
|
|
21
|
+
|
|
22
|
+
<p align="center">
|
|
23
|
+
<b>轻量级、持久化、零配置的 OpenAI API 统一网关</b><br>
|
|
24
|
+
一键聚合 vLLM、SGLang、lmdeploy、Ollama…
|
|
25
|
+
</p>
|
|
26
|
+
|
|
27
|
+
<p align="center">
|
|
28
|
+
<a href="#"><img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square"></a>
|
|
29
|
+
<a href="https://fastapi.tiangolo.com"><img src="https://img.shields.io/badge/FastAPI-v0.115+-teal?style=flat-square"></a>
|
|
30
|
+
<a href="https://gradio.app"><img src="https://img.shields.io/badge/Gradio-v5+-orange?style=flat-square"></a>
|
|
31
|
+
<a href="#"><img src="https://img.shields.io/badge/SQLite-内置存储-lightgrey?style=flat-square"></a>
|
|
32
|
+
</p>
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## ✨ Features
|
|
37
|
+
| Feature | Description |
|
|
38
|
+
|---------|-------------|
|
|
39
|
+
| 🌍 统一入口 | `/chat/completions`、`/embeddings`、`/images/generations`… 全部转发 |
|
|
40
|
+
| 🧩 多后端 | vLLM、SGLang、lmdeploy、Ollama… 任意组合 |
|
|
41
|
+
| 💾 持久化 | SQLite + SQLModel 零配置存储路由 |
|
|
42
|
+
| ⚡ 实时流 | SSE & Chunked Transfer 全双工支持 |
|
|
43
|
+
| 🎨 Web UI | Gradio 即用的管理面板 |
|
|
44
|
+
| 🔍 兼容 OpenAI | SDK / LangChain / AutoGen 等无需改动一行代码 |
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
### 📦 Quick Start
|
|
49
|
+
Step-1:安装
|
|
50
|
+
```bash
|
|
51
|
+
uv sync
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
Step-2:启动
|
|
56
|
+
```bash
|
|
57
|
+
python -m openai_router.main --host localhost --port 8000
|
|
58
|
+
```
|
|
59
|
+
浏览器自动打开
|
|
60
|
+
📍 UI:`http://localhost:8000`
|
|
61
|
+
📍 API:`http://localhost:8000/v1`
|
|
62
|
+
|
|
63
|
+
Step-3:添加后端
|
|
64
|
+
在 Web UI 「添加 / 更新」填入:
|
|
65
|
+
- 模型名:`gpt-4`
|
|
66
|
+
- 后端 URL:`http://localhost:8080/v1`
|
|
67
|
+
|
|
68
|
+
<img src="static/ui.png" width="800">
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## 🔧 API Usage
|
|
73
|
+
### **像官方 OpenAI SDK一样调用**
|
|
74
|
+
```python
|
|
75
|
+
from openai import OpenAI
|
|
76
|
+
client = OpenAI(
|
|
77
|
+
base_url="http://localhost:8000/v1",
|
|
78
|
+
api_key="sk-dummy"
|
|
79
|
+
)
|
|
80
|
+
resp = client.chat.completions.create(
|
|
81
|
+
model="gpt-4",
|
|
82
|
+
messages=[{"role":"user","content":"hello"}],
|
|
83
|
+
stream=True
|
|
84
|
+
)
|
|
85
|
+
for chunk in resp:
|
|
86
|
+
print(chunk.choices[0].delta.content or "", end="")
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
cURL
|
|
90
|
+
```bash
|
|
91
|
+
curl http://localhost:8000/v1/chat/completions \
|
|
92
|
+
-H "Content-Type: application/json" \
|
|
93
|
+
-d '{"model":"gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}'
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## 🗂️ Endpoints
|
|
99
|
+
| Method | Path | Description |
|
|
100
|
+
|--------|------|-------------|
|
|
101
|
+
| `GET` | `/` | Gradio Admin UI |
|
|
102
|
+
| `GET` | `/docs` | OpenAPI Swagger |
|
|
103
|
+
| `GET` | `/v1/models` | List available models |
|
|
104
|
+
`POST` | `/v1/responses` | Responses API |
|
|
105
|
+
| `POST` | `/v1/chat/completions` | Chat completion |
|
|
106
|
+
| `POST` | `/v1/embeddings` | Text embeddings |
|
|
107
|
+
| `POST` | `/v1/images/generations` | DALL·E style |
|
|
108
|
+
| `POST` | `/v1/audio/transcriptions` | Whisper |
|
|
109
|
+
| … | … | All OpenAI endpoints supported |
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## ⚙️ Configuration
|
|
114
|
+
CLI Options
|
|
115
|
+
```bash
|
|
116
|
+
python -m openai_router.main --help
|
|
117
|
+
```
|
|
118
|
+
| Flag | Default | Description |
|
|
119
|
+
|------|---------|-------------|
|
|
120
|
+
| `--host` | `localhost` | Bind address |
|
|
121
|
+
| `--port` | `8000` | Bind port |
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## 🏗️ Architecture
|
|
127
|
+
<img src="static/arch.png" width="800">
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
|
|
2
|
+
<!-- markdownlint-disable MD033 -->
|
|
3
|
+
<h1 align="center">
|
|
4
|
+
🚀 OpenAI Router
|
|
5
|
+
</h1>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<b>轻量级、持久化、零配置的 OpenAI API 统一网关</b><br>
|
|
9
|
+
一键聚合 vLLM、SGLang、lmdeploy、Ollama…
|
|
10
|
+
</p>
|
|
11
|
+
|
|
12
|
+
<p align="center">
|
|
13
|
+
<a href="#"><img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square"></a>
|
|
14
|
+
<a href="https://fastapi.tiangolo.com"><img src="https://img.shields.io/badge/FastAPI-v0.115+-teal?style=flat-square"></a>
|
|
15
|
+
<a href="https://gradio.app"><img src="https://img.shields.io/badge/Gradio-v5+-orange?style=flat-square"></a>
|
|
16
|
+
<a href="#"><img src="https://img.shields.io/badge/SQLite-内置存储-lightgrey?style=flat-square"></a>
|
|
17
|
+
</p>
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## ✨ Features
|
|
22
|
+
| Feature | Description |
|
|
23
|
+
|---------|-------------|
|
|
24
|
+
| 🌍 统一入口 | `/chat/completions`、`/embeddings`、`/images/generations`… 全部转发 |
|
|
25
|
+
| 🧩 多后端 | vLLM、SGLang、lmdeploy、Ollama… 任意组合 |
|
|
26
|
+
| 💾 持久化 | SQLite + SQLModel 零配置存储路由 |
|
|
27
|
+
| ⚡ 实时流 | SSE & Chunked Transfer 全双工支持 |
|
|
28
|
+
| 🎨 Web UI | Gradio 即用的管理面板 |
|
|
29
|
+
| 🔍 兼容 OpenAI | SDK / LangChain / AutoGen 等无需改动一行代码 |
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
### 📦 Quick Start
|
|
34
|
+
Step-1:安装
|
|
35
|
+
```bash
|
|
36
|
+
uv sync
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
Step-2:启动
|
|
41
|
+
```bash
|
|
42
|
+
python -m openai_router.main --host localhost --port 8000
|
|
43
|
+
```
|
|
44
|
+
浏览器自动打开
|
|
45
|
+
📍 UI:`http://localhost:8000`
|
|
46
|
+
📍 API:`http://localhost:8000/v1`
|
|
47
|
+
|
|
48
|
+
Step-3:添加后端
|
|
49
|
+
在 Web UI 「添加 / 更新」填入:
|
|
50
|
+
- 模型名:`gpt-4`
|
|
51
|
+
- 后端 URL:`http://localhost:8080/v1`
|
|
52
|
+
|
|
53
|
+
<img src="static/ui.png" width="800">
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## 🔧 API Usage
|
|
58
|
+
### **像官方 OpenAI SDK一样调用**
|
|
59
|
+
```python
|
|
60
|
+
from openai import OpenAI
|
|
61
|
+
client = OpenAI(
|
|
62
|
+
base_url="http://localhost:8000/v1",
|
|
63
|
+
api_key="sk-dummy"
|
|
64
|
+
)
|
|
65
|
+
resp = client.chat.completions.create(
|
|
66
|
+
model="gpt-4",
|
|
67
|
+
messages=[{"role":"user","content":"hello"}],
|
|
68
|
+
stream=True
|
|
69
|
+
)
|
|
70
|
+
for chunk in resp:
|
|
71
|
+
print(chunk.choices[0].delta.content or "", end="")
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
cURL
|
|
75
|
+
```bash
|
|
76
|
+
curl http://localhost:8000/v1/chat/completions \
|
|
77
|
+
-H "Content-Type: application/json" \
|
|
78
|
+
-d '{"model":"gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}'
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## 🗂️ Endpoints
|
|
84
|
+
| Method | Path | Description |
|
|
85
|
+
|--------|------|-------------|
|
|
86
|
+
| `GET` | `/` | Gradio Admin UI |
|
|
87
|
+
| `GET` | `/docs` | OpenAPI Swagger |
|
|
88
|
+
| `GET` | `/v1/models` | List available models |
|
|
89
|
+
`POST` | `/v1/responses` | Responses API |
|
|
90
|
+
| `POST` | `/v1/chat/completions` | Chat completion |
|
|
91
|
+
| `POST` | `/v1/embeddings` | Text embeddings |
|
|
92
|
+
| `POST` | `/v1/images/generations` | DALL·E style |
|
|
93
|
+
| `POST` | `/v1/audio/transcriptions` | Whisper |
|
|
94
|
+
| … | … | All OpenAI endpoints supported |
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## ⚙️ Configuration
|
|
99
|
+
CLI Options
|
|
100
|
+
```bash
|
|
101
|
+
python -m openai_router.main --help
|
|
102
|
+
```
|
|
103
|
+
| Flag | Default | Description |
|
|
104
|
+
|------|---------|-------------|
|
|
105
|
+
| `--host` | `localhost` | Bind address |
|
|
106
|
+
| `--port` | `8000` | Bind port |
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## 🏗️ Architecture
|
|
112
|
+
<img src="static/arch.png" width="800">
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "openai-router"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "openai-router"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"fastapi>=0.120.3",
|
|
9
|
+
"gradio>=5.49.1",
|
|
10
|
+
"httpx>=0.28.1",
|
|
11
|
+
"loguru>=0.7.3",
|
|
12
|
+
"sqlmodel>=0.0.27",
|
|
13
|
+
"uvicorn>=0.38.0",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.scripts]
|
|
17
|
+
openai_router = "openai_router.main:cli_app"
|
|
18
|
+
|
|
19
|
+
[[tool.uv.index]]
|
|
20
|
+
url = "https://pypi.org/simple/"
|
|
21
|
+
default = true
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
[tool.setuptools.packages.find]
|
|
25
|
+
where = ["src"]
|
|
26
|
+
|
|
27
|
+
[build-system]
|
|
28
|
+
requires = ["setuptools", "wheel"]
|
|
29
|
+
build-backend = "setuptools.build_meta"
|
|
File without changes
|
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
from typing import Annotated
|
|
2
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
3
|
+
from fastapi.responses import Response, StreamingResponse
|
|
4
|
+
from starlette.concurrency import run_in_threadpool
|
|
5
|
+
import httpx
|
|
6
|
+
from loguru import logger
|
|
7
|
+
from contextlib import asynccontextmanager
|
|
8
|
+
import gradio as gr
|
|
9
|
+
import pandas as pd
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
import typer
|
|
12
|
+
import uvicorn
|
|
13
|
+
import webbrowser
|
|
14
|
+
import time
|
|
15
|
+
|
|
16
|
+
# --- 导入 SQLModel 和同步组件 ---
|
|
17
|
+
# 切换到同步 Session 和 Engine
|
|
18
|
+
from sqlmodel import Field, SQLModel, create_engine, Session, select
|
|
19
|
+
from sqlalchemy.engine import Engine # 导入同步 Engine
|
|
20
|
+
|
|
21
|
+
# --- 数据库配置 ---
|
|
22
|
+
SQLITE_DB_FILE = "routes.db"
|
|
23
|
+
# 使用同步 SQLite URL
|
|
24
|
+
SQLITE_URL = f"sqlite:///{SQLITE_DB_FILE}"
|
|
25
|
+
|
|
26
|
+
# --- 全局变量 ---
|
|
27
|
+
client: httpx.AsyncClient = None
|
|
28
|
+
# 同步 Engine
|
|
29
|
+
engine: Engine = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# --- SQLModel 数据模型 ---
|
|
33
|
+
class ModelRoute(SQLModel, table=True):
|
|
34
|
+
"""
|
|
35
|
+
一个 SQLModel 模型,用于存储模型名称到后端 URL 的路由。
|
|
36
|
+
'model_name' 是主键,确保了唯一性。
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
model_name: str = Field(primary_key=True, index=True)
|
|
40
|
+
model_url: str
|
|
41
|
+
created: datetime = Field(
|
|
42
|
+
default_factory=lambda: datetime.now(timezone.utc), nullable=False
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# --- 同步数据库辅助函数 (将在线程池中执行) ---
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def create_db_and_tables_sync():
|
|
50
|
+
"""同步创建数据库和表"""
|
|
51
|
+
SQLModel.metadata.create_all(engine)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def get_all_model_names_sync():
|
|
55
|
+
"""同步地从数据库中查询所有可用的模型名称"""
|
|
56
|
+
with Session(engine) as session:
|
|
57
|
+
statement = select(ModelRoute.model_name)
|
|
58
|
+
results = session.exec(statement)
|
|
59
|
+
available_models = results.all()
|
|
60
|
+
return available_models
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def get_all_routes_sync():
|
|
64
|
+
"""同步地从数据库中查询所有可用的 ModelRoute 记录"""
|
|
65
|
+
with Session(engine) as session:
|
|
66
|
+
# 查询 ModelRoute 的所有记录
|
|
67
|
+
statement = select(ModelRoute)
|
|
68
|
+
results = session.exec(statement)
|
|
69
|
+
# 返回 ModelRoute 对象的列表
|
|
70
|
+
all_routes = results.all()
|
|
71
|
+
return all_routes
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def get_routing_info_sync(model: str):
|
|
75
|
+
"""同步地从数据库中查询模型和所有可用模型"""
|
|
76
|
+
with Session(engine) as session:
|
|
77
|
+
# 1. 获取特定模型
|
|
78
|
+
db_route = session.get(ModelRoute, model)
|
|
79
|
+
|
|
80
|
+
# 2. 获取所有可用模型
|
|
81
|
+
available_models = get_all_model_names_sync()
|
|
82
|
+
server = db_route.model_url if db_route else None
|
|
83
|
+
|
|
84
|
+
return server, available_models
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# --- FastAPI 生命周期 ---
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@asynccontextmanager
|
|
91
|
+
async def lifespan(app: FastAPI):
|
|
92
|
+
global client, engine
|
|
93
|
+
|
|
94
|
+
# --- 启动时的逻辑 ---
|
|
95
|
+
|
|
96
|
+
# 1. 初始化数据库引擎 (同步)
|
|
97
|
+
engine = create_engine(SQLITE_URL, echo=False)
|
|
98
|
+
|
|
99
|
+
# 2. 创建数据库和表 (使用 run_in_threadpool 执行同步代码)
|
|
100
|
+
await run_in_threadpool(create_db_and_tables_sync)
|
|
101
|
+
|
|
102
|
+
# 3. **移除环境变量同步逻辑**
|
|
103
|
+
|
|
104
|
+
# 4. 初始化 httpx 客户端
|
|
105
|
+
timeout = httpx.Timeout(10.0, connect=60.0, read=None, write=60.0)
|
|
106
|
+
client = httpx.AsyncClient(timeout=timeout)
|
|
107
|
+
|
|
108
|
+
yield # 应用运行期间
|
|
109
|
+
|
|
110
|
+
# --- 关闭时的逻辑 ---
|
|
111
|
+
|
|
112
|
+
if client:
|
|
113
|
+
await client.aclose()
|
|
114
|
+
logger.info("HTTPX client closed.")
|
|
115
|
+
|
|
116
|
+
if engine:
|
|
117
|
+
engine.dispose()
|
|
118
|
+
logger.info("Database engine disposed.")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# --- FastAPI 核心应用 ---
|
|
122
|
+
|
|
123
|
+
# 使用 lifespan 创建 FastAPI 实例
|
|
124
|
+
app = FastAPI(lifespan=lifespan)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
async def _get_routing_info(request: Request):
|
|
128
|
+
"""
|
|
129
|
+
异步辅助函数:解析请求体,并在线程池中执行同步数据库查询。
|
|
130
|
+
"""
|
|
131
|
+
try:
|
|
132
|
+
json_body = await request.json()
|
|
133
|
+
except Exception as e:
|
|
134
|
+
logger.error(f"Failed to parse request body: {e}")
|
|
135
|
+
raise HTTPException(status_code=400, detail="Invalid JSON body")
|
|
136
|
+
|
|
137
|
+
model = json_body.get("model")
|
|
138
|
+
if model is None:
|
|
139
|
+
raise HTTPException(
|
|
140
|
+
status_code=400, detail="'model' field is required in request body"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
# 在线程池中执行同步查询
|
|
144
|
+
server, available_models = await run_in_threadpool(get_routing_info_sync, model)
|
|
145
|
+
|
|
146
|
+
if server is None:
|
|
147
|
+
raise HTTPException(
|
|
148
|
+
status_code=400,
|
|
149
|
+
detail=f"Invalid model: {model}. Available models: {available_models}",
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
backend_url = server + request.url.path
|
|
153
|
+
logger.info(f"Routing to backend_url: {backend_url} for model {model}")
|
|
154
|
+
|
|
155
|
+
return backend_url, json_body
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
async def _stream_proxy(backend_url: str, request: Request, json_body: dict):
|
|
159
|
+
"""
|
|
160
|
+
这是一个异步生成器,用于代理流式响应。
|
|
161
|
+
"""
|
|
162
|
+
headers = {
|
|
163
|
+
h: v
|
|
164
|
+
for h, v in request.headers.items()
|
|
165
|
+
if h.lower() not in ["host", "content-length"]
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
async with client.stream(
|
|
170
|
+
request.method,
|
|
171
|
+
backend_url,
|
|
172
|
+
params=request.query_params,
|
|
173
|
+
json=json_body,
|
|
174
|
+
headers=headers,
|
|
175
|
+
) as response:
|
|
176
|
+
if response.status_code >= 400:
|
|
177
|
+
error_content = await response.aread()
|
|
178
|
+
logger.warning(
|
|
179
|
+
f"Backend error: {response.status_code} - {error_content.decode()}"
|
|
180
|
+
)
|
|
181
|
+
raise HTTPException(
|
|
182
|
+
status_code=response.status_code, detail=error_content.decode()
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
async for chunk in response.aiter_bytes():
|
|
186
|
+
yield chunk
|
|
187
|
+
|
|
188
|
+
except httpx.ConnectError as e:
|
|
189
|
+
logger.error(f"Connection error to backend {backend_url}: {e}")
|
|
190
|
+
raise HTTPException(status_code=503, detail="Backend service unavailable")
|
|
191
|
+
except Exception as e:
|
|
192
|
+
logger.error(f"An error occurred during streaming proxy: {e}")
|
|
193
|
+
logger.error("Streaming interrupted due to an error.")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
async def _non_stream_proxy(backend_url: str, request: Request, json_body: dict):
|
|
197
|
+
"""
|
|
198
|
+
处理非流式请求的代理逻辑。
|
|
199
|
+
"""
|
|
200
|
+
headers = {
|
|
201
|
+
h: v
|
|
202
|
+
for h, v in request.headers.items()
|
|
203
|
+
if h.lower() not in ["host", "content-length"]
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
try:
|
|
207
|
+
response = await client.post(
|
|
208
|
+
backend_url, params=request.query_params, json=json_body, headers=headers
|
|
209
|
+
)
|
|
210
|
+
return Response(
|
|
211
|
+
content=response.content,
|
|
212
|
+
media_type=response.headers.get("Content-Type"),
|
|
213
|
+
status_code=response.status_code,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
except httpx.ConnectError as e:
|
|
217
|
+
logger.error(f"Connection error to backend {backend_url}: {e}")
|
|
218
|
+
raise HTTPException(status_code=503, detail="Backend service unavailable")
|
|
219
|
+
except httpx.ReadTimeout as e:
|
|
220
|
+
logger.error(f"Read timeout from backend {backend_url}: {e}")
|
|
221
|
+
raise HTTPException(status_code=504, detail="Backend request timed out")
|
|
222
|
+
except Exception as e:
|
|
223
|
+
logger.error(f"An error occurred during non-streaming proxy: {e}")
|
|
224
|
+
raise HTTPException(status_code=500, detail=f"Internal proxy error: {e}")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@app.get("/v1/models", summary="List available models")
|
|
228
|
+
async def list_models():
|
|
229
|
+
"""
|
|
230
|
+
OpenAI 兼容接口: 列出所有可用的模型。
|
|
231
|
+
"""
|
|
232
|
+
try:
|
|
233
|
+
all_routes: list[ModelRoute] = await run_in_threadpool(get_all_routes_sync)
|
|
234
|
+
|
|
235
|
+
models_data = []
|
|
236
|
+
for route in all_routes:
|
|
237
|
+
created_timestamp = int(route.created.timestamp())
|
|
238
|
+
|
|
239
|
+
models_data.append(
|
|
240
|
+
{
|
|
241
|
+
"id": route.model_name,
|
|
242
|
+
"object": "model",
|
|
243
|
+
# 3. 使用数据库中的 created 时间戳
|
|
244
|
+
"created": created_timestamp,
|
|
245
|
+
"owned_by": "openai_router",
|
|
246
|
+
"permission": [],
|
|
247
|
+
}
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
response_data = {"object": "list", "data": models_data}
|
|
251
|
+
|
|
252
|
+
logger.info(f"Returning {len(all_routes)} available models for /v1/models.")
|
|
253
|
+
|
|
254
|
+
return response_data
|
|
255
|
+
|
|
256
|
+
except Exception as e:
|
|
257
|
+
logger.error(f"Failed to list models: {e}")
|
|
258
|
+
raise HTTPException(
|
|
259
|
+
status_code=500, detail=f"Internal server error when retrieving models: {e}"
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@app.post("/v1/responses", summary="/v1/responses ")
|
|
264
|
+
@app.post("/v1/completions", summary="/v1/completions")
|
|
265
|
+
@app.post("/v1/chat/completions", summary="/v1/chat/completions")
|
|
266
|
+
@app.post("/v1/embeddings", summary="/v1/embeddings")
|
|
267
|
+
@app.post("/v1/moderations", summary="/v1/moderations")
|
|
268
|
+
@app.post("/v1/images/generations", summary="/v1/images/generations")
|
|
269
|
+
@app.post("/v1/images/edits", summary="/v1/images/edits")
|
|
270
|
+
@app.post("/v1/images/variations", summary="/v1/images/variations")
|
|
271
|
+
@app.post("/v1/audio/transcriptions", summary="/v1/audio/transcriptions")
|
|
272
|
+
@app.post("/v1/audio/speech", summary="/v1/audio/speech")
|
|
273
|
+
@app.post("/v1/rerank", summary="/v1/rerank")
|
|
274
|
+
async def router(request: Request):
|
|
275
|
+
backend_url, json_body = await _get_routing_info(request)
|
|
276
|
+
if json_body.get("stream", False):
|
|
277
|
+
return StreamingResponse(
|
|
278
|
+
_stream_proxy(backend_url, request, json_body),
|
|
279
|
+
media_type="text/event-stream",
|
|
280
|
+
)
|
|
281
|
+
else:
|
|
282
|
+
return await _non_stream_proxy(backend_url, request, json_body)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
# --- Gradio 管理界面逻辑 (同步数据库操作) ---
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def get_current_routes_sync():
|
|
289
|
+
"""同步获取当前路由表"""
|
|
290
|
+
with Session(engine) as session:
|
|
291
|
+
statement = select(ModelRoute)
|
|
292
|
+
routes_db = session.exec(statement)
|
|
293
|
+
routes = [[route.model_name, route.model_url] for route in routes_db.all()]
|
|
294
|
+
return routes
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def add_or_update_route_sync(model_name: str, model_url: str):
|
|
298
|
+
"""同步添加或更新一个路由到数据库"""
|
|
299
|
+
status_message = ""
|
|
300
|
+
with Session(engine) as session:
|
|
301
|
+
db_route = session.get(ModelRoute, model_name)
|
|
302
|
+
|
|
303
|
+
if db_route:
|
|
304
|
+
db_route.model_url = model_url
|
|
305
|
+
status_message = f"路由 '{model_name}' 已更新。"
|
|
306
|
+
else:
|
|
307
|
+
db_route = ModelRoute(model_name=model_name, model_url=model_url)
|
|
308
|
+
status_message = f"路由 '{model_name}' 已添加。"
|
|
309
|
+
|
|
310
|
+
session.add(db_route)
|
|
311
|
+
session.commit()
|
|
312
|
+
|
|
313
|
+
logger.info(f"[Admin] {status_message}")
|
|
314
|
+
return status_message
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def delete_route_sync(model_name: str):
|
|
318
|
+
"""同步从数据库删除一个路由"""
|
|
319
|
+
status_message = ""
|
|
320
|
+
with Session(engine) as session:
|
|
321
|
+
db_route = session.get(ModelRoute, model_name)
|
|
322
|
+
|
|
323
|
+
if db_route:
|
|
324
|
+
session.delete(db_route)
|
|
325
|
+
session.commit()
|
|
326
|
+
status_message = f"路由 '{model_name}' 已删除。"
|
|
327
|
+
logger.info(f"[Admin] Route deleted: {model_name}")
|
|
328
|
+
else:
|
|
329
|
+
status_message = f"错误: 未找到路由 '{model_name}'。"
|
|
330
|
+
|
|
331
|
+
return status_message
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
# Gradio 异步事件处理函数 (使用 run_in_threadpool 调用同步函数)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
async def get_current_routes():
|
|
338
|
+
"""异步调用同步函数获取当前路由表"""
|
|
339
|
+
return await run_in_threadpool(get_current_routes_sync)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
async def add_or_update_route(model_name: str, model_url: str):
|
|
343
|
+
"""异步调用同步函数添加或更新路由"""
|
|
344
|
+
if not model_name or not model_url:
|
|
345
|
+
return "模型名称和 URL 不能为空", await get_current_routes()
|
|
346
|
+
|
|
347
|
+
status_message = await run_in_threadpool(
|
|
348
|
+
add_or_update_route_sync, model_name, model_url
|
|
349
|
+
)
|
|
350
|
+
return status_message, await get_current_routes()
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
async def delete_route(model_name: str):
|
|
354
|
+
"""异步调用同步函数删除路由"""
|
|
355
|
+
if not model_name:
|
|
356
|
+
return "要删除的模型名称不能为空", await get_current_routes()
|
|
357
|
+
|
|
358
|
+
status_message = await run_in_threadpool(delete_route_sync, model_name)
|
|
359
|
+
return status_message, await get_current_routes()
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def on_select_route(routes_data: pd.DataFrame, evt: gr.SelectData):
|
|
363
|
+
"""
|
|
364
|
+
Gradio: 当用户点击表格中的一行时,填充输入框。
|
|
365
|
+
"""
|
|
366
|
+
if evt.index is None:
|
|
367
|
+
return "", ""
|
|
368
|
+
|
|
369
|
+
selected_row = routes_data.iloc[evt.index[0]]
|
|
370
|
+
model_name = selected_row.iloc[0]
|
|
371
|
+
model_url = selected_row.iloc[1]
|
|
372
|
+
|
|
373
|
+
return model_name, model_url
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def create_admin_ui():
|
|
377
|
+
"""创建 Gradio Blocks 界面"""
|
|
378
|
+
with gr.Blocks(
|
|
379
|
+
title="模型路由管理器", css="footer {display: none !important}"
|
|
380
|
+
) as admin_ui:
|
|
381
|
+
gr.Markdown(
|
|
382
|
+
"<h1 style='text-align:center;'>模型路由管理器</h1>", elem_id="title"
|
|
383
|
+
)
|
|
384
|
+
gr.Markdown(
|
|
385
|
+
"""**将不同端口、不同服务的`openAI`的接口通过统一的url进行路由!兼容 `vLLM`、`SGLang`、`lmdeoply`、`Ollama`等。**\n
|
|
386
|
+
**注意:** 所有路由配置都持久化到 `routes.db` 数据库中。您需要手动添加初始路由。"""
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
with gr.Row():
|
|
390
|
+
refresh_button = gr.Button("刷新路由列表")
|
|
391
|
+
|
|
392
|
+
with gr.Row():
|
|
393
|
+
with gr.Column(scale=2):
|
|
394
|
+
routes_datagrid = gr.DataFrame(
|
|
395
|
+
headers=["模型名称 (Model Name)", "后端 URL (Backend URL)"],
|
|
396
|
+
label="当前路由表",
|
|
397
|
+
row_count=(1, "fixed"),
|
|
398
|
+
col_count=(2, "fixed"),
|
|
399
|
+
interactive=False,
|
|
400
|
+
)
|
|
401
|
+
with gr.Column(scale=1):
|
|
402
|
+
gr.Markdown("### 管理路由")
|
|
403
|
+
status_output = gr.Textbox(
|
|
404
|
+
label="操作状态",
|
|
405
|
+
interactive=False,
|
|
406
|
+
value="这里用于显示上一次的操作状态",
|
|
407
|
+
)
|
|
408
|
+
model_name_input = gr.Textbox(label="模型名称", value="gpt4")
|
|
409
|
+
model_url_input = gr.Textbox(
|
|
410
|
+
label="后端 URL", value="http://localhost:8082"
|
|
411
|
+
)
|
|
412
|
+
with gr.Row():
|
|
413
|
+
add_update_button = gr.Button("添加 / 更新")
|
|
414
|
+
delete_button = gr.Button(
|
|
415
|
+
"删除",
|
|
416
|
+
variant="stop",
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
# --- 绑定 Gradio 事件 ---
|
|
420
|
+
admin_ui.load(get_current_routes, outputs=routes_datagrid)
|
|
421
|
+
refresh_button.click(get_current_routes, outputs=routes_datagrid)
|
|
422
|
+
|
|
423
|
+
add_update_button.click(
|
|
424
|
+
add_or_update_route,
|
|
425
|
+
inputs=[model_name_input, model_url_input],
|
|
426
|
+
outputs=[status_output, routes_datagrid],
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
delete_button.click(
|
|
430
|
+
delete_route,
|
|
431
|
+
inputs=[model_name_input],
|
|
432
|
+
outputs=[status_output, routes_datagrid],
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
routes_datagrid.select(
|
|
436
|
+
on_select_route,
|
|
437
|
+
inputs=[routes_datagrid],
|
|
438
|
+
outputs=[model_name_input, model_url_input],
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
return admin_ui
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
# --- 挂载 Gradio 应用 ---
|
|
445
|
+
admin_interface = create_admin_ui()
|
|
446
|
+
app = gr.mount_gradio_app(app, admin_interface, path="/")
|
|
447
|
+
|
|
448
|
+
cli_app = typer.Typer()
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
@cli_app.command()
|
|
452
|
+
def main(
|
|
453
|
+
host: Annotated[
|
|
454
|
+
str, typer.Option(help="指定监听的主机地址", show_default=True)
|
|
455
|
+
] = "localhost",
|
|
456
|
+
port: Annotated[
|
|
457
|
+
int, typer.Option(help="指定监听的主机端口", show_default=True)
|
|
458
|
+
] = 8000,
|
|
459
|
+
):
|
|
460
|
+
base_url = f"http://{host}:{port}"
|
|
461
|
+
logger.info(f"UI 界面: http://{host}:{port}")
|
|
462
|
+
logger.info(f"openAI API 文档: http://{host}:{port}/docs")
|
|
463
|
+
time.sleep(1)
|
|
464
|
+
try:
|
|
465
|
+
if "0.0.0.0" in base_url:
|
|
466
|
+
base_url = f"http://localhost:{port}"
|
|
467
|
+
webbrowser.open_new_tab(base_url)
|
|
468
|
+
except Exception as e:
|
|
469
|
+
logger.warning(f"无法自动打开浏览器: {e}")
|
|
470
|
+
uvicorn.run(app, host=host, port=port)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
if __name__ == "__main__":
|
|
474
|
+
cli_app()
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: openai-router
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: openai-router
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
License-File: LICENSE.txt
|
|
8
|
+
Requires-Dist: fastapi>=0.120.3
|
|
9
|
+
Requires-Dist: gradio>=5.49.1
|
|
10
|
+
Requires-Dist: httpx>=0.28.1
|
|
11
|
+
Requires-Dist: loguru>=0.7.3
|
|
12
|
+
Requires-Dist: sqlmodel>=0.0.27
|
|
13
|
+
Requires-Dist: uvicorn>=0.38.0
|
|
14
|
+
Dynamic: license-file
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
<!-- markdownlint-disable MD033 -->
|
|
18
|
+
<h1 align="center">
|
|
19
|
+
🚀 OpenAI Router
|
|
20
|
+
</h1>
|
|
21
|
+
|
|
22
|
+
<p align="center">
|
|
23
|
+
<b>轻量级、持久化、零配置的 OpenAI API 统一网关</b><br>
|
|
24
|
+
一键聚合 vLLM、SGLang、lmdeploy、Ollama…
|
|
25
|
+
</p>
|
|
26
|
+
|
|
27
|
+
<p align="center">
|
|
28
|
+
<a href="#"><img src="https://img.shields.io/badge/license-MIT-blue?style=flat-square"></a>
|
|
29
|
+
<a href="https://fastapi.tiangolo.com"><img src="https://img.shields.io/badge/FastAPI-v0.115+-teal?style=flat-square"></a>
|
|
30
|
+
<a href="https://gradio.app"><img src="https://img.shields.io/badge/Gradio-v5+-orange?style=flat-square"></a>
|
|
31
|
+
<a href="#"><img src="https://img.shields.io/badge/SQLite-内置存储-lightgrey?style=flat-square"></a>
|
|
32
|
+
</p>
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## ✨ Features
|
|
37
|
+
| Feature | Description |
|
|
38
|
+
|---------|-------------|
|
|
39
|
+
| 🌍 统一入口 | `/chat/completions`、`/embeddings`、`/images/generations`… 全部转发 |
|
|
40
|
+
| 🧩 多后端 | vLLM、SGLang、lmdeploy、Ollama… 任意组合 |
|
|
41
|
+
| 💾 持久化 | SQLite + SQLModel 零配置存储路由 |
|
|
42
|
+
| ⚡ 实时流 | SSE & Chunked Transfer 全双工支持 |
|
|
43
|
+
| 🎨 Web UI | Gradio 即用的管理面板 |
|
|
44
|
+
| 🔍 兼容 OpenAI | SDK / LangChain / AutoGen 等无需改动一行代码 |
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
### 📦 Quick Start
|
|
49
|
+
Step-1:安装
|
|
50
|
+
```bash
|
|
51
|
+
uv sync
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
Step-2:启动
|
|
56
|
+
```bash
|
|
57
|
+
python -m openai_router.main --host localhost --port 8000
|
|
58
|
+
```
|
|
59
|
+
浏览器自动打开
|
|
60
|
+
📍 UI:`http://localhost:8000`
|
|
61
|
+
📍 API:`http://localhost:8000/v1`
|
|
62
|
+
|
|
63
|
+
Step-3:添加后端
|
|
64
|
+
在 Web UI 「添加 / 更新」填入:
|
|
65
|
+
- 模型名:`gpt-4`
|
|
66
|
+
- 后端 URL:`http://localhost:8080/v1`
|
|
67
|
+
|
|
68
|
+
<img src="static/ui.png" width="800">
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
## 🔧 API Usage
|
|
73
|
+
### **像官方 OpenAI SDK一样调用**
|
|
74
|
+
```python
|
|
75
|
+
from openai import OpenAI
|
|
76
|
+
client = OpenAI(
|
|
77
|
+
base_url="http://localhost:8000/v1",
|
|
78
|
+
api_key="sk-dummy"
|
|
79
|
+
)
|
|
80
|
+
resp = client.chat.completions.create(
|
|
81
|
+
model="gpt-4",
|
|
82
|
+
messages=[{"role":"user","content":"hello"}],
|
|
83
|
+
stream=True
|
|
84
|
+
)
|
|
85
|
+
for chunk in resp:
|
|
86
|
+
print(chunk.choices[0].delta.content or "", end="")
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
cURL
|
|
90
|
+
```bash
|
|
91
|
+
curl http://localhost:8000/v1/chat/completions \
|
|
92
|
+
-H "Content-Type: application/json" \
|
|
93
|
+
-d '{"model":"gpt-4","messages":[{"role":"user","content":"hi"}],"stream":true}'
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## 🗂️ Endpoints
|
|
99
|
+
| Method | Path | Description |
|
|
100
|
+
|--------|------|-------------|
|
|
101
|
+
| `GET` | `/` | Gradio Admin UI |
|
|
102
|
+
| `GET` | `/docs` | OpenAPI Swagger |
|
|
103
|
+
| `GET` | `/v1/models` | List available models |
|
|
104
|
+
`POST` | `/v1/responses` | Responses API |
|
|
105
|
+
| `POST` | `/v1/chat/completions` | Chat completion |
|
|
106
|
+
| `POST` | `/v1/embeddings` | Text embeddings |
|
|
107
|
+
| `POST` | `/v1/images/generations` | DALL·E style |
|
|
108
|
+
| `POST` | `/v1/audio/transcriptions` | Whisper |
|
|
109
|
+
| … | … | All OpenAI endpoints supported |
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## ⚙️ Configuration
|
|
114
|
+
CLI Options
|
|
115
|
+
```bash
|
|
116
|
+
python -m openai_router.main --help
|
|
117
|
+
```
|
|
118
|
+
| Flag | Default | Description |
|
|
119
|
+
|------|---------|-------------|
|
|
120
|
+
| `--host` | `localhost` | Bind address |
|
|
121
|
+
| `--port` | `8000` | Bind port |
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## 🏗️ Architecture
|
|
127
|
+
<img src="static/arch.png" width="800">
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
LICENSE.txt
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/openai_router/__init__.py
|
|
5
|
+
src/openai_router/main.py
|
|
6
|
+
src/openai_router.egg-info/PKG-INFO
|
|
7
|
+
src/openai_router.egg-info/SOURCES.txt
|
|
8
|
+
src/openai_router.egg-info/dependency_links.txt
|
|
9
|
+
src/openai_router.egg-info/entry_points.txt
|
|
10
|
+
src/openai_router.egg-info/requires.txt
|
|
11
|
+
src/openai_router.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
openai_router
|