mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-02 12:30:50 +08:00
Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
352a67857b | ||
|
|
644a3ad220 | ||
|
|
19c32f58b2 | ||
|
|
d01c4904ff | ||
|
|
8cfa2282ef | ||
|
|
8e88a61021 | ||
|
|
ad4d045101 | ||
|
|
5888e04654 | ||
|
|
19b10cb894 | ||
|
|
aa25820698 | ||
|
|
9e3b84939f | ||
|
|
1dbb930660 | ||
|
|
6557d9b728 | ||
|
|
250628dae3 | ||
|
|
da72ac1f6d | ||
|
|
f9a170a3c4 | ||
|
|
88f06fc305 | ||
|
|
562a49a194 | ||
|
|
6136a77eb3 | ||
|
|
afff9216ea | ||
|
|
b56edd4db0 | ||
|
|
d512f20c56 | ||
|
|
57c9ba49f4 | ||
|
|
40255b128e | ||
|
|
6524d3a51e | ||
|
|
92c8cd7c72 | ||
|
|
c678ca21d5 | ||
|
|
6d4b43dd7a | ||
|
|
b0f2ad7cfe | ||
|
|
cd0b1be46c | ||
|
|
08856a97fb | ||
|
|
b6d5ce2d4d | ||
|
|
0f55e550cf | ||
|
|
e1de04230f | ||
|
|
a887a337a5 | ||
|
|
2717ba3e50 | ||
|
|
63af4c551d | ||
|
|
c675cf5e72 | ||
|
|
4fd95ead3b | ||
|
|
514add4b85 | ||
|
|
3ca01b60a5 | ||
|
|
39e398ae02 | ||
|
|
9bbe64489f | ||
|
|
7e54156f2f | ||
|
|
9b80820b17 | ||
|
|
e836b4ac10 | ||
|
|
f228a4dcca | ||
|
|
3297f75edd | ||
|
|
25ba042493 | ||
|
|
483229779c | ||
|
|
5a50856fc1 | ||
|
|
cf734f7e7b | ||
|
|
72325f792c | ||
|
|
9761ac5045 | ||
|
|
8fa52e9d31 | ||
|
|
80b6a95eba | ||
|
|
96cebd2a35 | ||
|
|
fc103f6c17 | ||
|
|
a45d2109f3 | ||
|
|
7a30e65175 | ||
|
|
c63dc7fe2f |
@@ -95,7 +95,7 @@ If a plaintext key is detected in the config at startup, it will be bcrypt‑has
|
||||
```
|
||||
- Response:
|
||||
```json
|
||||
{"debug":true,"proxy-url":"","api-keys":["1...5","JS...W"],"quota-exceeded":{"switch-project":true,"switch-preview-model":true},"generative-language-api-key":["AI...01", "AI...02", "AI...03"],"request-log":true,"request-retry":3,"claude-api-key":[{"api-key":"cr...56","base-url":"https://example.com/api"},{"api-key":"cr...e3","base-url":"http://example.com:3000/api"},{"api-key":"sk-...q2","base-url":"https://example.com"}],"codex-api-key":[{"api-key":"sk...01","base-url":"https://example/v1"}],"openai-compatibility":[{"name":"openrouter","base-url":"https://openrouter.ai/api/v1","api-keys":["sk...01"],"models":[{"name":"moonshotai/kimi-k2:free","alias":"kimi-k2"}]},{"name":"iflow","base-url":"https://apis.iflow.cn/v1","api-keys":["sk...7e"],"models":[{"name":"deepseek-v3.1","alias":"deepseek-v3.1"},{"name":"glm-4.5","alias":"glm-4.5"},{"name":"kimi-k2","alias":"kimi-k2"}]}],"allow-localhost-unauthenticated":true}
|
||||
{"debug":true,"proxy-url":"","api-keys":["1...5","JS...W"],"quota-exceeded":{"switch-project":true,"switch-preview-model":true},"generative-language-api-key":["AI...01", "AI...02", "AI...03"],"request-log":true,"request-retry":3,"claude-api-key":[{"api-key":"cr...56","base-url":"https://example.com/api"},{"api-key":"cr...e3","base-url":"http://example.com:3000/api"},{"api-key":"sk-...q2","base-url":"https://example.com"}],"codex-api-key":[{"api-key":"sk...01","base-url":"https://example/v1"}],"openai-compatibility":[{"name":"openrouter","base-url":"https://openrouter.ai/api/v1","api-keys":["sk...01"],"models":[{"name":"moonshotai/kimi-k2:free","alias":"kimi-k2"}]},{"name":"iflow","base-url":"https://apis.iflow.cn/v1","api-keys":["sk...7e"],"models":[{"name":"deepseek-v3.1","alias":"deepseek-v3.1"},{"name":"glm-4.5","alias":"glm-4.5"},{"name":"kimi-k2","alias":"kimi-k2"}]}]}
|
||||
```
|
||||
|
||||
### Debug
|
||||
@@ -428,29 +428,6 @@ These endpoints update the inline `config-api-key` provider inside the `auth.pro
|
||||
{ "status": "ok" }
|
||||
```
|
||||
|
||||
### Allow Localhost Unauthenticated
|
||||
- GET `/allow-localhost-unauthenticated` — Get boolean
|
||||
- Request:
|
||||
```bash
|
||||
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/allow-localhost-unauthenticated
|
||||
```
|
||||
- Response:
|
||||
```json
|
||||
{ "allow-localhost-unauthenticated": false }
|
||||
```
|
||||
- PUT/PATCH `/allow-localhost-unauthenticated` — Set boolean
|
||||
- Request:
|
||||
```bash
|
||||
curl -X PUT -H 'Content-Type: application/json' \
|
||||
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||
-d '{"value":true}' \
|
||||
http://localhost:8317/v0/management/allow-localhost-unauthenticated
|
||||
```
|
||||
- Response:
|
||||
```json
|
||||
{ "status": "ok" }
|
||||
```
|
||||
|
||||
### Claude API KEY (object array)
|
||||
- GET `/claude-api-key` — List all
|
||||
- Request:
|
||||
@@ -664,7 +641,7 @@ These endpoints initiate provider login flows and return a URL to open in a brow
|
||||
```bash
|
||||
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"secure_1psid": "<__Secure-1PSID>", "secure_1psidts": "<__Secure-1PSIDTS>"}' \
|
||||
-d '{"secure_1psid": "<__Secure-1PSID>", "secure_1psidts": "<__Secure-1PSIDTS>", "label": "<LABEL>"}' \
|
||||
http://localhost:8317/v0/management/gemini-web-token
|
||||
```
|
||||
- Response:
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
```
|
||||
- 响应:
|
||||
```json
|
||||
{"debug":true,"proxy-url":"","api-keys":["1...5","JS...W"],"quota-exceeded":{"switch-project":true,"switch-preview-model":true},"generative-language-api-key":["AI...01", "AI...02", "AI...03"],"request-log":true,"request-retry":3,"claude-api-key":[{"api-key":"cr...56","base-url":"https://example.com/api"},{"api-key":"cr...e3","base-url":"http://example.com:3000/api"},{"api-key":"sk-...q2","base-url":"https://example.com"}],"codex-api-key":[{"api-key":"sk...01","base-url":"https://example/v1"}],"openai-compatibility":[{"name":"openrouter","base-url":"https://openrouter.ai/api/v1","api-keys":["sk...01"],"models":[{"name":"moonshotai/kimi-k2:free","alias":"kimi-k2"}]},{"name":"iflow","base-url":"https://apis.iflow.cn/v1","api-keys":["sk...7e"],"models":[{"name":"deepseek-v3.1","alias":"deepseek-v3.1"},{"name":"glm-4.5","alias":"glm-4.5"},{"name":"kimi-k2","alias":"kimi-k2"}]}],"allow-localhost-unauthenticated":true}
|
||||
{"debug":true,"proxy-url":"","api-keys":["1...5","JS...W"],"quota-exceeded":{"switch-project":true,"switch-preview-model":true},"generative-language-api-key":["AI...01", "AI...02", "AI...03"],"request-log":true,"request-retry":3,"claude-api-key":[{"api-key":"cr...56","base-url":"https://example.com/api"},{"api-key":"cr...e3","base-url":"http://example.com:3000/api"},{"api-key":"sk-...q2","base-url":"https://example.com"}],"codex-api-key":[{"api-key":"sk...01","base-url":"https://example/v1"}],"openai-compatibility":[{"name":"openrouter","base-url":"https://openrouter.ai/api/v1","api-keys":["sk...01"],"models":[{"name":"moonshotai/kimi-k2:free","alias":"kimi-k2"}]},{"name":"iflow","base-url":"https://apis.iflow.cn/v1","api-keys":["sk...7e"],"models":[{"name":"deepseek-v3.1","alias":"deepseek-v3.1"},{"name":"glm-4.5","alias":"glm-4.5"},{"name":"kimi-k2","alias":"kimi-k2"}]}]}
|
||||
```
|
||||
|
||||
### Debug
|
||||
@@ -428,29 +428,6 @@
|
||||
{ "status": "ok" }
|
||||
```
|
||||
|
||||
### 允许本地未认证访问
|
||||
- GET `/allow-localhost-unauthenticated` — 获取布尔值
|
||||
- 请求:
|
||||
```bash
|
||||
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/allow-localhost-unauthenticated
|
||||
```
|
||||
- 响应:
|
||||
```json
|
||||
{ "allow-localhost-unauthenticated": false }
|
||||
```
|
||||
- PUT/PATCH `/allow-localhost-unauthenticated` — 设置布尔值
|
||||
- 请求:
|
||||
```bash
|
||||
curl -X PUT -H 'Content-Type: application/json' \
|
||||
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||
-d '{"value":true}' \
|
||||
http://localhost:8317/v0/management/allow-localhost-unauthenticated
|
||||
```
|
||||
- 响应:
|
||||
```json
|
||||
{ "status": "ok" }
|
||||
```
|
||||
|
||||
### Claude API KEY(对象数组)
|
||||
- GET `/claude-api-key` — 列出全部
|
||||
- 请求:
|
||||
@@ -664,7 +641,7 @@
|
||||
```bash
|
||||
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"secure_1psid": "<__Secure-1PSID>", "secure_1psidts": "<__Secure-1PSIDTS>"}' \
|
||||
-d '{"secure_1psid": "<__Secure-1PSID>", "secure_1psidts": "<__Secure-1PSIDTS>", "label": "<LABEL>"}' \
|
||||
http://localhost:8317/v0/management/gemini-web-token
|
||||
```
|
||||
- 响应:
|
||||
|
||||
39
README.md
39
README.md
@@ -62,6 +62,16 @@ The first Chinese provider has now been added: [Qwen Code](https://github.com/Qw
|
||||
|
||||
## Usage
|
||||
|
||||
### GUI Client & Official WebUI
|
||||
|
||||
#### [EasyCLI](https://github.com/router-for-me/EasyCLI)
|
||||
|
||||
A cross-platform desktop GUI client for CLIProxyAPI.
|
||||
|
||||
#### [Cli-Proxy-API-Management-Center](https://github.com/router-for-me/Cli-Proxy-API-Management-Center)
|
||||
|
||||
A web-based management center for CLIProxyAPI.
|
||||
|
||||
### Authentication
|
||||
|
||||
You can authenticate for Gemini, OpenAI, and/or Claude. All can coexist in the same `auth-dir` and will be load balanced.
|
||||
@@ -270,11 +280,8 @@ The server uses a YAML configuration file (`config.yaml`) located in the project
|
||||
| `quota-exceeded.switch-project` | boolean | true | Whether to automatically switch to another project when a quota is exceeded. |
|
||||
| `quota-exceeded.switch-preview-model` | boolean | true | Whether to automatically switch to a preview model when a quota is exceeded. |
|
||||
| `debug` | boolean | false | Enable debug mode for verbose logging. |
|
||||
| `auth` | object | {} | Request authentication configuration. |
|
||||
| `auth.providers` | object[] | [] | Authentication providers. Includes built-in `config-api-key` for inline keys. |
|
||||
| `auth.providers.*.name` | string | "" | Provider instance name. |
|
||||
| `auth.providers.*.type` | string | "" | Provider implementation identifier (for example `config-api-key`). |
|
||||
| `auth.providers.*.api-keys` | string[] | [] | Inline API keys consumed by the `config-api-key` provider. |
|
||||
| `logging-to-file` | boolean | true | Write application logs to rotating files instead of stdout. Set to `false` to log to stdout/stderr. |
|
||||
| `usage-statistics-enabled` | boolean | true | Enable in-memory usage aggregation for management APIs. Disable to drop all collected usage metrics. |
|
||||
| `api-keys` | string[] | [] | Legacy shorthand for inline API keys. Values are mirrored into the `config-api-key` provider for backwards compatibility. |
|
||||
| `generative-language-api-key` | string[] | [] | List of Generative Language API keys. |
|
||||
| `codex-api-key` | object | {} | List of Codex API keys. |
|
||||
@@ -319,6 +326,12 @@ auth-dir: "~/.cli-proxy-api"
|
||||
# Enable debug logging
|
||||
debug: false
|
||||
|
||||
# When true, write application logs to rotating files instead of stdout
|
||||
logging-to-file: true
|
||||
|
||||
# When false, disable in-memory usage statistics aggregation
|
||||
usage-statistics-enabled: true
|
||||
|
||||
# Proxy URL. Supports socks5/http/https protocols. Example: socks5://user:pass@192.168.1.1:1080/
|
||||
proxy-url: ""
|
||||
|
||||
@@ -336,15 +349,6 @@ gemini-web:
|
||||
code-mode: false # Enable code mode
|
||||
max-chars-per-request: 1000000 # Max characters per request
|
||||
|
||||
# Request authentication providers
|
||||
auth:
|
||||
providers:
|
||||
- name: "default"
|
||||
type: "config-api-key"
|
||||
api-keys:
|
||||
- "your-api-key-1"
|
||||
- "your-api-key-2"
|
||||
|
||||
# API keys for official Generative Language API
|
||||
generative-language-api-key:
|
||||
- "AIzaSy...01"
|
||||
@@ -626,8 +630,11 @@ see [MANAGEMENT_API.md](MANAGEMENT_API.md)
|
||||
|
||||
## SDK Docs
|
||||
|
||||
- Usage: `docs/sdk-usage.md` (中文: `docs/sdk-usage_CN.md`)
|
||||
- Advanced (executors & translators): `docs/sdk-advanced.md` (中文: `docs/sdk-advanced_CN.md`)
|
||||
- Usage: [docs/sdk-usage.md](docs/sdk-usage.md)
|
||||
- Advanced (executors & translators): [docs/sdk-advanced.md](docs/sdk-advanced.md)
|
||||
- Access: [docs/sdk-access.md](docs/sdk-access.md)
|
||||
- Watcher: [docs/sdk-watcher.md](docs/sdk-watcher.md)
|
||||
- Custom Provider Example: `examples/custom-provider`
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
38
README_CN.md
38
README_CN.md
@@ -75,6 +75,16 @@
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 图形客户端与官方 WebUI
|
||||
|
||||
#### [EasyCLI](https://github.com/router-for-me/EasyCLI)
|
||||
|
||||
CLIProxyAPI 的跨平台桌面图形客户端。
|
||||
|
||||
#### [Cli-Proxy-API-Management-Center](https://github.com/router-for-me/Cli-Proxy-API-Management-Center)
|
||||
|
||||
CLIProxyAPI 的基于 Web 的管理中心。
|
||||
|
||||
### 身份验证
|
||||
|
||||
您可以分别为 Gemini、OpenAI 和 Claude 进行身份验证,三者可同时存在于同一个 `auth-dir` 中并参与负载均衡。
|
||||
@@ -282,11 +292,8 @@ console.log(await claudeResponse.json());
|
||||
| `quota-exceeded.switch-project` | boolean | true | 当配额超限时,是否自动切换到另一个项目。 |
|
||||
| `quota-exceeded.switch-preview-model` | boolean | true | 当配额超限时,是否自动切换到预览模型。 |
|
||||
| `debug` | boolean | false | 启用调试模式以获取详细日志。 |
|
||||
| `auth` | object | {} | 请求鉴权配置。 |
|
||||
| `auth.providers` | object[] | [] | 鉴权提供方列表,内置 `config-api-key` 支持内联密钥。 |
|
||||
| `auth.providers.*.name` | string | "" | 提供方实例名称。 |
|
||||
| `auth.providers.*.type` | string | "" | 提供方实现标识(例如 `config-api-key`)。 |
|
||||
| `auth.providers.*.api-keys` | string[] | [] | `config-api-key` 提供方使用的内联密钥。 |
|
||||
| `logging-to-file` | boolean | true | 是否将应用日志写入滚动文件;设为 false 时输出到 stdout/stderr。 |
|
||||
| `usage-statistics-enabled` | boolean | true | 是否启用内存中的使用统计;设为 false 时直接丢弃所有统计数据。 |
|
||||
| `api-keys` | string[] | [] | 兼容旧配置的简写,会自动同步到默认 `config-api-key` 提供方。 |
|
||||
| `generative-language-api-key` | string[] | [] | 生成式语言API密钥列表。 |
|
||||
| `codex-api-key` | object | {} | Codex API密钥列表。 |
|
||||
@@ -330,6 +337,12 @@ auth-dir: "~/.cli-proxy-api"
|
||||
# 启用调试日志
|
||||
debug: false
|
||||
|
||||
# 为 true 时将应用日志写入滚动文件而不是 stdout
|
||||
logging-to-file: true
|
||||
|
||||
# 为 false 时禁用内存中的使用统计并直接丢弃所有数据
|
||||
usage-statistics-enabled: true
|
||||
|
||||
# 代理URL。支持socks5/http/https协议。例如:socks5://user:pass@192.168.1.1:1080/
|
||||
proxy-url: ""
|
||||
|
||||
@@ -348,15 +361,6 @@ gemini-web:
|
||||
code-mode: false # 启用代码模式
|
||||
max-chars-per-request: 1000000 # 单次请求最大字符数
|
||||
|
||||
# 请求鉴权提供方
|
||||
auth:
|
||||
providers:
|
||||
- name: "default"
|
||||
type: "config-api-key"
|
||||
api-keys:
|
||||
- "your-api-key-1"
|
||||
- "your-api-key-2"
|
||||
|
||||
# AIStduio Gemini API 的 API 密钥
|
||||
generative-language-api-key:
|
||||
- "AIzaSy...01"
|
||||
@@ -635,8 +639,10 @@ docker run --rm -p 8317:8317 -v /path/to/your/config.yaml:/CLIProxyAPI/config.ya
|
||||
|
||||
## SDK 文档
|
||||
|
||||
- 使用文档:`docs/sdk-usage_CN.md`(English: `docs/sdk-usage.md`)
|
||||
- 高级(执行器与翻译器):`docs/sdk-advanced_CN.md`(English: `docs/sdk-advanced.md`)
|
||||
- 使用文档:[docs/sdk-usage_CN.md](docs/sdk-usage_CN.md)
|
||||
- 高级(执行器与翻译器):[docs/sdk-advanced_CN.md](docs/sdk-advanced_CN.md)
|
||||
- 认证: [docs/sdk-access_CN.md](docs/sdk-access_CN.md)
|
||||
- 凭据加载/更新: [docs/sdk-watcher_CN.md](docs/sdk-watcher_CN.md)
|
||||
- 自定义 Provider 示例:`examples/custom-provider`
|
||||
|
||||
## 贡献
|
||||
|
||||
@@ -4,106 +4,31 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
configaccess "github.com/router-for-me/CLIProxyAPI/v6/internal/access/config_access"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/cmd"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
|
||||
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"gopkg.in/natefinch/lumberjack.v2"
|
||||
)
|
||||
|
||||
var (
|
||||
Version = "dev"
|
||||
Commit = "none"
|
||||
BuildDate = "unknown"
|
||||
logWriter *lumberjack.Logger
|
||||
ginInfoWriter *io.PipeWriter
|
||||
ginErrorWriter *io.PipeWriter
|
||||
Version = "dev"
|
||||
Commit = "none"
|
||||
BuildDate = "unknown"
|
||||
)
|
||||
|
||||
// LogFormatter defines a custom log format for logrus.
|
||||
// This formatter adds timestamp, log level, and source location information
|
||||
// to each log entry for better debugging and monitoring.
|
||||
type LogFormatter struct {
|
||||
}
|
||||
|
||||
// Format renders a single log entry with custom formatting.
|
||||
// It includes timestamp, log level, source file and line number, and the log message.
|
||||
func (m *LogFormatter) Format(entry *log.Entry) ([]byte, error) {
|
||||
var b *bytes.Buffer
|
||||
if entry.Buffer != nil {
|
||||
b = entry.Buffer
|
||||
} else {
|
||||
b = &bytes.Buffer{}
|
||||
}
|
||||
|
||||
timestamp := entry.Time.Format("2006-01-02 15:04:05")
|
||||
var newLog string
|
||||
// Ensure message doesn't carry trailing newlines; formatter appends one.
|
||||
msg := strings.TrimRight(entry.Message, "\r\n")
|
||||
// Customize the log format to include timestamp, level, caller file/line, and message.
|
||||
newLog = fmt.Sprintf("[%s] [%s] [%s:%d] %s\n", timestamp, entry.Level, filepath.Base(entry.Caller.File), entry.Caller.Line, msg)
|
||||
|
||||
b.WriteString(newLog)
|
||||
return b.Bytes(), nil
|
||||
}
|
||||
|
||||
// init initializes the logger configuration.
|
||||
// It sets up the custom log formatter, enables caller reporting,
|
||||
// and configures the log output destination.
|
||||
// init initializes the shared logger setup.
|
||||
func init() {
|
||||
logDir := "logs"
|
||||
if err := os.MkdirAll(logDir, 0755); err != nil {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "failed to create log directory: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
logWriter = &lumberjack.Logger{
|
||||
Filename: filepath.Join(logDir, "main.log"),
|
||||
MaxSize: 10,
|
||||
MaxBackups: 0,
|
||||
MaxAge: 0,
|
||||
Compress: false,
|
||||
}
|
||||
|
||||
log.SetOutput(logWriter)
|
||||
// Enable reporting the caller function's file and line number.
|
||||
log.SetReportCaller(true)
|
||||
// Set the custom log formatter.
|
||||
log.SetFormatter(&LogFormatter{})
|
||||
|
||||
ginInfoWriter = log.StandardLogger().Writer()
|
||||
gin.DefaultWriter = ginInfoWriter
|
||||
ginErrorWriter = log.StandardLogger().WriterLevel(log.ErrorLevel)
|
||||
gin.DefaultErrorWriter = ginErrorWriter
|
||||
gin.DebugPrintFunc = func(format string, values ...interface{}) {
|
||||
// Trim trailing newlines from Gin's formatted messages to avoid blank lines.
|
||||
// Gin's debug prints usually include a trailing "\n"; our formatter also appends one.
|
||||
// Removing it here ensures a single newline per entry.
|
||||
format = strings.TrimRight(format, "\r\n")
|
||||
log.StandardLogger().Infof(format, values...)
|
||||
}
|
||||
log.RegisterExitHandler(func() {
|
||||
if logWriter != nil {
|
||||
_ = logWriter.Close()
|
||||
}
|
||||
if ginInfoWriter != nil {
|
||||
_ = ginInfoWriter.Close()
|
||||
}
|
||||
if ginErrorWriter != nil {
|
||||
_ = ginErrorWriter.Close()
|
||||
}
|
||||
})
|
||||
logging.SetupBaseLogger()
|
||||
}
|
||||
|
||||
// main is the entry point of the application.
|
||||
@@ -111,7 +36,6 @@ func init() {
|
||||
// service based on the provided flags (login, codex-login, or server mode).
|
||||
func main() {
|
||||
fmt.Printf("CLIProxyAPI Version: %s, Commit: %s, BuiltAt: %s\n", Version, Commit, BuildDate)
|
||||
log.Infof("CLIProxyAPI Version: %s, Commit: %s, BuiltAt: %s", Version, Commit, BuildDate)
|
||||
|
||||
// Command-line flags to control the application's behavior.
|
||||
var login bool
|
||||
@@ -143,7 +67,7 @@ func main() {
|
||||
return
|
||||
}
|
||||
s := fmt.Sprintf(" -%s", f.Name)
|
||||
name, usage := flag.UnquoteUsage(f)
|
||||
name, unquoteUsage := flag.UnquoteUsage(f)
|
||||
if name != "" {
|
||||
s += " " + name
|
||||
}
|
||||
@@ -152,8 +76,8 @@ func main() {
|
||||
} else {
|
||||
s += "\n "
|
||||
}
|
||||
if usage != "" {
|
||||
s += usage
|
||||
if unquoteUsage != "" {
|
||||
s += unquoteUsage
|
||||
}
|
||||
if f.DefValue != "" && f.DefValue != "false" && f.DefValue != "0" {
|
||||
s += fmt.Sprintf(" (default %s)", f.DefValue)
|
||||
@@ -188,26 +112,21 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatalf("failed to load config: %v", err)
|
||||
}
|
||||
usage.SetStatisticsEnabled(cfg.UsageStatisticsEnabled)
|
||||
|
||||
if err = logging.ConfigureLogOutput(cfg.LoggingToFile); err != nil {
|
||||
log.Fatalf("failed to configure log output: %v", err)
|
||||
}
|
||||
|
||||
log.Infof("CLIProxyAPI Version: %s, Commit: %s, BuiltAt: %s", Version, Commit, BuildDate)
|
||||
|
||||
// Set the log level based on the configuration.
|
||||
util.SetLogLevel(cfg)
|
||||
|
||||
// Expand the tilde (~) in the auth directory path to the user's home directory.
|
||||
if strings.HasPrefix(cfg.AuthDir, "~") {
|
||||
home, errUserHomeDir := os.UserHomeDir()
|
||||
if errUserHomeDir != nil {
|
||||
log.Fatalf("failed to get home directory: %v", errUserHomeDir)
|
||||
}
|
||||
// Reconstruct the path by replacing the tilde with the user's home directory.
|
||||
remainder := strings.TrimPrefix(cfg.AuthDir, "~")
|
||||
remainder = strings.TrimLeft(remainder, "/\\")
|
||||
if remainder == "" {
|
||||
cfg.AuthDir = home
|
||||
} else {
|
||||
// Normalize any slash style in the remainder so Windows paths keep nested directories.
|
||||
normalized := strings.ReplaceAll(remainder, "\\", "/")
|
||||
cfg.AuthDir = filepath.Join(home, filepath.FromSlash(normalized))
|
||||
}
|
||||
if resolvedAuthDir, errResolveAuthDir := util.ResolveAuthDir(cfg.AuthDir); errResolveAuthDir != nil {
|
||||
log.Fatalf("failed to resolve auth directory: %v", errResolveAuthDir)
|
||||
} else {
|
||||
cfg.AuthDir = resolvedAuthDir
|
||||
}
|
||||
|
||||
// Create login options to be used in authentication flows.
|
||||
@@ -218,6 +137,9 @@ func main() {
|
||||
// Register the shared token store once so all components use the same persistence backend.
|
||||
sdkAuth.RegisterTokenStore(sdkAuth.NewFileTokenStore())
|
||||
|
||||
// Register built-in access providers before constructing services.
|
||||
configaccess.Register()
|
||||
|
||||
// Handle different command modes based on the provided flags.
|
||||
|
||||
if login {
|
||||
|
||||
@@ -15,9 +15,20 @@ remote-management:
|
||||
# Authentication directory (supports ~ for home directory)
|
||||
auth-dir: "~/.cli-proxy-api"
|
||||
|
||||
# API keys for authentication
|
||||
api-keys:
|
||||
- "your-api-key-1"
|
||||
- "your-api-key-2"
|
||||
|
||||
# Enable debug logging
|
||||
debug: false
|
||||
|
||||
# When true, write application logs to rotating files instead of stdout
|
||||
logging-to-file: false
|
||||
|
||||
# When false, disable in-memory usage statistics aggregation
|
||||
usage-statistics-enabled: false
|
||||
|
||||
# Proxy URL. Supports socks5/http/https protocols. Example: socks5://user:pass@192.168.1.1:1080/
|
||||
proxy-url: ""
|
||||
|
||||
@@ -29,58 +40,49 @@ quota-exceeded:
|
||||
switch-project: true # Whether to automatically switch to another project when a quota is exceeded
|
||||
switch-preview-model: true # Whether to automatically switch to a preview model when a quota is exceeded
|
||||
|
||||
# Request authentication providers
|
||||
auth:
|
||||
providers:
|
||||
- name: "default"
|
||||
type: "config-api-key"
|
||||
api-keys:
|
||||
- "your-api-key-1"
|
||||
- "your-api-key-2"
|
||||
|
||||
# API keys for official Generative Language API
|
||||
generative-language-api-key:
|
||||
- "AIzaSy...01"
|
||||
- "AIzaSy...02"
|
||||
- "AIzaSy...03"
|
||||
- "AIzaSy...04"
|
||||
#generative-language-api-key:
|
||||
# - "AIzaSy...01"
|
||||
# - "AIzaSy...02"
|
||||
# - "AIzaSy...03"
|
||||
# - "AIzaSy...04"
|
||||
|
||||
# Codex API keys
|
||||
codex-api-key:
|
||||
- api-key: "sk-atSM..."
|
||||
base-url: "https://www.example.com" # use the custom codex API endpoint
|
||||
#codex-api-key:
|
||||
# - api-key: "sk-atSM..."
|
||||
# base-url: "https://www.example.com" # use the custom codex API endpoint
|
||||
|
||||
# Claude API keys
|
||||
claude-api-key:
|
||||
- api-key: "sk-atSM..." # use the official claude API key, no need to set the base url
|
||||
- api-key: "sk-atSM..."
|
||||
base-url: "https://www.example.com" # use the custom claude API endpoint
|
||||
#claude-api-key:
|
||||
# - api-key: "sk-atSM..." # use the official claude API key, no need to set the base url
|
||||
# - api-key: "sk-atSM..."
|
||||
# base-url: "https://www.example.com" # use the custom claude API endpoint
|
||||
|
||||
# OpenAI compatibility providers
|
||||
openai-compatibility:
|
||||
- name: "openrouter" # The name of the provider; it will be used in the user agent and other places.
|
||||
base-url: "https://openrouter.ai/api/v1" # The base URL of the provider.
|
||||
api-keys: # The API keys for the provider. Add multiple keys if needed. Omit if unauthenticated access is allowed.
|
||||
- "sk-or-v1-...b780"
|
||||
- "sk-or-v1-...b781"
|
||||
models: # The models supported by the provider.
|
||||
- name: "moonshotai/kimi-k2:free" # The actual model name.
|
||||
alias: "kimi-k2" # The alias used in the API.
|
||||
#openai-compatibility:
|
||||
# - name: "openrouter" # The name of the provider; it will be used in the user agent and other places.
|
||||
# base-url: "https://openrouter.ai/api/v1" # The base URL of the provider.
|
||||
# api-keys: # The API keys for the provider. Add multiple keys if needed. Omit if unauthenticated access is allowed.
|
||||
# - "sk-or-v1-...b780"
|
||||
# - "sk-or-v1-...b781"
|
||||
# models: # The models supported by the provider.
|
||||
# - name: "moonshotai/kimi-k2:free" # The actual model name.
|
||||
# alias: "kimi-k2" # The alias used in the API.
|
||||
|
||||
# Gemini Web settings
|
||||
gemini-web:
|
||||
# Conversation reuse: set to true to enable (default), false to disable.
|
||||
context: true
|
||||
# Maximum characters per single request to Gemini Web. Requests exceeding this
|
||||
# size split into chunks. Only the last chunk carries files and yields the final answer.
|
||||
max-chars-per-request: 1000000
|
||||
# Disable the short continuation hint appended to intermediate chunks
|
||||
# when splitting long prompts. Default is false (hint enabled by default).
|
||||
disable-continuation-hint: false
|
||||
# Code mode:
|
||||
# - true: enable XML wrapping hint and attach the coding-partner Gem.
|
||||
# Thought merging (<think> into visible content) applies to STREAMING only;
|
||||
# non-stream responses keep reasoning/thought parts separate for clients
|
||||
# that expect explicit reasoning fields.
|
||||
# - false: disable XML hint and keep <think> separate
|
||||
code-mode: false
|
||||
#gemini-web:
|
||||
# # Conversation reuse: set to true to enable (default), false to disable.
|
||||
# context: true
|
||||
# # Maximum characters per single request to Gemini Web. Requests exceeding this
|
||||
# # size split into chunks. Only the last chunk carries files and yields the final answer.
|
||||
# max-chars-per-request: 1000000
|
||||
# # Disable the short continuation hint appended to intermediate chunks
|
||||
# # when splitting long prompts. Default is false (hint enabled by default).
|
||||
# disable-continuation-hint: false
|
||||
# # Code mode:
|
||||
# # - true: enable XML wrapping hint and attach the coding-partner Gem.
|
||||
# # Thought merging (<think> into visible content) applies to STREAMING only;
|
||||
# # non-stream responses keep reasoning/thought parts separate for clients
|
||||
# # that expect explicit reasoning fields.
|
||||
# # - false: disable XML hint and keep <think> separate
|
||||
# code-mode: false
|
||||
|
||||
@@ -160,11 +160,7 @@ func main() {
|
||||
if dirSetter, ok := tokenStore.(interface{ SetBaseDir(string) }); ok {
|
||||
dirSetter.SetBaseDir(cfg.AuthDir)
|
||||
}
|
||||
store, ok := tokenStore.(coreauth.Store)
|
||||
if !ok {
|
||||
panic("token store does not implement coreauth.Store")
|
||||
}
|
||||
core := coreauth.NewManager(store, nil, nil)
|
||||
core := coreauth.NewManager(tokenStore, nil, nil)
|
||||
core.RegisterExecutor(MyExecutor{})
|
||||
|
||||
hooks := cliproxy.Hooks{
|
||||
|
||||
@@ -1,27 +1,33 @@
|
||||
package configapikey
|
||||
package configaccess
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
|
||||
sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
||||
)
|
||||
|
||||
var registerOnce sync.Once
|
||||
|
||||
// Register ensures the config-access provider is available to the access manager.
|
||||
func Register() {
|
||||
registerOnce.Do(func() {
|
||||
sdkaccess.RegisterProvider(sdkconfig.AccessProviderTypeConfigAPIKey, newProvider)
|
||||
})
|
||||
}
|
||||
|
||||
type provider struct {
|
||||
name string
|
||||
keys map[string]struct{}
|
||||
}
|
||||
|
||||
func init() {
|
||||
sdkaccess.RegisterProvider(config.AccessProviderTypeConfigAPIKey, newProvider)
|
||||
}
|
||||
|
||||
func newProvider(cfg *config.AccessProvider, _ *config.Config) (sdkaccess.Provider, error) {
|
||||
func newProvider(cfg *sdkconfig.AccessProvider, _ *sdkconfig.SDKConfig) (sdkaccess.Provider, error) {
|
||||
name := cfg.Name
|
||||
if name == "" {
|
||||
name = config.DefaultAccessProviderName
|
||||
name = sdkconfig.DefaultAccessProviderName
|
||||
}
|
||||
keys := make(map[string]struct{}, len(cfg.APIKeys))
|
||||
for _, key := range cfg.APIKeys {
|
||||
@@ -35,7 +41,7 @@ func newProvider(cfg *config.AccessProvider, _ *config.Config) (sdkaccess.Provid
|
||||
|
||||
func (p *provider) Identifier() string {
|
||||
if p == nil || p.name == "" {
|
||||
return config.DefaultAccessProviderName
|
||||
return sdkconfig.DefaultAccessProviderName
|
||||
}
|
||||
return p.name
|
||||
}
|
||||
280
internal/access/reconcile.go
Normal file
280
internal/access/reconcile.go
Normal file
@@ -0,0 +1,280 @@
|
||||
package access
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
|
||||
sdkConfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// ReconcileProviders builds the desired provider list by reusing existing providers when possible
|
||||
// and creating or removing providers only when their configuration changed. It returns the final
|
||||
// ordered provider slice along with the identifiers of providers that were added, updated, or
|
||||
// removed compared to the previous configuration.
|
||||
func ReconcileProviders(oldCfg, newCfg *config.Config, existing []sdkaccess.Provider) (result []sdkaccess.Provider, added, updated, removed []string, err error) {
|
||||
if newCfg == nil {
|
||||
return nil, nil, nil, nil, nil
|
||||
}
|
||||
|
||||
existingMap := make(map[string]sdkaccess.Provider, len(existing))
|
||||
for _, provider := range existing {
|
||||
if provider == nil {
|
||||
continue
|
||||
}
|
||||
existingMap[provider.Identifier()] = provider
|
||||
}
|
||||
|
||||
oldCfgMap := accessProviderMap(oldCfg)
|
||||
newEntries := collectProviderEntries(newCfg)
|
||||
|
||||
result = make([]sdkaccess.Provider, 0, len(newEntries))
|
||||
finalIDs := make(map[string]struct{}, len(newEntries))
|
||||
|
||||
isInlineProvider := func(id string) bool {
|
||||
return strings.EqualFold(id, sdkConfig.DefaultAccessProviderName)
|
||||
}
|
||||
appendChange := func(list *[]string, id string) {
|
||||
if isInlineProvider(id) {
|
||||
return
|
||||
}
|
||||
*list = append(*list, id)
|
||||
}
|
||||
|
||||
for _, providerCfg := range newEntries {
|
||||
key := providerIdentifier(providerCfg)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if oldCfgProvider, ok := oldCfgMap[key]; ok {
|
||||
isAliased := oldCfgProvider == providerCfg
|
||||
if !isAliased && providerConfigEqual(oldCfgProvider, providerCfg) {
|
||||
if existingProvider, okExisting := existingMap[key]; okExisting {
|
||||
result = append(result, existingProvider)
|
||||
finalIDs[key] = struct{}{}
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
provider, buildErr := sdkaccess.BuildProvider(providerCfg, &newCfg.SDKConfig)
|
||||
if buildErr != nil {
|
||||
return nil, nil, nil, nil, buildErr
|
||||
}
|
||||
if _, ok := oldCfgMap[key]; ok {
|
||||
if _, existed := existingMap[key]; existed {
|
||||
appendChange(&updated, key)
|
||||
} else {
|
||||
appendChange(&added, key)
|
||||
}
|
||||
} else {
|
||||
appendChange(&added, key)
|
||||
}
|
||||
result = append(result, provider)
|
||||
finalIDs[key] = struct{}{}
|
||||
}
|
||||
|
||||
if len(result) == 0 && len(newCfg.APIKeys) > 0 {
|
||||
sdkConfig.SyncInlineAPIKeys(&newCfg.SDKConfig, newCfg.APIKeys)
|
||||
if providerCfg := newCfg.ConfigAPIKeyProvider(); providerCfg != nil {
|
||||
key := providerIdentifier(providerCfg)
|
||||
if key != "" {
|
||||
if oldCfgProvider, ok := oldCfgMap[key]; ok {
|
||||
isAliased := oldCfgProvider == providerCfg
|
||||
if !isAliased && providerConfigEqual(oldCfgProvider, providerCfg) {
|
||||
if existingProvider, okExisting := existingMap[key]; okExisting {
|
||||
result = append(result, existingProvider)
|
||||
} else {
|
||||
provider, buildErr := sdkaccess.BuildProvider(providerCfg, &newCfg.SDKConfig)
|
||||
if buildErr != nil {
|
||||
return nil, nil, nil, nil, buildErr
|
||||
}
|
||||
if _, existed := existingMap[key]; existed {
|
||||
appendChange(&updated, key)
|
||||
} else {
|
||||
appendChange(&added, key)
|
||||
}
|
||||
result = append(result, provider)
|
||||
}
|
||||
} else {
|
||||
provider, buildErr := sdkaccess.BuildProvider(providerCfg, &newCfg.SDKConfig)
|
||||
if buildErr != nil {
|
||||
return nil, nil, nil, nil, buildErr
|
||||
}
|
||||
if _, existed := existingMap[key]; existed {
|
||||
appendChange(&updated, key)
|
||||
} else {
|
||||
appendChange(&added, key)
|
||||
}
|
||||
result = append(result, provider)
|
||||
}
|
||||
} else {
|
||||
provider, buildErr := sdkaccess.BuildProvider(providerCfg, &newCfg.SDKConfig)
|
||||
if buildErr != nil {
|
||||
return nil, nil, nil, nil, buildErr
|
||||
}
|
||||
appendChange(&added, key)
|
||||
result = append(result, provider)
|
||||
}
|
||||
finalIDs[key] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removedSet := make(map[string]struct{})
|
||||
for id := range existingMap {
|
||||
if _, ok := finalIDs[id]; !ok {
|
||||
if isInlineProvider(id) {
|
||||
continue
|
||||
}
|
||||
removedSet[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
removed = make([]string, 0, len(removedSet))
|
||||
for id := range removedSet {
|
||||
removed = append(removed, id)
|
||||
}
|
||||
|
||||
sort.Strings(added)
|
||||
sort.Strings(updated)
|
||||
sort.Strings(removed)
|
||||
|
||||
return result, added, updated, removed, nil
|
||||
}
|
||||
|
||||
// ApplyAccessProviders reconciles the configured access providers against the
|
||||
// currently registered providers and updates the manager. It logs a concise
|
||||
// summary of the detected changes and returns whether any provider changed.
|
||||
func ApplyAccessProviders(manager *sdkaccess.Manager, oldCfg, newCfg *config.Config) (bool, error) {
|
||||
if manager == nil || newCfg == nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
existing := manager.Providers()
|
||||
providers, added, updated, removed, err := ReconcileProviders(oldCfg, newCfg, existing)
|
||||
if err != nil {
|
||||
log.Errorf("failed to reconcile request auth providers: %v", err)
|
||||
return false, fmt.Errorf("reconciling access providers: %w", err)
|
||||
}
|
||||
|
||||
manager.SetProviders(providers)
|
||||
|
||||
if len(added)+len(updated)+len(removed) > 0 {
|
||||
log.Debugf("auth providers reconciled (added=%d updated=%d removed=%d)", len(added), len(updated), len(removed))
|
||||
log.Debugf("auth providers changes details - added=%v updated=%v removed=%v", added, updated, removed)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
log.Debug("auth providers unchanged after config update")
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func accessProviderMap(cfg *config.Config) map[string]*sdkConfig.AccessProvider {
|
||||
result := make(map[string]*sdkConfig.AccessProvider)
|
||||
if cfg == nil {
|
||||
return result
|
||||
}
|
||||
for i := range cfg.Access.Providers {
|
||||
providerCfg := &cfg.Access.Providers[i]
|
||||
if providerCfg.Type == "" {
|
||||
continue
|
||||
}
|
||||
key := providerIdentifier(providerCfg)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
result[key] = providerCfg
|
||||
}
|
||||
if len(result) == 0 && len(cfg.APIKeys) > 0 {
|
||||
if provider := cfg.ConfigAPIKeyProvider(); provider != nil {
|
||||
if key := providerIdentifier(provider); key != "" {
|
||||
result[key] = provider
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func collectProviderEntries(cfg *config.Config) []*sdkConfig.AccessProvider {
|
||||
entries := make([]*sdkConfig.AccessProvider, 0, len(cfg.Access.Providers))
|
||||
for i := range cfg.Access.Providers {
|
||||
providerCfg := &cfg.Access.Providers[i]
|
||||
if providerCfg.Type == "" {
|
||||
continue
|
||||
}
|
||||
if key := providerIdentifier(providerCfg); key != "" {
|
||||
entries = append(entries, providerCfg)
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func providerIdentifier(provider *sdkConfig.AccessProvider) string {
|
||||
if provider == nil {
|
||||
return ""
|
||||
}
|
||||
if name := strings.TrimSpace(provider.Name); name != "" {
|
||||
return name
|
||||
}
|
||||
typ := strings.TrimSpace(provider.Type)
|
||||
if typ == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.EqualFold(typ, sdkConfig.AccessProviderTypeConfigAPIKey) {
|
||||
return sdkConfig.DefaultAccessProviderName
|
||||
}
|
||||
return typ
|
||||
}
|
||||
|
||||
func providerConfigEqual(a, b *sdkConfig.AccessProvider) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == nil && b == nil
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSpace(a.Type), strings.TrimSpace(b.Type)) {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(a.SDK) != strings.TrimSpace(b.SDK) {
|
||||
return false
|
||||
}
|
||||
if !stringSetEqual(a.APIKeys, b.APIKeys) {
|
||||
return false
|
||||
}
|
||||
if len(a.Config) != len(b.Config) {
|
||||
return false
|
||||
}
|
||||
if len(a.Config) > 0 && !reflect.DeepEqual(a.Config, b.Config) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func stringSetEqual(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
if len(a) == 0 {
|
||||
return true
|
||||
}
|
||||
seen := make(map[string]int, len(a))
|
||||
for _, val := range a {
|
||||
seen[val]++
|
||||
}
|
||||
for _, val := range b {
|
||||
count := seen[val]
|
||||
if count == 0 {
|
||||
return false
|
||||
}
|
||||
if count == 1 {
|
||||
delete(seen, val)
|
||||
} else {
|
||||
seen[val] = count - 1
|
||||
}
|
||||
}
|
||||
return len(seen) == 0
|
||||
}
|
||||
@@ -344,7 +344,7 @@ func (h *Handler) disableAuth(ctx context.Context, id string) {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) saveTokenRecord(ctx context.Context, record *sdkAuth.TokenRecord) (string, error) {
|
||||
func (h *Handler) saveTokenRecord(ctx context.Context, record *coreauth.Auth) (string, error) {
|
||||
if record == nil {
|
||||
return "", fmt.Errorf("token record is nil")
|
||||
}
|
||||
@@ -353,13 +353,18 @@ func (h *Handler) saveTokenRecord(ctx context.Context, record *sdkAuth.TokenReco
|
||||
store = sdkAuth.GetTokenStore()
|
||||
h.tokenStore = store
|
||||
}
|
||||
return store.Save(ctx, h.cfg, record)
|
||||
if h.cfg != nil {
|
||||
if dirSetter, ok := store.(interface{ SetBaseDir(string) }); ok {
|
||||
dirSetter.SetBaseDir(h.cfg.AuthDir)
|
||||
}
|
||||
}
|
||||
return store.Save(ctx, record)
|
||||
}
|
||||
|
||||
func (h *Handler) RequestAnthropicToken(c *gin.Context) {
|
||||
ctx := context.Background()
|
||||
|
||||
log.Info("Initializing Claude authentication...")
|
||||
fmt.Println("Initializing Claude authentication...")
|
||||
|
||||
// Generate PKCE codes
|
||||
pkceCodes, err := claude.GeneratePKCECodes()
|
||||
@@ -407,7 +412,7 @@ func (h *Handler) RequestAnthropicToken(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
log.Info("Waiting for authentication callback...")
|
||||
fmt.Println("Waiting for authentication callback...")
|
||||
// Wait up to 5 minutes
|
||||
resultMap, errWait := waitForFile(waitFile, 5*time.Minute)
|
||||
if errWait != nil {
|
||||
@@ -449,7 +454,7 @@ func (h *Handler) RequestAnthropicToken(c *gin.Context) {
|
||||
}
|
||||
bodyJSON, _ := json.Marshal(bodyMap)
|
||||
|
||||
httpClient := util.SetProxy(h.cfg, &http.Client{})
|
||||
httpClient := util.SetProxy(&h.cfg.SDKConfig, &http.Client{})
|
||||
req, _ := http.NewRequestWithContext(ctx, "POST", "https://console.anthropic.com/v1/oauth/token", strings.NewReader(string(bodyJSON)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
@@ -496,11 +501,12 @@ func (h *Handler) RequestAnthropicToken(c *gin.Context) {
|
||||
|
||||
// Create token storage
|
||||
tokenStorage := anthropicAuth.CreateTokenStorage(bundle)
|
||||
record := &sdkAuth.TokenRecord{
|
||||
record := &coreauth.Auth{
|
||||
ID: fmt.Sprintf("claude-%s.json", tokenStorage.Email),
|
||||
Provider: "claude",
|
||||
FileName: fmt.Sprintf("claude-%s.json", tokenStorage.Email),
|
||||
Storage: tokenStorage,
|
||||
Metadata: map[string]string{"email": tokenStorage.Email},
|
||||
Metadata: map[string]any{"email": tokenStorage.Email},
|
||||
}
|
||||
savedPath, errSave := h.saveTokenRecord(ctx, record)
|
||||
if errSave != nil {
|
||||
@@ -509,11 +515,11 @@ func (h *Handler) RequestAnthropicToken(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
log.Infof("Authentication successful! Token saved to %s", savedPath)
|
||||
fmt.Printf("Authentication successful! Token saved to %s\n", savedPath)
|
||||
if bundle.APIKey != "" {
|
||||
log.Info("API key obtained and saved")
|
||||
fmt.Println("API key obtained and saved")
|
||||
}
|
||||
log.Info("You can now use Claude services through this CLI")
|
||||
fmt.Println("You can now use Claude services through this CLI")
|
||||
delete(oauthStatus, state)
|
||||
}()
|
||||
|
||||
@@ -527,7 +533,7 @@ func (h *Handler) RequestGeminiCLIToken(c *gin.Context) {
|
||||
// Optional project ID from query
|
||||
projectID := c.Query("project_id")
|
||||
|
||||
log.Info("Initializing Google authentication...")
|
||||
fmt.Println("Initializing Google authentication...")
|
||||
|
||||
// OAuth2 configuration (mirrors internal/auth/gemini)
|
||||
conf := &oauth2.Config{
|
||||
@@ -549,7 +555,7 @@ func (h *Handler) RequestGeminiCLIToken(c *gin.Context) {
|
||||
go func() {
|
||||
// Wait for callback file written by server route
|
||||
waitFile := filepath.Join(h.cfg.AuthDir, fmt.Sprintf(".oauth-gemini-%s.oauth", state))
|
||||
log.Info("Waiting for authentication callback...")
|
||||
fmt.Println("Waiting for authentication callback...")
|
||||
deadline := time.Now().Add(5 * time.Minute)
|
||||
var authCode string
|
||||
for {
|
||||
@@ -618,9 +624,9 @@ func (h *Handler) RequestGeminiCLIToken(c *gin.Context) {
|
||||
|
||||
email := gjson.GetBytes(bodyBytes, "email").String()
|
||||
if email != "" {
|
||||
log.Infof("Authenticated user email: %s", email)
|
||||
fmt.Printf("Authenticated user email: %s\n", email)
|
||||
} else {
|
||||
log.Info("Failed to get user email from token")
|
||||
fmt.Println("Failed to get user email from token")
|
||||
oauthStatus[state] = "Failed to get user email from token"
|
||||
}
|
||||
|
||||
@@ -657,13 +663,14 @@ func (h *Handler) RequestGeminiCLIToken(c *gin.Context) {
|
||||
oauthStatus[state] = "Failed to get authenticated client"
|
||||
return
|
||||
}
|
||||
log.Info("Authentication successful.")
|
||||
fmt.Println("Authentication successful.")
|
||||
|
||||
record := &sdkAuth.TokenRecord{
|
||||
record := &coreauth.Auth{
|
||||
ID: fmt.Sprintf("gemini-%s.json", ts.Email),
|
||||
Provider: "gemini",
|
||||
FileName: fmt.Sprintf("gemini-%s.json", ts.Email),
|
||||
Storage: &ts,
|
||||
Metadata: map[string]string{
|
||||
Metadata: map[string]any{
|
||||
"email": ts.Email,
|
||||
"project_id": ts.ProjectID,
|
||||
},
|
||||
@@ -676,7 +683,7 @@ func (h *Handler) RequestGeminiCLIToken(c *gin.Context) {
|
||||
}
|
||||
|
||||
delete(oauthStatus, state)
|
||||
log.Infof("You can now use Gemini CLI services through this CLI; token saved to %s", savedPath)
|
||||
fmt.Printf("You can now use Gemini CLI services through this CLI; token saved to %s\n", savedPath)
|
||||
}()
|
||||
|
||||
oauthStatus[state] = ""
|
||||
@@ -689,6 +696,7 @@ func (h *Handler) CreateGeminiWebToken(c *gin.Context) {
|
||||
var payload struct {
|
||||
Secure1PSID string `json:"secure_1psid"`
|
||||
Secure1PSIDTS string `json:"secure_1psidts"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&payload); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
||||
@@ -696,6 +704,7 @@ func (h *Handler) CreateGeminiWebToken(c *gin.Context) {
|
||||
}
|
||||
payload.Secure1PSID = strings.TrimSpace(payload.Secure1PSID)
|
||||
payload.Secure1PSIDTS = strings.TrimSpace(payload.Secure1PSIDTS)
|
||||
payload.Label = strings.TrimSpace(payload.Label)
|
||||
if payload.Secure1PSID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "secure_1psid is required"})
|
||||
return
|
||||
@@ -704,6 +713,10 @@ func (h *Handler) CreateGeminiWebToken(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "secure_1psidts is required"})
|
||||
return
|
||||
}
|
||||
if payload.Label == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "label is required"})
|
||||
return
|
||||
}
|
||||
|
||||
sha := sha256.New()
|
||||
sha.Write([]byte(payload.Secure1PSID))
|
||||
@@ -713,11 +726,13 @@ func (h *Handler) CreateGeminiWebToken(c *gin.Context) {
|
||||
tokenStorage := &geminiAuth.GeminiWebTokenStorage{
|
||||
Secure1PSID: payload.Secure1PSID,
|
||||
Secure1PSIDTS: payload.Secure1PSIDTS,
|
||||
Label: payload.Label,
|
||||
}
|
||||
// Provide a stable label (gemini-web-<hash>) for logging and identification
|
||||
tokenStorage.Label = strings.TrimSuffix(fileName, ".json")
|
||||
|
||||
record := &sdkAuth.TokenRecord{
|
||||
record := &coreauth.Auth{
|
||||
ID: fileName,
|
||||
Provider: "gemini-web",
|
||||
FileName: fileName,
|
||||
Storage: tokenStorage,
|
||||
@@ -730,14 +745,14 @@ func (h *Handler) CreateGeminiWebToken(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
log.Infof("Successfully saved Gemini Web token to: %s", savedPath)
|
||||
fmt.Printf("Successfully saved Gemini Web token to: %s\n", savedPath)
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "file": filepath.Base(savedPath)})
|
||||
}
|
||||
|
||||
func (h *Handler) RequestCodexToken(c *gin.Context) {
|
||||
ctx := context.Background()
|
||||
|
||||
log.Info("Initializing Codex authentication...")
|
||||
fmt.Println("Initializing Codex authentication...")
|
||||
|
||||
// Generate PKCE codes
|
||||
pkceCodes, err := codex.GeneratePKCECodes()
|
||||
@@ -811,7 +826,7 @@ func (h *Handler) RequestCodexToken(c *gin.Context) {
|
||||
"redirect_uri": {"http://localhost:1455/auth/callback"},
|
||||
"code_verifier": {pkceCodes.CodeVerifier},
|
||||
}
|
||||
httpClient := util.SetProxy(h.cfg, &http.Client{})
|
||||
httpClient := util.SetProxy(&h.cfg.SDKConfig, &http.Client{})
|
||||
req, _ := http.NewRequestWithContext(ctx, "POST", "https://auth.openai.com/oauth/token", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
@@ -862,11 +877,12 @@ func (h *Handler) RequestCodexToken(c *gin.Context) {
|
||||
|
||||
// Create token storage and persist
|
||||
tokenStorage := openaiAuth.CreateTokenStorage(bundle)
|
||||
record := &sdkAuth.TokenRecord{
|
||||
record := &coreauth.Auth{
|
||||
ID: fmt.Sprintf("codex-%s.json", tokenStorage.Email),
|
||||
Provider: "codex",
|
||||
FileName: fmt.Sprintf("codex-%s.json", tokenStorage.Email),
|
||||
Storage: tokenStorage,
|
||||
Metadata: map[string]string{
|
||||
Metadata: map[string]any{
|
||||
"email": tokenStorage.Email,
|
||||
"account_id": tokenStorage.AccountID,
|
||||
},
|
||||
@@ -877,11 +893,11 @@ func (h *Handler) RequestCodexToken(c *gin.Context) {
|
||||
log.Fatalf("Failed to save authentication tokens: %v", errSave)
|
||||
return
|
||||
}
|
||||
log.Infof("Authentication successful! Token saved to %s", savedPath)
|
||||
fmt.Printf("Authentication successful! Token saved to %s\n", savedPath)
|
||||
if bundle.APIKey != "" {
|
||||
log.Info("API key obtained and saved")
|
||||
fmt.Println("API key obtained and saved")
|
||||
}
|
||||
log.Info("You can now use Codex services through this CLI")
|
||||
fmt.Println("You can now use Codex services through this CLI")
|
||||
delete(oauthStatus, state)
|
||||
}()
|
||||
|
||||
@@ -892,7 +908,7 @@ func (h *Handler) RequestCodexToken(c *gin.Context) {
|
||||
func (h *Handler) RequestQwenToken(c *gin.Context) {
|
||||
ctx := context.Background()
|
||||
|
||||
log.Info("Initializing Qwen authentication...")
|
||||
fmt.Println("Initializing Qwen authentication...")
|
||||
|
||||
state := fmt.Sprintf("gem-%d", time.Now().UnixNano())
|
||||
// Initialize Qwen auth service
|
||||
@@ -907,7 +923,7 @@ func (h *Handler) RequestQwenToken(c *gin.Context) {
|
||||
authURL := deviceFlow.VerificationURIComplete
|
||||
|
||||
go func() {
|
||||
log.Info("Waiting for authentication...")
|
||||
fmt.Println("Waiting for authentication...")
|
||||
tokenData, errPollForToken := qwenAuth.PollForToken(deviceFlow.DeviceCode, deviceFlow.CodeVerifier)
|
||||
if errPollForToken != nil {
|
||||
oauthStatus[state] = "Authentication failed"
|
||||
@@ -919,11 +935,12 @@ func (h *Handler) RequestQwenToken(c *gin.Context) {
|
||||
tokenStorage := qwenAuth.CreateTokenStorage(tokenData)
|
||||
|
||||
tokenStorage.Email = fmt.Sprintf("qwen-%d", time.Now().UnixMilli())
|
||||
record := &sdkAuth.TokenRecord{
|
||||
record := &coreauth.Auth{
|
||||
ID: fmt.Sprintf("qwen-%s.json", tokenStorage.Email),
|
||||
Provider: "qwen",
|
||||
FileName: fmt.Sprintf("qwen-%s.json", tokenStorage.Email),
|
||||
Storage: tokenStorage,
|
||||
Metadata: map[string]string{"email": tokenStorage.Email},
|
||||
Metadata: map[string]any{"email": tokenStorage.Email},
|
||||
}
|
||||
savedPath, errSave := h.saveTokenRecord(ctx, record)
|
||||
if errSave != nil {
|
||||
@@ -932,8 +949,8 @@ func (h *Handler) RequestQwenToken(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
log.Infof("Authentication successful! Token saved to %s", savedPath)
|
||||
log.Info("You can now use Qwen services through this CLI")
|
||||
fmt.Printf("Authentication successful! Token saved to %s\n", savedPath)
|
||||
fmt.Println("You can now use Qwen services through this CLI")
|
||||
delete(oauthStatus, state)
|
||||
}()
|
||||
|
||||
|
||||
@@ -12,6 +12,22 @@ func (h *Handler) GetConfig(c *gin.Context) {
|
||||
func (h *Handler) GetDebug(c *gin.Context) { c.JSON(200, gin.H{"debug": h.cfg.Debug}) }
|
||||
func (h *Handler) PutDebug(c *gin.Context) { h.updateBoolField(c, func(v bool) { h.cfg.Debug = v }) }
|
||||
|
||||
// UsageStatisticsEnabled
|
||||
func (h *Handler) GetUsageStatisticsEnabled(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"usage-statistics-enabled": h.cfg.UsageStatisticsEnabled})
|
||||
}
|
||||
func (h *Handler) PutUsageStatisticsEnabled(c *gin.Context) {
|
||||
h.updateBoolField(c, func(v bool) { h.cfg.UsageStatisticsEnabled = v })
|
||||
}
|
||||
|
||||
// UsageStatisticsEnabled
|
||||
func (h *Handler) GetLoggingToFile(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"logging-to-file": h.cfg.LoggingToFile})
|
||||
}
|
||||
func (h *Handler) PutLoggingToFile(c *gin.Context) {
|
||||
h.updateBoolField(c, func(v bool) { h.cfg.LoggingToFile = v })
|
||||
}
|
||||
|
||||
// Request log
|
||||
func (h *Handler) GetRequestLog(c *gin.Context) { c.JSON(200, gin.H{"request-log": h.cfg.RequestLog}) }
|
||||
func (h *Handler) PutRequestLog(c *gin.Context) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
sdkConfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
||||
)
|
||||
|
||||
// Generic helpers for list[string]
|
||||
@@ -106,13 +107,13 @@ func (h *Handler) deleteFromStringList(c *gin.Context, target *[]string, after f
|
||||
// api-keys
|
||||
func (h *Handler) GetAPIKeys(c *gin.Context) { c.JSON(200, gin.H{"api-keys": h.cfg.APIKeys}) }
|
||||
func (h *Handler) PutAPIKeys(c *gin.Context) {
|
||||
h.putStringList(c, func(v []string) { config.SyncInlineAPIKeys(h.cfg, v) }, nil)
|
||||
h.putStringList(c, func(v []string) { sdkConfig.SyncInlineAPIKeys(&h.cfg.SDKConfig, v) }, nil)
|
||||
}
|
||||
func (h *Handler) PatchAPIKeys(c *gin.Context) {
|
||||
h.patchStringList(c, &h.cfg.APIKeys, func() { config.SyncInlineAPIKeys(h.cfg, h.cfg.APIKeys) })
|
||||
h.patchStringList(c, &h.cfg.APIKeys, func() { sdkConfig.SyncInlineAPIKeys(&h.cfg.SDKConfig, h.cfg.APIKeys) })
|
||||
}
|
||||
func (h *Handler) DeleteAPIKeys(c *gin.Context) {
|
||||
h.deleteFromStringList(c, &h.cfg.APIKeys, func() { config.SyncInlineAPIKeys(h.cfg, h.cfg.APIKeys) })
|
||||
h.deleteFromStringList(c, &h.cfg.APIKeys, func() { sdkConfig.SyncInlineAPIKeys(&h.cfg.SDKConfig, h.cfg.APIKeys) })
|
||||
}
|
||||
|
||||
// generative-language-api-key
|
||||
|
||||
@@ -33,7 +33,7 @@ type Handler struct {
|
||||
failedAttempts map[string]*attemptInfo // keyed by client IP
|
||||
authManager *coreauth.Manager
|
||||
usageStats *usage.RequestStatistics
|
||||
tokenStore sdkAuth.TokenStore
|
||||
tokenStore coreauth.Store
|
||||
|
||||
localPassword string
|
||||
}
|
||||
|
||||
@@ -6,24 +6,28 @@ package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/api/handlers"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/api/handlers/claude"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/api/handlers/gemini"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/access"
|
||||
managementHandlers "github.com/router-for-me/CLIProxyAPI/v6/internal/api/handlers/management"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/api/handlers/openai"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/api/middleware"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers/claude"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers/gemini"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers/openai"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
@@ -34,6 +38,9 @@ type serverOptionConfig struct {
|
||||
routerConfigurator func(*gin.Engine, *handlers.BaseAPIHandler, *config.Config)
|
||||
requestLoggerFactory func(*config.Config, string) logging.RequestLogger
|
||||
localPassword string
|
||||
keepAliveEnabled bool
|
||||
keepAliveTimeout time.Duration
|
||||
keepAliveOnTimeout func()
|
||||
}
|
||||
|
||||
// ServerOption customises HTTP server construction.
|
||||
@@ -71,6 +78,18 @@ func WithLocalManagementPassword(password string) ServerOption {
|
||||
}
|
||||
}
|
||||
|
||||
// WithKeepAliveEndpoint enables a keep-alive endpoint with the provided timeout and callback.
|
||||
func WithKeepAliveEndpoint(timeout time.Duration, onTimeout func()) ServerOption {
|
||||
return func(cfg *serverOptionConfig) {
|
||||
if timeout <= 0 || onTimeout == nil {
|
||||
return
|
||||
}
|
||||
cfg.keepAliveEnabled = true
|
||||
cfg.keepAliveTimeout = timeout
|
||||
cfg.keepAliveOnTimeout = onTimeout
|
||||
}
|
||||
}
|
||||
|
||||
// WithRequestLoggerFactory customises request logger creation.
|
||||
func WithRequestLoggerFactory(factory func(*config.Config, string) logging.RequestLogger) ServerOption {
|
||||
return func(cfg *serverOptionConfig) {
|
||||
@@ -105,6 +124,14 @@ type Server struct {
|
||||
|
||||
// management handler
|
||||
mgmt *managementHandlers.Handler
|
||||
|
||||
localPassword string
|
||||
|
||||
keepAliveEnabled bool
|
||||
keepAliveTimeout time.Duration
|
||||
keepAliveOnTimeout func()
|
||||
keepAliveHeartbeat chan struct{}
|
||||
keepAliveStop chan struct{}
|
||||
}
|
||||
|
||||
// NewServer creates and initializes a new API server instance.
|
||||
@@ -161,19 +188,20 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
|
||||
// Create server instance
|
||||
s := &Server{
|
||||
engine: engine,
|
||||
handlers: handlers.NewBaseAPIHandlers(cfg, authManager),
|
||||
handlers: handlers.NewBaseAPIHandlers(&cfg.SDKConfig, authManager),
|
||||
cfg: cfg,
|
||||
accessManager: accessManager,
|
||||
requestLogger: requestLogger,
|
||||
loggerToggle: toggle,
|
||||
configFilePath: configFilePath,
|
||||
}
|
||||
s.applyAccessConfig(cfg)
|
||||
s.applyAccessConfig(nil, cfg)
|
||||
// Initialize management handler
|
||||
s.mgmt = managementHandlers.NewHandler(cfg, configFilePath, authManager)
|
||||
if optionState.localPassword != "" {
|
||||
s.mgmt.SetLocalPassword(optionState.localPassword)
|
||||
}
|
||||
s.localPassword = optionState.localPassword
|
||||
|
||||
// Setup routes
|
||||
s.setupRoutes()
|
||||
@@ -181,6 +209,10 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
|
||||
optionState.routerConfigurator(engine, s.handlers, cfg)
|
||||
}
|
||||
|
||||
if optionState.keepAliveEnabled {
|
||||
s.enableKeepAlive(optionState.keepAliveTimeout, optionState.keepAliveOnTimeout)
|
||||
}
|
||||
|
||||
// Create HTTP server
|
||||
s.server = &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", cfg.Port),
|
||||
@@ -287,6 +319,14 @@ func (s *Server) setupRoutes() {
|
||||
mgmt.PUT("/debug", s.mgmt.PutDebug)
|
||||
mgmt.PATCH("/debug", s.mgmt.PutDebug)
|
||||
|
||||
mgmt.GET("/logging-to-file", s.mgmt.GetLoggingToFile)
|
||||
mgmt.PUT("/logging-to-file", s.mgmt.PutLoggingToFile)
|
||||
mgmt.PATCH("/logging-to-file", s.mgmt.PutLoggingToFile)
|
||||
|
||||
mgmt.GET("/usage-statistics-enabled", s.mgmt.GetUsageStatisticsEnabled)
|
||||
mgmt.PUT("/usage-statistics-enabled", s.mgmt.PutUsageStatisticsEnabled)
|
||||
mgmt.PATCH("/usage-statistics-enabled", s.mgmt.PutUsageStatisticsEnabled)
|
||||
|
||||
mgmt.GET("/proxy-url", s.mgmt.GetProxyURL)
|
||||
mgmt.PUT("/proxy-url", s.mgmt.PutProxyURL)
|
||||
mgmt.PATCH("/proxy-url", s.mgmt.PutProxyURL)
|
||||
@@ -348,6 +388,84 @@ func (s *Server) setupRoutes() {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) enableKeepAlive(timeout time.Duration, onTimeout func()) {
|
||||
if timeout <= 0 || onTimeout == nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.keepAliveEnabled = true
|
||||
s.keepAliveTimeout = timeout
|
||||
s.keepAliveOnTimeout = onTimeout
|
||||
s.keepAliveHeartbeat = make(chan struct{}, 1)
|
||||
s.keepAliveStop = make(chan struct{}, 1)
|
||||
|
||||
s.engine.GET("/keep-alive", s.handleKeepAlive)
|
||||
|
||||
go s.watchKeepAlive()
|
||||
}
|
||||
|
||||
func (s *Server) handleKeepAlive(c *gin.Context) {
|
||||
if s.localPassword != "" {
|
||||
provided := strings.TrimSpace(c.GetHeader("Authorization"))
|
||||
if provided != "" {
|
||||
parts := strings.SplitN(provided, " ", 2)
|
||||
if len(parts) == 2 && strings.EqualFold(parts[0], "bearer") {
|
||||
provided = parts[1]
|
||||
}
|
||||
}
|
||||
if provided == "" {
|
||||
provided = strings.TrimSpace(c.GetHeader("X-Local-Password"))
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(provided), []byte(s.localPassword)) != 1 {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid password"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
s.signalKeepAlive()
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
func (s *Server) signalKeepAlive() {
|
||||
if !s.keepAliveEnabled {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case s.keepAliveHeartbeat <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) watchKeepAlive() {
|
||||
if !s.keepAliveEnabled {
|
||||
return
|
||||
}
|
||||
|
||||
timer := time.NewTimer(s.keepAliveTimeout)
|
||||
defer timer.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-timer.C:
|
||||
log.Warnf("keep-alive endpoint idle for %s, shutting down", s.keepAliveTimeout)
|
||||
if s.keepAliveOnTimeout != nil {
|
||||
s.keepAliveOnTimeout()
|
||||
}
|
||||
return
|
||||
case <-s.keepAliveHeartbeat:
|
||||
if !timer.Stop() {
|
||||
select {
|
||||
case <-timer.C:
|
||||
default:
|
||||
}
|
||||
}
|
||||
timer.Reset(s.keepAliveTimeout)
|
||||
case <-s.keepAliveStop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// unifiedModelsHandler creates a unified handler for the /v1/models endpoint
|
||||
// that routes to different handlers based on the User-Agent header.
|
||||
// If User-Agent starts with "claude-cli", it routes to Claude handler,
|
||||
@@ -394,6 +512,13 @@ func (s *Server) Start() error {
|
||||
func (s *Server) Stop(ctx context.Context) error {
|
||||
log.Debug("Stopping API server...")
|
||||
|
||||
if s.keepAliveEnabled {
|
||||
select {
|
||||
case s.keepAliveStop <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown the HTTP server.
|
||||
if err := s.server.Shutdown(ctx); err != nil {
|
||||
return fmt.Errorf("failed to shutdown HTTP server: %v", err)
|
||||
@@ -423,16 +548,13 @@ func corsMiddleware() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) applyAccessConfig(cfg *config.Config) {
|
||||
if s == nil || s.accessManager == nil {
|
||||
func (s *Server) applyAccessConfig(oldCfg, newCfg *config.Config) {
|
||||
if s == nil || s.accessManager == nil || newCfg == nil {
|
||||
return
|
||||
}
|
||||
providers, err := sdkaccess.BuildProviders(cfg)
|
||||
if err != nil {
|
||||
log.Errorf("failed to update request auth providers: %v", err)
|
||||
if _, err := access.ApplyAccessProviders(s.accessManager, oldCfg, newCfg); err != nil {
|
||||
return
|
||||
}
|
||||
s.accessManager.SetProviders(providers)
|
||||
}
|
||||
|
||||
// UpdateClients updates the server's client list and configuration.
|
||||
@@ -442,29 +564,60 @@ func (s *Server) applyAccessConfig(cfg *config.Config) {
|
||||
// - clients: The new slice of AI service clients
|
||||
// - cfg: The new application configuration
|
||||
func (s *Server) UpdateClients(cfg *config.Config) {
|
||||
oldCfg := s.cfg
|
||||
|
||||
// Update request logger enabled state if it has changed
|
||||
if s.requestLogger != nil && s.cfg.RequestLog != cfg.RequestLog {
|
||||
previousRequestLog := false
|
||||
if oldCfg != nil {
|
||||
previousRequestLog = oldCfg.RequestLog
|
||||
}
|
||||
if s.requestLogger != nil && (oldCfg == nil || previousRequestLog != cfg.RequestLog) {
|
||||
if s.loggerToggle != nil {
|
||||
s.loggerToggle(cfg.RequestLog)
|
||||
} else if toggler, ok := s.requestLogger.(interface{ SetEnabled(bool) }); ok {
|
||||
toggler.SetEnabled(cfg.RequestLog)
|
||||
}
|
||||
log.Debugf("request logging updated from %t to %t", s.cfg.RequestLog, cfg.RequestLog)
|
||||
if oldCfg != nil {
|
||||
log.Debugf("request logging updated from %t to %t", previousRequestLog, cfg.RequestLog)
|
||||
} else {
|
||||
log.Debugf("request logging toggled to %t", cfg.RequestLog)
|
||||
}
|
||||
}
|
||||
|
||||
if oldCfg != nil && oldCfg.LoggingToFile != cfg.LoggingToFile {
|
||||
if err := logging.ConfigureLogOutput(cfg.LoggingToFile); err != nil {
|
||||
log.Errorf("failed to reconfigure log output: %v", err)
|
||||
} else {
|
||||
log.Debugf("logging_to_file updated from %t to %t", oldCfg.LoggingToFile, cfg.LoggingToFile)
|
||||
}
|
||||
}
|
||||
|
||||
if oldCfg == nil || oldCfg.UsageStatisticsEnabled != cfg.UsageStatisticsEnabled {
|
||||
usage.SetStatisticsEnabled(cfg.UsageStatisticsEnabled)
|
||||
if oldCfg != nil {
|
||||
log.Debugf("usage_statistics_enabled updated from %t to %t", oldCfg.UsageStatisticsEnabled, cfg.UsageStatisticsEnabled)
|
||||
} else {
|
||||
log.Debugf("usage_statistics_enabled toggled to %t", cfg.UsageStatisticsEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
// Update log level dynamically when debug flag changes
|
||||
if s.cfg.Debug != cfg.Debug {
|
||||
if oldCfg == nil || oldCfg.Debug != cfg.Debug {
|
||||
util.SetLogLevel(cfg)
|
||||
log.Debugf("debug mode updated from %t to %t", s.cfg.Debug, cfg.Debug)
|
||||
if oldCfg != nil {
|
||||
log.Debugf("debug mode updated from %t to %t", oldCfg.Debug, cfg.Debug)
|
||||
} else {
|
||||
log.Debugf("debug mode toggled to %t", cfg.Debug)
|
||||
}
|
||||
}
|
||||
|
||||
s.applyAccessConfig(oldCfg, cfg)
|
||||
s.cfg = cfg
|
||||
s.handlers.UpdateClients(cfg)
|
||||
s.handlers.UpdateClients(&cfg.SDKConfig)
|
||||
if s.mgmt != nil {
|
||||
s.mgmt.SetConfig(cfg)
|
||||
s.mgmt.SetAuthManager(s.handlers.AuthManager)
|
||||
}
|
||||
s.applyAccessConfig(cfg)
|
||||
|
||||
// Count client sources from configuration and auth directory
|
||||
authFiles := util.CountAuthFiles(cfg.AuthDir)
|
||||
@@ -477,7 +630,7 @@ func (s *Server) UpdateClients(cfg *config.Config) {
|
||||
}
|
||||
|
||||
total := authFiles + glAPIKeyCount + claudeAPIKeyCount + codexAPIKeyCount + openAICompatCount
|
||||
log.Infof("server clients and configuration updated: %d clients (%d auth files + %d GL API keys + %d Claude API keys + %d Codex keys + %d OpenAI-compat)",
|
||||
fmt.Printf("server clients and configuration updated: %d clients (%d auth files + %d GL API keys + %d Claude API keys + %d Codex keys + %d OpenAI-compat)\n",
|
||||
total,
|
||||
authFiles,
|
||||
glAPIKeyCount,
|
||||
|
||||
@@ -59,7 +59,7 @@ type ClaudeAuth struct {
|
||||
// - *ClaudeAuth: A new Claude authentication service instance
|
||||
func NewClaudeAuth(cfg *config.Config) *ClaudeAuth {
|
||||
return &ClaudeAuth{
|
||||
httpClient: util.SetProxy(cfg, &http.Client{}),
|
||||
httpClient: util.SetProxy(&cfg.SDKConfig, &http.Client{}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ type CodexAuth struct {
|
||||
// It initializes an HTTP client with proxy settings from the provided configuration.
|
||||
func NewCodexAuth(cfg *config.Config) *CodexAuth {
|
||||
return &CodexAuth{
|
||||
httpClient: util.SetProxy(cfg, &http.Client{}),
|
||||
httpClient: util.SetProxy(&cfg.SDKConfig, &http.Client{}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ func (g *GeminiAuth) GetAuthenticatedClient(ctx context.Context, ts *GeminiToken
|
||||
|
||||
// If no token is found in storage, initiate the web-based OAuth flow.
|
||||
if ts.Token == nil {
|
||||
log.Info("Could not load token from file, starting OAuth flow.")
|
||||
fmt.Printf("Could not load token from file, starting OAuth flow.\n")
|
||||
token, err = g.getTokenFromWeb(ctx, conf, noBrowser...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get token from web: %w", err)
|
||||
@@ -169,9 +169,9 @@ func (g *GeminiAuth) createTokenStorage(ctx context.Context, config *oauth2.Conf
|
||||
|
||||
emailResult := gjson.GetBytes(bodyBytes, "email")
|
||||
if emailResult.Exists() && emailResult.Type == gjson.String {
|
||||
log.Infof("Authenticated user email: %s", emailResult.String())
|
||||
fmt.Printf("Authenticated user email: %s\n", emailResult.String())
|
||||
} else {
|
||||
log.Info("Failed to get user email from token")
|
||||
fmt.Println("Failed to get user email from token")
|
||||
}
|
||||
|
||||
var ifToken map[string]any
|
||||
@@ -246,19 +246,19 @@ func (g *GeminiAuth) getTokenFromWeb(ctx context.Context, config *oauth2.Config,
|
||||
authURL := config.AuthCodeURL("state-token", oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("prompt", "consent"))
|
||||
|
||||
if len(noBrowser) == 1 && !noBrowser[0] {
|
||||
log.Info("Opening browser for authentication...")
|
||||
fmt.Println("Opening browser for authentication...")
|
||||
|
||||
// Check if browser is available
|
||||
if !browser.IsAvailable() {
|
||||
log.Warn("No browser available on this system")
|
||||
util.PrintSSHTunnelInstructions(8085)
|
||||
log.Infof("Please manually open this URL in your browser:\n\n%s\n", authURL)
|
||||
fmt.Printf("Please manually open this URL in your browser:\n\n%s\n", authURL)
|
||||
} else {
|
||||
if err := browser.OpenURL(authURL); err != nil {
|
||||
authErr := codex.NewAuthenticationError(codex.ErrBrowserOpenFailed, err)
|
||||
log.Warn(codex.GetUserFriendlyMessage(authErr))
|
||||
util.PrintSSHTunnelInstructions(8085)
|
||||
log.Infof("Please manually open this URL in your browser:\n\n%s\n", authURL)
|
||||
fmt.Printf("Please manually open this URL in your browser:\n\n%s\n", authURL)
|
||||
|
||||
// Log platform info for debugging
|
||||
platformInfo := browser.GetPlatformInfo()
|
||||
@@ -269,10 +269,10 @@ func (g *GeminiAuth) getTokenFromWeb(ctx context.Context, config *oauth2.Config,
|
||||
}
|
||||
} else {
|
||||
util.PrintSSHTunnelInstructions(8085)
|
||||
log.Infof("Please open this URL in your browser:\n\n%s\n", authURL)
|
||||
fmt.Printf("Please open this URL in your browser:\n\n%s\n", authURL)
|
||||
}
|
||||
|
||||
log.Info("Waiting for authentication callback...")
|
||||
fmt.Println("Waiting for authentication callback...")
|
||||
|
||||
// Wait for the authorization code or an error.
|
||||
var authCode string
|
||||
@@ -296,6 +296,6 @@ func (g *GeminiAuth) getTokenFromWeb(ctx context.Context, config *oauth2.Config,
|
||||
return nil, fmt.Errorf("failed to exchange token: %w", err)
|
||||
}
|
||||
|
||||
log.Info("Authentication successful.")
|
||||
fmt.Println("Authentication successful.")
|
||||
return token, nil
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ type QwenAuth struct {
|
||||
// NewQwenAuth creates a new QwenAuth instance with a proxy-configured HTTP client.
|
||||
func NewQwenAuth(cfg *config.Config) *QwenAuth {
|
||||
return &QwenAuth{
|
||||
httpClient: util.SetProxy(cfg, &http.Client{}),
|
||||
httpClient: util.SetProxy(&cfg.SDKConfig, &http.Client{}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,7 +260,7 @@ func (qa *QwenAuth) PollForToken(deviceCode, codeVerifier string) (*QwenTokenDat
|
||||
switch errorType {
|
||||
case "authorization_pending":
|
||||
// User has not yet approved the authorization request. Continue polling.
|
||||
log.Infof("Polling attempt %d/%d...\n", attempt+1, maxAttempts)
|
||||
fmt.Printf("Polling attempt %d/%d...\n\n", attempt+1, maxAttempts)
|
||||
time.Sleep(pollInterval)
|
||||
continue
|
||||
case "slow_down":
|
||||
@@ -269,7 +269,7 @@ func (qa *QwenAuth) PollForToken(deviceCode, codeVerifier string) (*QwenTokenDat
|
||||
if pollInterval > 10*time.Second {
|
||||
pollInterval = 10 * time.Second
|
||||
}
|
||||
log.Infof("Server requested to slow down, increasing poll interval to %v\n", pollInterval)
|
||||
fmt.Printf("Server requested to slow down, increasing poll interval to %v\n\n", pollInterval)
|
||||
time.Sleep(pollInterval)
|
||||
continue
|
||||
case "expired_token":
|
||||
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
// Returns:
|
||||
// - An error if the URL cannot be opened, otherwise nil.
|
||||
func OpenURL(url string) error {
|
||||
log.Infof("Attempting to open URL in browser: %s", url)
|
||||
fmt.Printf("Attempting to open URL in browser: %s\n", url)
|
||||
|
||||
// Try using the open-golang library first
|
||||
err := open.Run(url)
|
||||
|
||||
@@ -6,42 +6,147 @@ import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
||||
log "github.com/sirupsen/logrus"
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||
)
|
||||
|
||||
// banner prints a simple ASCII banner for clarity without ANSI colors.
|
||||
func banner(title string) {
|
||||
line := strings.Repeat("=", len(title)+8)
|
||||
fmt.Println(line)
|
||||
fmt.Println("=== " + title + " ===")
|
||||
fmt.Println(line)
|
||||
}
|
||||
|
||||
// DoGeminiWebAuth handles the process of creating a Gemini Web token file.
|
||||
// It prompts the user for their cookie values and saves them to a JSON file.
|
||||
// New flow:
|
||||
// 1. Prompt user to paste the full cookie string.
|
||||
// 2. Extract __Secure-1PSID and __Secure-1PSIDTS from the cookie string.
|
||||
// 3. Call https://accounts.google.com/ListAccounts with the cookie to obtain email.
|
||||
// 4. Save auth file with the same structure, and set Label to the email.
|
||||
func DoGeminiWebAuth(cfg *config.Config) {
|
||||
var secure1psid, secure1psidts, email string
|
||||
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
isMacOS := strings.HasPrefix(runtime.GOOS, "darwin")
|
||||
cookieProvided := false
|
||||
banner("Gemini Web Cookie Sign-in")
|
||||
if !isMacOS {
|
||||
// NOTE: Provide extra guidance for macOS users or anyone unsure about retrieving cookies.
|
||||
fmt.Println("--- Cookie Input ---")
|
||||
fmt.Println(">> Paste your full Google Cookie and press Enter")
|
||||
fmt.Println("Tip: If you are on macOS, or don't know how to get the cookie, just press Enter and follow the prompts.")
|
||||
fmt.Print("Cookie: ")
|
||||
rawCookie, _ := reader.ReadString('\n')
|
||||
rawCookie = strings.TrimSpace(rawCookie)
|
||||
if rawCookie == "" {
|
||||
// Skip cookie-based parsing; fall back to manual field prompts.
|
||||
fmt.Println("==> No cookie provided. Proceeding with manual input.")
|
||||
} else {
|
||||
cookieProvided = true
|
||||
// Parse K=V cookie pairs separated by ';'
|
||||
cookieMap := make(map[string]string)
|
||||
parts := strings.Split(rawCookie, ";")
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if eq := strings.Index(p, "="); eq > 0 {
|
||||
k := strings.TrimSpace(p[:eq])
|
||||
v := strings.TrimSpace(p[eq+1:])
|
||||
if k != "" {
|
||||
cookieMap[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
secure1psid = strings.TrimSpace(cookieMap["__Secure-1PSID"])
|
||||
secure1psidts = strings.TrimSpace(cookieMap["__Secure-1PSIDTS"])
|
||||
|
||||
fmt.Print("Enter your __Secure-1PSID cookie value: ")
|
||||
secure1psid, _ := reader.ReadString('\n')
|
||||
secure1psid = strings.TrimSpace(secure1psid)
|
||||
// Build HTTP client with proxy settings respected.
|
||||
httpClient := &http.Client{Timeout: 15 * time.Second}
|
||||
httpClient = util.SetProxy(&cfg.SDKConfig, httpClient)
|
||||
|
||||
// Request ListAccounts to extract email as label (use POST per upstream behavior).
|
||||
req, err := http.NewRequest(http.MethodPost, "https://accounts.google.com/ListAccounts", nil)
|
||||
if err != nil {
|
||||
fmt.Println("!! Failed to create request:", err)
|
||||
} else {
|
||||
req.Header.Set("Cookie", rawCookie)
|
||||
req.Header.Set("Accept", "application/json, text/plain, */*")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36")
|
||||
req.Header.Set("Origin", "https://accounts.google.com")
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8")
|
||||
|
||||
resp, errDo := httpClient.Do(req)
|
||||
if errDo != nil {
|
||||
fmt.Println("!! Request to ListAccounts failed:", err)
|
||||
} else {
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
fmt.Printf("!! ListAccounts returned status code: %d\n", resp.StatusCode)
|
||||
} else {
|
||||
var payload []any
|
||||
if err = json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
fmt.Println("!! Failed to parse ListAccounts response:", err)
|
||||
} else {
|
||||
// Expected structure like: ["gaia.l.a.r", [["gaia.l.a",1,"Name","email@example.com", ... ]]]
|
||||
if len(payload) >= 2 {
|
||||
if accounts, ok := payload[1].([]any); ok && len(accounts) >= 1 {
|
||||
if first, ok1 := accounts[0].([]any); ok1 && len(first) >= 4 {
|
||||
if em, ok2 := first[3].(string); ok2 {
|
||||
email = strings.TrimSpace(em)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if email == "" {
|
||||
fmt.Println("!! Failed to parse email from ListAccounts response")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: prompt user to input missing values
|
||||
if secure1psid == "" {
|
||||
log.Fatal("The __Secure-1PSID value cannot be empty.")
|
||||
return
|
||||
if cookieProvided && !isMacOS {
|
||||
fmt.Println("!! Cookie missing __Secure-1PSID.")
|
||||
}
|
||||
fmt.Print("Enter __Secure-1PSID: ")
|
||||
v, _ := reader.ReadString('\n')
|
||||
secure1psid = strings.TrimSpace(v)
|
||||
}
|
||||
|
||||
fmt.Print("Enter your __Secure-1PSIDTS cookie value: ")
|
||||
secure1psidts, _ := reader.ReadString('\n')
|
||||
secure1psidts = strings.TrimSpace(secure1psidts)
|
||||
|
||||
if secure1psidts == "" {
|
||||
fmt.Println("The __Secure-1PSIDTS value cannot be empty.")
|
||||
if cookieProvided && !isMacOS {
|
||||
fmt.Println("!! Cookie missing __Secure-1PSIDTS.")
|
||||
}
|
||||
fmt.Print("Enter __Secure-1PSIDTS: ")
|
||||
v, _ := reader.ReadString('\n')
|
||||
secure1psidts = strings.TrimSpace(v)
|
||||
}
|
||||
if secure1psid == "" || secure1psidts == "" {
|
||||
// Use print instead of logger to avoid log redirection.
|
||||
fmt.Println("!! __Secure-1PSID and __Secure-1PSIDTS cannot be empty")
|
||||
return
|
||||
}
|
||||
|
||||
tokenStorage := &gemini.GeminiWebTokenStorage{
|
||||
Secure1PSID: secure1psid,
|
||||
Secure1PSIDTS: secure1psidts,
|
||||
if isMacOS {
|
||||
fmt.Print("Enter your account email: ")
|
||||
v, _ := reader.ReadString('\n')
|
||||
email = strings.TrimSpace(v)
|
||||
}
|
||||
|
||||
// Generate a filename based on the SHA256 hash of the PSID
|
||||
@@ -49,21 +154,44 @@ func DoGeminiWebAuth(cfg *config.Config) {
|
||||
hasher.Write([]byte(secure1psid))
|
||||
hash := hex.EncodeToString(hasher.Sum(nil))
|
||||
fileName := fmt.Sprintf("gemini-web-%s.json", hash[:16])
|
||||
// Set a stable label for logging, e.g. gemini-web-<hash>
|
||||
if tokenStorage != nil {
|
||||
tokenStorage.Label = strings.TrimSuffix(fileName, ".json")
|
||||
|
||||
// Decide label: prefer email; fallback prompt then file name without .json
|
||||
defaultLabel := strings.TrimSuffix(fileName, ".json")
|
||||
label := email
|
||||
if label == "" {
|
||||
fmt.Print(fmt.Sprintf("Enter label for this auth (default: %s): ", defaultLabel))
|
||||
v, _ := reader.ReadString('\n')
|
||||
v = strings.TrimSpace(v)
|
||||
if v != "" {
|
||||
label = v
|
||||
} else {
|
||||
label = defaultLabel
|
||||
}
|
||||
}
|
||||
record := &sdkAuth.TokenRecord{
|
||||
|
||||
tokenStorage := &gemini.GeminiWebTokenStorage{
|
||||
Secure1PSID: secure1psid,
|
||||
Secure1PSIDTS: secure1psidts,
|
||||
Label: label,
|
||||
}
|
||||
record := &coreauth.Auth{
|
||||
ID: fileName,
|
||||
Provider: "gemini-web",
|
||||
FileName: fileName,
|
||||
Storage: tokenStorage,
|
||||
}
|
||||
store := sdkAuth.GetTokenStore()
|
||||
savedPath, err := store.Save(context.Background(), cfg, record)
|
||||
if cfg != nil {
|
||||
if dirSetter, ok := store.(interface{ SetBaseDir(string) }); ok {
|
||||
dirSetter.SetBaseDir(cfg.AuthDir)
|
||||
}
|
||||
}
|
||||
savedPath, err := store.Save(context.Background(), record)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to save Gemini Web token to file: %v\n", err)
|
||||
fmt.Println("!! Failed to save Gemini Web token to file:", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("Successfully saved Gemini Web token to: %s\n", savedPath)
|
||||
fmt.Println("==> Successfully saved Gemini Web token!")
|
||||
fmt.Println("==> Saved to:", savedPath)
|
||||
}
|
||||
|
||||
@@ -62,8 +62,8 @@ func DoLogin(cfg *config.Config, projectID string, options *LoginOptions) {
|
||||
}
|
||||
|
||||
if savedPath != "" {
|
||||
log.Infof("Authentication saved to %s", savedPath)
|
||||
fmt.Printf("Authentication saved to %s\n", savedPath)
|
||||
}
|
||||
|
||||
log.Info("Gemini authentication successful!")
|
||||
fmt.Println("Gemini authentication successful!")
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@ import (
|
||||
"errors"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/api"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy"
|
||||
log "github.com/sirupsen/logrus"
|
||||
@@ -23,19 +25,30 @@ import (
|
||||
// - configPath: The path to the configuration file
|
||||
// - localPassword: Optional password accepted for local management requests
|
||||
func StartService(cfg *config.Config, configPath string, localPassword string) {
|
||||
service, err := cliproxy.NewBuilder().
|
||||
builder := cliproxy.NewBuilder().
|
||||
WithConfig(cfg).
|
||||
WithConfigPath(configPath).
|
||||
WithLocalManagementPassword(localPassword).
|
||||
Build()
|
||||
WithLocalManagementPassword(localPassword)
|
||||
|
||||
ctxSignal, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
runCtx := ctxSignal
|
||||
if localPassword != "" {
|
||||
var keepAliveCancel context.CancelFunc
|
||||
runCtx, keepAliveCancel = context.WithCancel(ctxSignal)
|
||||
builder = builder.WithServerOptions(api.WithKeepAliveEndpoint(10*time.Second, func() {
|
||||
log.Warn("keep-alive endpoint idle for 10s, shutting down")
|
||||
keepAliveCancel()
|
||||
}))
|
||||
}
|
||||
|
||||
service, err := builder.Build()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to build proxy service: %v", err)
|
||||
}
|
||||
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
err = service.Run(ctx)
|
||||
err = service.Run(runCtx)
|
||||
if err != nil && !errors.Is(err, context.Canceled) {
|
||||
log.Fatalf("proxy service exited with error: %v", err)
|
||||
}
|
||||
|
||||
@@ -8,12 +8,14 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Config represents the application's configuration, loaded from a YAML file.
|
||||
type Config struct {
|
||||
config.SDKConfig `yaml:",inline"`
|
||||
// Port is the network port on which the API server will listen.
|
||||
Port int `yaml:"port" json:"-"`
|
||||
|
||||
@@ -23,14 +25,11 @@ type Config struct {
|
||||
// Debug enables or disables debug-level logging and other debug features.
|
||||
Debug bool `yaml:"debug" json:"debug"`
|
||||
|
||||
// ProxyURL is the URL of an optional proxy server to use for outbound requests.
|
||||
ProxyURL string `yaml:"proxy-url" json:"proxy-url"`
|
||||
// LoggingToFile controls whether application logs are written to rotating files or stdout.
|
||||
LoggingToFile bool `yaml:"logging-to-file" json:"logging-to-file"`
|
||||
|
||||
// APIKeys is a list of keys for authenticating clients to this proxy server.
|
||||
APIKeys []string `yaml:"api-keys" json:"api-keys"`
|
||||
|
||||
// Access holds request authentication provider configuration.
|
||||
Access AccessConfig `yaml:"auth" json:"auth"`
|
||||
// UsageStatisticsEnabled toggles in-memory usage aggregation; when false, usage data is discarded.
|
||||
UsageStatisticsEnabled bool `yaml:"usage-statistics-enabled" json:"usage-statistics-enabled"`
|
||||
|
||||
// QuotaExceeded defines the behavior when a quota is exceeded.
|
||||
QuotaExceeded QuotaExceeded `yaml:"quota-exceeded" json:"quota-exceeded"`
|
||||
@@ -38,9 +37,6 @@ type Config struct {
|
||||
// GlAPIKey is the API key for the generative language API.
|
||||
GlAPIKey []string `yaml:"generative-language-api-key" json:"generative-language-api-key"`
|
||||
|
||||
// RequestLog enables or disables detailed request logging functionality.
|
||||
RequestLog bool `yaml:"request-log" json:"request-log"`
|
||||
|
||||
// RequestRetry defines the retry times when the request failed.
|
||||
RequestRetry int `yaml:"request-retry" json:"request-retry"`
|
||||
|
||||
@@ -60,38 +56,6 @@ type Config struct {
|
||||
GeminiWeb GeminiWebConfig `yaml:"gemini-web" json:"gemini-web"`
|
||||
}
|
||||
|
||||
// AccessConfig groups request authentication providers.
|
||||
type AccessConfig struct {
|
||||
// Providers lists configured authentication providers.
|
||||
Providers []AccessProvider `yaml:"providers" json:"providers"`
|
||||
}
|
||||
|
||||
// AccessProvider describes a request authentication provider entry.
|
||||
type AccessProvider struct {
|
||||
// Name is the instance identifier for the provider.
|
||||
Name string `yaml:"name" json:"name"`
|
||||
|
||||
// Type selects the provider implementation registered via the SDK.
|
||||
Type string `yaml:"type" json:"type"`
|
||||
|
||||
// SDK optionally names a third-party SDK module providing this provider.
|
||||
SDK string `yaml:"sdk,omitempty" json:"sdk,omitempty"`
|
||||
|
||||
// APIKeys lists inline keys for providers that require them.
|
||||
APIKeys []string `yaml:"api-keys,omitempty" json:"api-keys,omitempty"`
|
||||
|
||||
// Config passes provider-specific options to the implementation.
|
||||
Config map[string]any `yaml:"config,omitempty" json:"config,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
// AccessProviderTypeConfigAPIKey is the built-in provider validating inline API keys.
|
||||
AccessProviderTypeConfigAPIKey = "config-api-key"
|
||||
|
||||
// DefaultAccessProviderName is applied when no provider name is supplied.
|
||||
DefaultAccessProviderName = "config-inline"
|
||||
)
|
||||
|
||||
// GeminiWebConfig nests Gemini Web related options under 'gemini-web'.
|
||||
type GeminiWebConfig struct {
|
||||
// Context enables JSON-based conversation reuse.
|
||||
@@ -200,21 +164,23 @@ func LoadConfig(configFile string) (*Config, error) {
|
||||
}
|
||||
|
||||
// Unmarshal the YAML data into the Config struct.
|
||||
var config Config
|
||||
var cfg Config
|
||||
// Set defaults before unmarshal so that absent keys keep defaults.
|
||||
config.GeminiWeb.Context = true
|
||||
if err = yaml.Unmarshal(data, &config); err != nil {
|
||||
cfg.LoggingToFile = true
|
||||
cfg.UsageStatisticsEnabled = true
|
||||
cfg.GeminiWeb.Context = true
|
||||
if err = yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse config file: %w", err)
|
||||
}
|
||||
|
||||
// Hash remote management key if plaintext is detected (nested)
|
||||
// We consider a value to be already hashed if it looks like a bcrypt hash ($2a$, $2b$, or $2y$ prefix).
|
||||
if config.RemoteManagement.SecretKey != "" && !looksLikeBcrypt(config.RemoteManagement.SecretKey) {
|
||||
hashed, errHash := hashSecret(config.RemoteManagement.SecretKey)
|
||||
if cfg.RemoteManagement.SecretKey != "" && !looksLikeBcrypt(cfg.RemoteManagement.SecretKey) {
|
||||
hashed, errHash := hashSecret(cfg.RemoteManagement.SecretKey)
|
||||
if errHash != nil {
|
||||
return nil, fmt.Errorf("failed to hash remote management key: %w", errHash)
|
||||
}
|
||||
config.RemoteManagement.SecretKey = hashed
|
||||
cfg.RemoteManagement.SecretKey = hashed
|
||||
|
||||
// Persist the hashed value back to the config file to avoid re-hashing on next startup.
|
||||
// Preserve YAML comments and ordering; update only the nested key.
|
||||
@@ -222,47 +188,10 @@ func LoadConfig(configFile string) (*Config, error) {
|
||||
}
|
||||
|
||||
// Sync request authentication providers with inline API keys for backwards compatibility.
|
||||
syncInlineAccessProvider(&config)
|
||||
syncInlineAccessProvider(&cfg)
|
||||
|
||||
// Return the populated configuration struct.
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// SyncInlineAPIKeys updates the inline API key provider and top-level APIKeys field.
|
||||
func SyncInlineAPIKeys(cfg *Config, keys []string) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
cloned := append([]string(nil), keys...)
|
||||
cfg.APIKeys = cloned
|
||||
if provider := cfg.ConfigAPIKeyProvider(); provider != nil {
|
||||
if provider.Name == "" {
|
||||
provider.Name = DefaultAccessProviderName
|
||||
}
|
||||
provider.APIKeys = cloned
|
||||
return
|
||||
}
|
||||
cfg.Access.Providers = append(cfg.Access.Providers, AccessProvider{
|
||||
Name: DefaultAccessProviderName,
|
||||
Type: AccessProviderTypeConfigAPIKey,
|
||||
APIKeys: cloned,
|
||||
})
|
||||
}
|
||||
|
||||
// ConfigAPIKeyProvider returns the first inline API key provider if present.
|
||||
func (c *Config) ConfigAPIKeyProvider() *AccessProvider {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
for i := range c.Access.Providers {
|
||||
if c.Access.Providers[i].Type == AccessProviderTypeConfigAPIKey {
|
||||
if c.Access.Providers[i].Name == "" {
|
||||
c.Access.Providers[i].Name = DefaultAccessProviderName
|
||||
}
|
||||
return &c.Access.Providers[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func syncInlineAccessProvider(cfg *Config) {
|
||||
@@ -273,9 +202,9 @@ func syncInlineAccessProvider(cfg *Config) {
|
||||
if len(cfg.APIKeys) == 0 {
|
||||
return
|
||||
}
|
||||
cfg.Access.Providers = append(cfg.Access.Providers, AccessProvider{
|
||||
Name: DefaultAccessProviderName,
|
||||
Type: AccessProviderTypeConfigAPIKey,
|
||||
cfg.Access.Providers = append(cfg.Access.Providers, config.AccessProvider{
|
||||
Name: config.DefaultAccessProviderName,
|
||||
Type: config.AccessProviderTypeConfigAPIKey,
|
||||
APIKeys: append([]string(nil), cfg.APIKeys...),
|
||||
})
|
||||
return
|
||||
@@ -285,9 +214,9 @@ func syncInlineAccessProvider(cfg *Config) {
|
||||
if len(cfg.APIKeys) == 0 {
|
||||
return
|
||||
}
|
||||
cfg.Access.Providers = append(cfg.Access.Providers, AccessProvider{
|
||||
Name: DefaultAccessProviderName,
|
||||
Type: AccessProviderTypeConfigAPIKey,
|
||||
cfg.Access.Providers = append(cfg.Access.Providers, config.AccessProvider{
|
||||
Name: config.DefaultAccessProviderName,
|
||||
Type: config.AccessProviderTypeConfigAPIKey,
|
||||
APIKeys: append([]string(nil), cfg.APIKeys...),
|
||||
})
|
||||
return
|
||||
|
||||
117
internal/logging/global_logger.go
Normal file
117
internal/logging/global_logger.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"gopkg.in/natefinch/lumberjack.v2"
|
||||
)
|
||||
|
||||
var (
|
||||
setupOnce sync.Once
|
||||
writerMu sync.Mutex
|
||||
logWriter *lumberjack.Logger
|
||||
ginInfoWriter *io.PipeWriter
|
||||
ginErrorWriter *io.PipeWriter
|
||||
)
|
||||
|
||||
// LogFormatter defines a custom log format for logrus.
|
||||
// This formatter adds timestamp, level, and source location to each log entry.
|
||||
type LogFormatter struct{}
|
||||
|
||||
// Format renders a single log entry with custom formatting.
|
||||
func (m *LogFormatter) Format(entry *log.Entry) ([]byte, error) {
|
||||
var buffer *bytes.Buffer
|
||||
if entry.Buffer != nil {
|
||||
buffer = entry.Buffer
|
||||
} else {
|
||||
buffer = &bytes.Buffer{}
|
||||
}
|
||||
|
||||
timestamp := entry.Time.Format("2006-01-02 15:04:05")
|
||||
message := strings.TrimRight(entry.Message, "\r\n")
|
||||
formatted := fmt.Sprintf("[%s] [%s] [%s:%d] %s\n", timestamp, entry.Level, filepath.Base(entry.Caller.File), entry.Caller.Line, message)
|
||||
buffer.WriteString(formatted)
|
||||
|
||||
return buffer.Bytes(), nil
|
||||
}
|
||||
|
||||
// SetupBaseLogger configures the shared logrus instance and Gin writers.
|
||||
// It is safe to call multiple times; initialization happens only once.
|
||||
func SetupBaseLogger() {
|
||||
setupOnce.Do(func() {
|
||||
log.SetOutput(os.Stdout)
|
||||
log.SetReportCaller(true)
|
||||
log.SetFormatter(&LogFormatter{})
|
||||
|
||||
ginInfoWriter = log.StandardLogger().Writer()
|
||||
gin.DefaultWriter = ginInfoWriter
|
||||
ginErrorWriter = log.StandardLogger().WriterLevel(log.ErrorLevel)
|
||||
gin.DefaultErrorWriter = ginErrorWriter
|
||||
gin.DebugPrintFunc = func(format string, values ...interface{}) {
|
||||
format = strings.TrimRight(format, "\r\n")
|
||||
log.StandardLogger().Infof(format, values...)
|
||||
}
|
||||
|
||||
log.RegisterExitHandler(closeLogOutputs)
|
||||
})
|
||||
}
|
||||
|
||||
// ConfigureLogOutput switches the global log destination between rotating files and stdout.
|
||||
func ConfigureLogOutput(loggingToFile bool) error {
|
||||
SetupBaseLogger()
|
||||
|
||||
writerMu.Lock()
|
||||
defer writerMu.Unlock()
|
||||
|
||||
if loggingToFile {
|
||||
const logDir = "logs"
|
||||
if err := os.MkdirAll(logDir, 0o755); err != nil {
|
||||
return fmt.Errorf("logging: failed to create log directory: %w", err)
|
||||
}
|
||||
if logWriter != nil {
|
||||
_ = logWriter.Close()
|
||||
}
|
||||
logWriter = &lumberjack.Logger{
|
||||
Filename: filepath.Join(logDir, "main.log"),
|
||||
MaxSize: 10,
|
||||
MaxBackups: 0,
|
||||
MaxAge: 0,
|
||||
Compress: false,
|
||||
}
|
||||
log.SetOutput(logWriter)
|
||||
return nil
|
||||
}
|
||||
|
||||
if logWriter != nil {
|
||||
_ = logWriter.Close()
|
||||
logWriter = nil
|
||||
}
|
||||
log.SetOutput(os.Stdout)
|
||||
return nil
|
||||
}
|
||||
|
||||
func closeLogOutputs() {
|
||||
writerMu.Lock()
|
||||
defer writerMu.Unlock()
|
||||
|
||||
if logWriter != nil {
|
||||
_ = logWriter.Close()
|
||||
logWriter = nil
|
||||
}
|
||||
if ginInfoWriter != nil {
|
||||
_ = ginInfoWriter.Close()
|
||||
ginInfoWriter = nil
|
||||
}
|
||||
if ginErrorWriter != nil {
|
||||
_ = ginErrorWriter.Close()
|
||||
ginErrorWriter = nil
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,13 +1,15 @@
|
||||
package misc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
var credentialSeparator = strings.Repeat("-", 70)
|
||||
// Separator used to visually group related log lines.
|
||||
var credentialSeparator = strings.Repeat("-", 67)
|
||||
|
||||
// LogSavingCredentials emits a consistent log message when persisting auth material.
|
||||
func LogSavingCredentials(path string) {
|
||||
@@ -15,10 +17,10 @@ func LogSavingCredentials(path string) {
|
||||
return
|
||||
}
|
||||
// Use filepath.Clean so logs remain stable even if callers pass redundant separators.
|
||||
log.Infof("Saving credentials to %s", filepath.Clean(path))
|
||||
fmt.Printf("Saving credentials to %s\n", filepath.Clean(path))
|
||||
}
|
||||
|
||||
// LogCredentialSeparator adds a visual separator to group auth/key processing logs.
|
||||
func LogCredentialSeparator() {
|
||||
log.Info(credentialSeparator)
|
||||
log.Debug(credentialSeparator)
|
||||
}
|
||||
|
||||
@@ -9,8 +9,6 @@ import (
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -126,19 +124,6 @@ func getAccessToken(baseCookies map[string]string, proxy string, verbose bool, i
|
||||
}
|
||||
}
|
||||
|
||||
cacheDir := "temp"
|
||||
_ = os.MkdirAll(cacheDir, 0o755)
|
||||
if v1, ok1 := baseCookies["__Secure-1PSID"]; ok1 {
|
||||
cacheFile := filepath.Join(cacheDir, ".cached_1psidts_"+v1+".txt")
|
||||
if b, err := os.ReadFile(cacheFile); err == nil {
|
||||
cv := strings.TrimSpace(string(b))
|
||||
if cv != "" {
|
||||
merged := map[string]string{"__Secure-1PSID": v1, "__Secure-1PSIDTS": cv}
|
||||
trySets = append(trySets, merged)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(extraCookies) > 0 {
|
||||
trySets = append(trySets, extraCookies)
|
||||
}
|
||||
@@ -162,7 +147,7 @@ func getAccessToken(baseCookies map[string]string, proxy string, verbose bool, i
|
||||
if len(matches) >= 2 {
|
||||
token := matches[1]
|
||||
if verbose {
|
||||
log.Infof("Gemini access token acquired.")
|
||||
fmt.Println("Gemini access token acquired.")
|
||||
}
|
||||
return token, mergedCookies, nil
|
||||
}
|
||||
@@ -295,7 +280,7 @@ func (c *GeminiClient) Init(timeoutSec float64, verbose bool) error {
|
||||
|
||||
c.Timeout = time.Duration(timeoutSec * float64(time.Second))
|
||||
if verbose {
|
||||
log.Infof("Gemini client initialized successfully.")
|
||||
fmt.Println("Gemini client initialized successfully.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -307,7 +292,7 @@ func (c *GeminiClient) Close(delaySec float64) {
|
||||
c.Running = false
|
||||
}
|
||||
|
||||
// ensureRunning mirrors the Python decorator behavior and retries on APIError.
|
||||
// ensureRunning mirrors the decorator behavior and retries on APIError.
|
||||
func (c *GeminiClient) ensureRunning() error {
|
||||
if c.Running {
|
||||
return nil
|
||||
@@ -434,7 +419,7 @@ func (c *GeminiClient) generateOnce(prompt string, files []string, model Model,
|
||||
}()
|
||||
|
||||
if resp.StatusCode == 429 {
|
||||
// Surface 429 as TemporarilyBlocked to match Python behavior
|
||||
// Surface 429 as TemporarilyBlocked to match reference behavior
|
||||
c.Close(0)
|
||||
return empty, &TemporarilyBlocked{GeminiError{Msg: "Too many requests. IP temporarily blocked."}}
|
||||
}
|
||||
@@ -514,7 +499,7 @@ func (c *GeminiClient) generateOnce(prompt string, files []string, model Model,
|
||||
}
|
||||
}
|
||||
}
|
||||
// Parse nested error code to align with Python mapping
|
||||
// Parse nested error code to align with error mapping
|
||||
var top []any
|
||||
// Prefer lastTop from fallback scan; otherwise try parts[2]
|
||||
if len(lastTop) > 0 {
|
||||
@@ -537,7 +522,7 @@ func (c *GeminiClient) generateOnce(prompt string, files []string, model Model,
|
||||
}
|
||||
}
|
||||
// Debug("Invalid response: control frames only; no body found")
|
||||
// Close the client to force re-initialization on next request (parity with Python client behavior)
|
||||
// Close the client to force re-initialization on next request (parity with reference client behavior)
|
||||
c.Close(0)
|
||||
return empty, &APIError{Msg: "Failed to generate contents. Invalid response data received."}
|
||||
}
|
||||
@@ -760,7 +745,7 @@ func (c *GeminiClient) generateOnce(prompt string, files []string, model Model,
|
||||
}
|
||||
|
||||
// extractErrorCode attempts to navigate the known nested error structure and fetch the integer code.
|
||||
// Mirrors Python path: response_json[0][5][2][0][1][0]
|
||||
// Mirrors reference path: response_json[0][5][2][0][1][0]
|
||||
func extractErrorCode(top []any) (int, bool) {
|
||||
if len(top) == 0 {
|
||||
return 0, false
|
||||
|
||||
@@ -52,7 +52,7 @@ func (i Image) Save(path string, filename string, cookies map[string]string, ver
|
||||
filename = q[0]
|
||||
}
|
||||
}
|
||||
// Regex validation (align with Python: ^(.*\.\w+)) to extract name with extension.
|
||||
// Regex validation (pattern: ^(.*\.\w+)) to extract name with extension.
|
||||
if filename != "" {
|
||||
re := regexp.MustCompile(`^(.*\.\w+)`)
|
||||
if m := re.FindStringSubmatch(filename); len(m) >= 2 {
|
||||
@@ -70,7 +70,7 @@ func (i Image) Save(path string, filename string, cookies map[string]string, ver
|
||||
client := newHTTPClient(httpOptions{ProxyURL: i.Proxy, Insecure: insecure, FollowRedirects: true})
|
||||
client.Timeout = 120 * time.Second
|
||||
|
||||
// Helper to set raw Cookie header using provided cookies (to mirror Python client behavior).
|
||||
// Helper to set raw Cookie header using provided cookies (parity with the reference client behavior).
|
||||
buildCookieHeader := func(m map[string]string) string {
|
||||
if len(m) == 0 {
|
||||
return ""
|
||||
@@ -136,7 +136,7 @@ func (i Image) Save(path string, filename string, cookies map[string]string, ver
|
||||
return "", err
|
||||
}
|
||||
if verbose {
|
||||
log.Infof("Image saved as %s", dest)
|
||||
fmt.Printf("Image saved as %s\n", dest)
|
||||
}
|
||||
abspath, _ := filepath.Abs(dest)
|
||||
return abspath, nil
|
||||
|
||||
@@ -88,6 +88,11 @@ func (s *GeminiWebState) Label() string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
if s.token != nil {
|
||||
if lbl := strings.TrimSpace(s.token.Label); lbl != "" {
|
||||
return lbl
|
||||
}
|
||||
}
|
||||
if s.storagePath != "" {
|
||||
base := strings.TrimSuffix(filepath.Base(s.storagePath), filepath.Ext(s.storagePath))
|
||||
if base != "" {
|
||||
@@ -98,16 +103,16 @@ func (s *GeminiWebState) Label() string {
|
||||
}
|
||||
|
||||
func (s *GeminiWebState) loadConversationCaches() {
|
||||
if path := s.convPath(); path != "" {
|
||||
if store, err := LoadConvStore(path); err == nil {
|
||||
s.convStore = store
|
||||
}
|
||||
path := s.convPath()
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
if path := s.convPath(); path != "" {
|
||||
if items, index, err := LoadConvData(path); err == nil {
|
||||
s.convData = items
|
||||
s.convIndex = index
|
||||
}
|
||||
if store, err := LoadConvStore(path); err == nil {
|
||||
s.convStore = store
|
||||
}
|
||||
if items, index, err := LoadConvData(path); err == nil {
|
||||
s.convData = items
|
||||
s.convIndex = index
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,8 +174,12 @@ func (s *GeminiWebState) Refresh(ctx context.Context) error {
|
||||
s.client.Cookies["__Secure-1PSIDTS"] = newTS
|
||||
}
|
||||
s.tokenMu.Unlock()
|
||||
// Detailed debug log: provider and account.
|
||||
log.Debugf("gemini web account %s rotated 1PSIDTS: %s", s.accountID, MaskToken28(newTS))
|
||||
// Detailed debug log: provider and account label.
|
||||
label := strings.TrimSpace(s.Label())
|
||||
if label == "" {
|
||||
label = s.accountID
|
||||
}
|
||||
log.Debugf("gemini web account %s rotated 1PSIDTS: %s", label, MaskToken28(newTS))
|
||||
}
|
||||
s.lastRefresh = time.Now()
|
||||
return nil
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
misc "github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
@@ -100,54 +101,265 @@ func (r *ModelRegistry) RegisterClient(clientID, clientProvider string, models [
|
||||
r.mutex.Lock()
|
||||
defer r.mutex.Unlock()
|
||||
|
||||
// Remove any existing registration for this client
|
||||
r.unregisterClientInternal(clientID)
|
||||
|
||||
provider := strings.ToLower(clientProvider)
|
||||
modelIDs := make([]string, 0, len(models))
|
||||
uniqueModelIDs := make([]string, 0, len(models))
|
||||
rawModelIDs := make([]string, 0, len(models))
|
||||
newModels := make(map[string]*ModelInfo, len(models))
|
||||
newCounts := make(map[string]int, len(models))
|
||||
for _, model := range models {
|
||||
if model == nil || model.ID == "" {
|
||||
continue
|
||||
}
|
||||
rawModelIDs = append(rawModelIDs, model.ID)
|
||||
newCounts[model.ID]++
|
||||
if _, exists := newModels[model.ID]; exists {
|
||||
continue
|
||||
}
|
||||
newModels[model.ID] = model
|
||||
uniqueModelIDs = append(uniqueModelIDs, model.ID)
|
||||
}
|
||||
|
||||
if len(uniqueModelIDs) == 0 {
|
||||
// No models supplied; unregister existing client state if present.
|
||||
r.unregisterClientInternal(clientID)
|
||||
delete(r.clientModels, clientID)
|
||||
delete(r.clientProviders, clientID)
|
||||
misc.LogCredentialSeparator()
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
for _, model := range models {
|
||||
modelIDs = append(modelIDs, model.ID)
|
||||
|
||||
if existing, exists := r.models[model.ID]; exists {
|
||||
// Model already exists, increment count
|
||||
existing.Count++
|
||||
existing.LastUpdated = now
|
||||
if existing.SuspendedClients == nil {
|
||||
existing.SuspendedClients = make(map[string]string)
|
||||
}
|
||||
if provider != "" {
|
||||
if existing.Providers == nil {
|
||||
existing.Providers = make(map[string]int)
|
||||
}
|
||||
existing.Providers[provider]++
|
||||
}
|
||||
log.Debugf("Incremented count for model %s, now %d clients", model.ID, existing.Count)
|
||||
oldModels, hadExisting := r.clientModels[clientID]
|
||||
oldProvider, _ := r.clientProviders[clientID]
|
||||
providerChanged := oldProvider != provider
|
||||
if !hadExisting {
|
||||
// Pure addition path.
|
||||
for _, modelID := range rawModelIDs {
|
||||
model := newModels[modelID]
|
||||
r.addModelRegistration(modelID, provider, model, now)
|
||||
}
|
||||
r.clientModels[clientID] = append([]string(nil), rawModelIDs...)
|
||||
if provider != "" {
|
||||
r.clientProviders[clientID] = provider
|
||||
} else {
|
||||
// New model, create registration
|
||||
registration := &ModelRegistration{
|
||||
Info: model,
|
||||
Count: 1,
|
||||
LastUpdated: now,
|
||||
QuotaExceededClients: make(map[string]*time.Time),
|
||||
SuspendedClients: make(map[string]string),
|
||||
}
|
||||
if provider != "" {
|
||||
registration.Providers = map[string]int{provider: 1}
|
||||
}
|
||||
r.models[model.ID] = registration
|
||||
log.Debugf("Registered new model %s from provider %s", model.ID, clientProvider)
|
||||
delete(r.clientProviders, clientID)
|
||||
}
|
||||
log.Debugf("Registered client %s from provider %s with %d models", clientID, clientProvider, len(rawModelIDs))
|
||||
misc.LogCredentialSeparator()
|
||||
return
|
||||
}
|
||||
|
||||
oldCounts := make(map[string]int, len(oldModels))
|
||||
for _, id := range oldModels {
|
||||
oldCounts[id]++
|
||||
}
|
||||
|
||||
added := make([]string, 0)
|
||||
for _, id := range uniqueModelIDs {
|
||||
if oldCounts[id] == 0 {
|
||||
added = append(added, id)
|
||||
}
|
||||
}
|
||||
|
||||
r.clientModels[clientID] = modelIDs
|
||||
removed := make([]string, 0)
|
||||
for id := range oldCounts {
|
||||
if newCounts[id] == 0 {
|
||||
removed = append(removed, id)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle provider change for overlapping models before modifications.
|
||||
if providerChanged && oldProvider != "" {
|
||||
for id, newCount := range newCounts {
|
||||
if newCount == 0 {
|
||||
continue
|
||||
}
|
||||
oldCount := oldCounts[id]
|
||||
if oldCount == 0 {
|
||||
continue
|
||||
}
|
||||
toRemove := newCount
|
||||
if oldCount < toRemove {
|
||||
toRemove = oldCount
|
||||
}
|
||||
if reg, ok := r.models[id]; ok && reg.Providers != nil {
|
||||
if count, okProv := reg.Providers[oldProvider]; okProv {
|
||||
if count <= toRemove {
|
||||
delete(reg.Providers, oldProvider)
|
||||
} else {
|
||||
reg.Providers[oldProvider] = count - toRemove
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply removals first to keep counters accurate.
|
||||
for _, id := range removed {
|
||||
oldCount := oldCounts[id]
|
||||
for i := 0; i < oldCount; i++ {
|
||||
r.removeModelRegistration(clientID, id, oldProvider, now)
|
||||
}
|
||||
}
|
||||
|
||||
for id, oldCount := range oldCounts {
|
||||
newCount := newCounts[id]
|
||||
if newCount == 0 || oldCount <= newCount {
|
||||
continue
|
||||
}
|
||||
overage := oldCount - newCount
|
||||
for i := 0; i < overage; i++ {
|
||||
r.removeModelRegistration(clientID, id, oldProvider, now)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply additions.
|
||||
for id, newCount := range newCounts {
|
||||
oldCount := oldCounts[id]
|
||||
if newCount <= oldCount {
|
||||
continue
|
||||
}
|
||||
model := newModels[id]
|
||||
diff := newCount - oldCount
|
||||
for i := 0; i < diff; i++ {
|
||||
r.addModelRegistration(id, provider, model, now)
|
||||
}
|
||||
}
|
||||
|
||||
// Update metadata for models that remain associated with the client.
|
||||
addedSet := make(map[string]struct{}, len(added))
|
||||
for _, id := range added {
|
||||
addedSet[id] = struct{}{}
|
||||
}
|
||||
for _, id := range uniqueModelIDs {
|
||||
model := newModels[id]
|
||||
if reg, ok := r.models[id]; ok {
|
||||
reg.Info = cloneModelInfo(model)
|
||||
reg.LastUpdated = now
|
||||
if reg.QuotaExceededClients != nil {
|
||||
delete(reg.QuotaExceededClients, clientID)
|
||||
}
|
||||
if reg.SuspendedClients != nil {
|
||||
delete(reg.SuspendedClients, clientID)
|
||||
}
|
||||
if providerChanged && provider != "" {
|
||||
if _, newlyAdded := addedSet[id]; newlyAdded {
|
||||
continue
|
||||
}
|
||||
overlapCount := newCounts[id]
|
||||
if oldCount := oldCounts[id]; oldCount < overlapCount {
|
||||
overlapCount = oldCount
|
||||
}
|
||||
if overlapCount <= 0 {
|
||||
continue
|
||||
}
|
||||
if reg.Providers == nil {
|
||||
reg.Providers = make(map[string]int)
|
||||
}
|
||||
reg.Providers[provider] += overlapCount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update client bookkeeping.
|
||||
if len(rawModelIDs) > 0 {
|
||||
r.clientModels[clientID] = append([]string(nil), rawModelIDs...)
|
||||
}
|
||||
if provider != "" {
|
||||
r.clientProviders[clientID] = provider
|
||||
} else {
|
||||
delete(r.clientProviders, clientID)
|
||||
}
|
||||
log.Debugf("Registered client %s from provider %s with %d models", clientID, clientProvider, len(models))
|
||||
|
||||
if len(added) == 0 && len(removed) == 0 && !providerChanged {
|
||||
// Only metadata (e.g., display name) changed; skip separator when no log output.
|
||||
return
|
||||
}
|
||||
|
||||
log.Debugf("Reconciled client %s (provider %s) models: +%d, -%d", clientID, provider, len(added), len(removed))
|
||||
misc.LogCredentialSeparator()
|
||||
}
|
||||
|
||||
func (r *ModelRegistry) addModelRegistration(modelID, provider string, model *ModelInfo, now time.Time) {
|
||||
if model == nil || modelID == "" {
|
||||
return
|
||||
}
|
||||
if existing, exists := r.models[modelID]; exists {
|
||||
existing.Count++
|
||||
existing.LastUpdated = now
|
||||
existing.Info = cloneModelInfo(model)
|
||||
if existing.SuspendedClients == nil {
|
||||
existing.SuspendedClients = make(map[string]string)
|
||||
}
|
||||
if provider != "" {
|
||||
if existing.Providers == nil {
|
||||
existing.Providers = make(map[string]int)
|
||||
}
|
||||
existing.Providers[provider]++
|
||||
}
|
||||
log.Debugf("Incremented count for model %s, now %d clients", modelID, existing.Count)
|
||||
return
|
||||
}
|
||||
|
||||
registration := &ModelRegistration{
|
||||
Info: cloneModelInfo(model),
|
||||
Count: 1,
|
||||
LastUpdated: now,
|
||||
QuotaExceededClients: make(map[string]*time.Time),
|
||||
SuspendedClients: make(map[string]string),
|
||||
}
|
||||
if provider != "" {
|
||||
registration.Providers = map[string]int{provider: 1}
|
||||
}
|
||||
r.models[modelID] = registration
|
||||
log.Debugf("Registered new model %s from provider %s", modelID, provider)
|
||||
}
|
||||
|
||||
func (r *ModelRegistry) removeModelRegistration(clientID, modelID, provider string, now time.Time) {
|
||||
registration, exists := r.models[modelID]
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
registration.Count--
|
||||
registration.LastUpdated = now
|
||||
if registration.QuotaExceededClients != nil {
|
||||
delete(registration.QuotaExceededClients, clientID)
|
||||
}
|
||||
if registration.SuspendedClients != nil {
|
||||
delete(registration.SuspendedClients, clientID)
|
||||
}
|
||||
if registration.Count < 0 {
|
||||
registration.Count = 0
|
||||
}
|
||||
if provider != "" && registration.Providers != nil {
|
||||
if count, ok := registration.Providers[provider]; ok {
|
||||
if count <= 1 {
|
||||
delete(registration.Providers, provider)
|
||||
} else {
|
||||
registration.Providers[provider] = count - 1
|
||||
}
|
||||
}
|
||||
}
|
||||
log.Debugf("Decremented count for model %s, now %d clients", modelID, registration.Count)
|
||||
if registration.Count <= 0 {
|
||||
delete(r.models, modelID)
|
||||
log.Debugf("Removed model %s as no clients remain", modelID)
|
||||
}
|
||||
}
|
||||
|
||||
func cloneModelInfo(model *ModelInfo) *ModelInfo {
|
||||
if model == nil {
|
||||
return nil
|
||||
}
|
||||
copy := *model
|
||||
if len(model.SupportedGenerationMethods) > 0 {
|
||||
copy.SupportedGenerationMethods = append([]string(nil), model.SupportedGenerationMethods...)
|
||||
}
|
||||
if len(model.SupportedParameters) > 0 {
|
||||
copy.SupportedParameters = append([]string(nil), model.SupportedParameters...)
|
||||
}
|
||||
return ©
|
||||
}
|
||||
|
||||
// UnregisterClient removes a client and decrements counts for its models
|
||||
@@ -207,6 +419,8 @@ func (r *ModelRegistry) unregisterClientInternal(clientID string) {
|
||||
delete(r.clientProviders, clientID)
|
||||
}
|
||||
log.Debugf("Unregistered client %s", clientID)
|
||||
// Separator line after completing client unregistration (after the summary line)
|
||||
misc.LogCredentialSeparator()
|
||||
}
|
||||
|
||||
// SetModelQuotaExceeded marks a model as quota exceeded for a specific client
|
||||
|
||||
@@ -284,6 +284,7 @@ func hasZSTDEcoding(contentEncoding string) bool {
|
||||
func applyClaudeHeaders(r *http.Request, apiKey string, stream bool) {
|
||||
r.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
r.Header.Set("Content-Type", "application/json")
|
||||
r.Header.Set("Anthropic-Beta", "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14")
|
||||
|
||||
var ginHeaders http.Header
|
||||
if ginCtx, ok := r.Context().Value("gin").(*gin.Context); ok && ginCtx != nil && ginCtx.Request != nil {
|
||||
@@ -292,7 +293,6 @@ func applyClaudeHeaders(r *http.Request, apiKey string, stream bool) {
|
||||
|
||||
misc.EnsureHeader(r.Header, ginHeaders, "Anthropic-Version", "2023-06-01")
|
||||
misc.EnsureHeader(r.Header, ginHeaders, "Anthropic-Dangerous-Direct-Browser-Access", "true")
|
||||
misc.EnsureHeader(r.Header, ginHeaders, "Anthropic-Beta", "claude-code-20250219,oauth-2025-04-20,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14")
|
||||
misc.EnsureHeader(r.Header, ginHeaders, "X-App", "cli")
|
||||
misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Helper-Method", "stream")
|
||||
misc.EnsureHeader(r.Header, ginHeaders, "X-Stainless-Retry-Count", "0")
|
||||
|
||||
@@ -54,8 +54,6 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
|
||||
if util.InArray([]string{"gpt-5", "gpt-5-minimal", "gpt-5-low", "gpt-5-medium", "gpt-5-high"}, req.Model) {
|
||||
body, _ = sjson.SetBytes(body, "model", "gpt-5")
|
||||
switch req.Model {
|
||||
case "gpt-5":
|
||||
body, _ = sjson.DeleteBytes(body, "reasoning.effort")
|
||||
case "gpt-5-minimal":
|
||||
body, _ = sjson.SetBytes(body, "reasoning.effort", "minimal")
|
||||
case "gpt-5-low":
|
||||
@@ -68,8 +66,6 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
|
||||
} else if util.InArray([]string{"gpt-5-codex", "gpt-5-codex-low", "gpt-5-codex-medium", "gpt-5-codex-high"}, req.Model) {
|
||||
body, _ = sjson.SetBytes(body, "model", "gpt-5-codex")
|
||||
switch req.Model {
|
||||
case "gpt-5-codex":
|
||||
body, _ = sjson.DeleteBytes(body, "reasoning.effort")
|
||||
case "gpt-5-codex-low":
|
||||
body, _ = sjson.SetBytes(body, "reasoning.effort", "low")
|
||||
case "gpt-5-codex-medium":
|
||||
@@ -147,8 +143,6 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
|
||||
if util.InArray([]string{"gpt-5", "gpt-5-minimal", "gpt-5-low", "gpt-5-medium", "gpt-5-high"}, req.Model) {
|
||||
body, _ = sjson.SetBytes(body, "model", "gpt-5")
|
||||
switch req.Model {
|
||||
case "gpt-5":
|
||||
body, _ = sjson.DeleteBytes(body, "reasoning.effort")
|
||||
case "gpt-5-minimal":
|
||||
body, _ = sjson.SetBytes(body, "reasoning.effort", "minimal")
|
||||
case "gpt-5-low":
|
||||
@@ -161,8 +155,6 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
|
||||
} else if util.InArray([]string{"gpt-5-codex", "gpt-5-codex-low", "gpt-5-codex-medium", "gpt-5-codex-high"}, req.Model) {
|
||||
body, _ = sjson.SetBytes(body, "model", "gpt-5-codex")
|
||||
switch req.Model {
|
||||
case "gpt-5-codex":
|
||||
body, _ = sjson.DeleteBytes(body, "reasoning.effort")
|
||||
case "gpt-5-codex-low":
|
||||
body, _ = sjson.SetBytes(body, "reasoning.effort", "low")
|
||||
case "gpt-5-codex-medium":
|
||||
|
||||
@@ -320,7 +320,7 @@ func (e *GeminiExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (
|
||||
conf := &oauth2.Config{ClientID: clientID, ClientSecret: clientSecret, Endpoint: endpoint}
|
||||
|
||||
// Ensure proxy-aware HTTP client for token refresh
|
||||
httpClient := util.SetProxy(e.cfg, &http.Client{})
|
||||
httpClient := util.SetProxy(&e.cfg.SDKConfig, &http.Client{})
|
||||
ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient)
|
||||
|
||||
// Build base token
|
||||
|
||||
@@ -200,7 +200,8 @@ func parseGeminiWebToken(auth *cliproxyauth.Auth) (*gemini.GeminiWebTokenStorage
|
||||
if psid == "" || psidts == "" {
|
||||
return nil, fmt.Errorf("gemini-web executor: incomplete cookie metadata")
|
||||
}
|
||||
return &gemini.GeminiWebTokenStorage{Secure1PSID: psid, Secure1PSIDTS: psidts}, nil
|
||||
label := strings.TrimSpace(stringFromMetadata(auth.Metadata, "label"))
|
||||
return &gemini.GeminiWebTokenStorage{Secure1PSID: psid, Secure1PSIDTS: psidts, Label: label}, nil
|
||||
}
|
||||
|
||||
func stringFromMetadata(meta map[string]any, keys ...string) string {
|
||||
|
||||
@@ -8,15 +8,24 @@ package gemini
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
var (
|
||||
user = ""
|
||||
account = ""
|
||||
session = ""
|
||||
)
|
||||
|
||||
// ConvertGeminiRequestToClaude parses and transforms a Gemini API request into Claude Code API format.
|
||||
// It extracts the model name, system instruction, message contents, and tool declarations
|
||||
// from the raw JSON request and returns them in the format expected by the Claude Code API.
|
||||
@@ -37,8 +46,23 @@ import (
|
||||
// - []byte: The transformed request data in Claude Code API format
|
||||
func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream bool) []byte {
|
||||
rawJSON := bytes.Clone(inputRawJSON)
|
||||
// Base Claude Code API template with default max_tokens value
|
||||
out := `{"model":"","max_tokens":32000,"messages":[]}`
|
||||
|
||||
if account == "" {
|
||||
u, _ := uuid.NewRandom()
|
||||
account = u.String()
|
||||
}
|
||||
if session == "" {
|
||||
u, _ := uuid.NewRandom()
|
||||
session = u.String()
|
||||
}
|
||||
if user == "" {
|
||||
sum := sha256.Sum256([]byte(account + session))
|
||||
user = hex.EncodeToString(sum[:])
|
||||
}
|
||||
userID := fmt.Sprintf("user_%s_account_%s_session_%s", user, account, session)
|
||||
|
||||
// Base Claude message payload
|
||||
out := fmt.Sprintf(`{"model":"","max_tokens":32000,"messages":[],"metadata":{"user_id":"%s"}}`, userID)
|
||||
|
||||
root := gjson.ParseBytes(rawJSON)
|
||||
|
||||
|
||||
@@ -8,14 +8,24 @@ package chat_completions
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
var (
|
||||
user = ""
|
||||
account = ""
|
||||
session = ""
|
||||
)
|
||||
|
||||
// ConvertOpenAIRequestToClaude parses and transforms an OpenAI Chat Completions API request into Claude Code API format.
|
||||
// It extracts the model name, system instruction, message contents, and tool declarations
|
||||
// from the raw JSON request and returns them in the format expected by the Claude Code API.
|
||||
@@ -36,8 +46,22 @@ import (
|
||||
func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream bool) []byte {
|
||||
rawJSON := bytes.Clone(inputRawJSON)
|
||||
|
||||
if account == "" {
|
||||
u, _ := uuid.NewRandom()
|
||||
account = u.String()
|
||||
}
|
||||
if session == "" {
|
||||
u, _ := uuid.NewRandom()
|
||||
session = u.String()
|
||||
}
|
||||
if user == "" {
|
||||
sum := sha256.Sum256([]byte(account + session))
|
||||
user = hex.EncodeToString(sum[:])
|
||||
}
|
||||
userID := fmt.Sprintf("user_%s_account_%s_session_%s", user, account, session)
|
||||
|
||||
// Base Claude Code API template with default max_tokens value
|
||||
out := `{"model":"","max_tokens":32000,"messages":[]}`
|
||||
out := fmt.Sprintf(`{"model":"","max_tokens":32000,"messages":[],"metadata":{"user_id":"%s"}}`, userID)
|
||||
|
||||
root := gjson.ParseBytes(rawJSON)
|
||||
|
||||
|
||||
@@ -3,13 +3,23 @@ package responses
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
var (
|
||||
user = ""
|
||||
account = ""
|
||||
session = ""
|
||||
)
|
||||
|
||||
// ConvertOpenAIResponsesRequestToClaude transforms an OpenAI Responses API request
|
||||
// into a Claude Messages API request using only gjson/sjson for JSON handling.
|
||||
// It supports:
|
||||
@@ -23,8 +33,22 @@ import (
|
||||
func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte, stream bool) []byte {
|
||||
rawJSON := bytes.Clone(inputRawJSON)
|
||||
|
||||
if account == "" {
|
||||
u, _ := uuid.NewRandom()
|
||||
account = u.String()
|
||||
}
|
||||
if session == "" {
|
||||
u, _ := uuid.NewRandom()
|
||||
session = u.String()
|
||||
}
|
||||
if user == "" {
|
||||
sum := sha256.Sum256([]byte(account + session))
|
||||
user = hex.EncodeToString(sum[:])
|
||||
}
|
||||
userID := fmt.Sprintf("user_%s_account_%s_session_%s", user, account, session)
|
||||
|
||||
// Base Claude message payload
|
||||
out := `{"model":"","max_tokens":32000,"messages":[]}`
|
||||
out := fmt.Sprintf(`{"model":"","max_tokens":32000,"messages":[],"metadata":{"user_id":"%s"}}`, userID)
|
||||
|
||||
root := gjson.ParseBytes(rawJSON)
|
||||
|
||||
|
||||
@@ -32,6 +32,10 @@ type claudeToResponsesState struct {
|
||||
ReasoningBuf strings.Builder
|
||||
ReasoningPartAdded bool
|
||||
ReasoningIndex int
|
||||
// usage aggregation
|
||||
InputTokens int64
|
||||
OutputTokens int64
|
||||
UsageSeen bool
|
||||
}
|
||||
|
||||
var dataTag = []byte("data:")
|
||||
@@ -77,6 +81,19 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
|
||||
st.FuncArgsBuf = make(map[int]*strings.Builder)
|
||||
st.FuncNames = make(map[int]string)
|
||||
st.FuncCallIDs = make(map[int]string)
|
||||
st.InputTokens = 0
|
||||
st.OutputTokens = 0
|
||||
st.UsageSeen = false
|
||||
if usage := msg.Get("usage"); usage.Exists() {
|
||||
if v := usage.Get("input_tokens"); v.Exists() {
|
||||
st.InputTokens = v.Int()
|
||||
st.UsageSeen = true
|
||||
}
|
||||
if v := usage.Get("output_tokens"); v.Exists() {
|
||||
st.OutputTokens = v.Int()
|
||||
st.UsageSeen = true
|
||||
}
|
||||
}
|
||||
// response.created
|
||||
created := `{"type":"response.created","sequence_number":0,"response":{"id":"","object":"response","created_at":0,"status":"in_progress","background":false,"error":null,"instructions":""}}`
|
||||
created, _ = sjson.Set(created, "sequence_number", nextSeq())
|
||||
@@ -227,7 +244,6 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
|
||||
out = append(out, emitEvent("response.output_item.done", itemDone))
|
||||
st.InFuncBlock = false
|
||||
} else if st.ReasoningActive {
|
||||
// close reasoning
|
||||
full := st.ReasoningBuf.String()
|
||||
textDone := `{"type":"response.reasoning_summary_text.done","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"text":""}`
|
||||
textDone, _ = sjson.Set(textDone, "sequence_number", nextSeq())
|
||||
@@ -244,7 +260,19 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
|
||||
st.ReasoningActive = false
|
||||
st.ReasoningPartAdded = false
|
||||
}
|
||||
case "message_delta":
|
||||
if usage := root.Get("usage"); usage.Exists() {
|
||||
if v := usage.Get("output_tokens"); v.Exists() {
|
||||
st.OutputTokens = v.Int()
|
||||
st.UsageSeen = true
|
||||
}
|
||||
if v := usage.Get("input_tokens"); v.Exists() {
|
||||
st.InputTokens = v.Int()
|
||||
st.UsageSeen = true
|
||||
}
|
||||
}
|
||||
case "message_stop":
|
||||
|
||||
completed := `{"type":"response.completed","sequence_number":0,"response":{"id":"","object":"response","created_at":0,"status":"completed","background":false,"error":null}}`
|
||||
completed, _ = sjson.Set(completed, "sequence_number", nextSeq())
|
||||
completed, _ = sjson.Set(completed, "response.id", st.ResponseID)
|
||||
@@ -381,6 +409,24 @@ func ConvertClaudeResponseToOpenAIResponses(ctx context.Context, modelName strin
|
||||
if len(outputs) > 0 {
|
||||
completed, _ = sjson.Set(completed, "response.output", outputs)
|
||||
}
|
||||
|
||||
reasoningTokens := int64(0)
|
||||
if st.ReasoningBuf.Len() > 0 {
|
||||
reasoningTokens = int64(st.ReasoningBuf.Len() / 4)
|
||||
}
|
||||
usagePresent := st.UsageSeen || reasoningTokens > 0
|
||||
if usagePresent {
|
||||
completed, _ = sjson.Set(completed, "response.usage.input_tokens", st.InputTokens)
|
||||
completed, _ = sjson.Set(completed, "response.usage.input_tokens_details.cached_tokens", 0)
|
||||
completed, _ = sjson.Set(completed, "response.usage.output_tokens", st.OutputTokens)
|
||||
if reasoningTokens > 0 {
|
||||
completed, _ = sjson.Set(completed, "response.usage.output_tokens_details.reasoning_tokens", reasoningTokens)
|
||||
}
|
||||
total := st.InputTokens + st.OutputTokens
|
||||
if total > 0 || st.UsageSeen {
|
||||
completed, _ = sjson.Set(completed, "response.usage.total_tokens", total)
|
||||
}
|
||||
}
|
||||
out = append(out, emitEvent("response.completed", completed))
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,9 @@ func ConvertOpenAIResponsesRequestToCodex(modelName string, inputRawJSON []byte,
|
||||
rawJSON, _ = sjson.SetBytes(rawJSON, "store", false)
|
||||
rawJSON, _ = sjson.SetBytes(rawJSON, "parallel_tool_calls", true)
|
||||
rawJSON, _ = sjson.SetBytes(rawJSON, "include", []string{"reasoning.encrypted_content"})
|
||||
// Codex Responses rejects token limit fields, so strip them out before forwarding.
|
||||
rawJSON, _ = sjson.DeleteBytes(rawJSON, "max_output_tokens")
|
||||
rawJSON, _ = sjson.DeleteBytes(rawJSON, "max_completion_tokens")
|
||||
rawJSON, _ = sjson.DeleteBytes(rawJSON, "temperature")
|
||||
rawJSON, _ = sjson.DeleteBytes(rawJSON, "top_p")
|
||||
|
||||
|
||||
@@ -25,7 +25,6 @@ import (
|
||||
// Returns:
|
||||
// - []byte: The transformed request data in Gemini CLI API format
|
||||
func ConvertOpenAIRequestToGeminiCLI(modelName string, inputRawJSON []byte, _ bool) []byte {
|
||||
log.Debug("ConvertOpenAIRequestToGeminiCLI")
|
||||
rawJSON := bytes.Clone(inputRawJSON)
|
||||
// Base envelope
|
||||
out := []byte(`{"project":"","request":{"contents":[],"generationConfig":{"thinkingConfig":{"include_thoughts":true}}},"model":"gemini-2.5-pro"}`)
|
||||
|
||||
@@ -78,12 +78,21 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
|
||||
textDone, _ = sjson.Set(textDone, "output_index", st.ReasoningIndex)
|
||||
textDone, _ = sjson.Set(textDone, "text", full)
|
||||
out = append(out, emitEvent("response.reasoning_summary_text.done", textDone))
|
||||
|
||||
partDone := `{"type":"response.reasoning_summary_part.done","sequence_number":0,"item_id":"","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":""}}`
|
||||
partDone, _ = sjson.Set(partDone, "sequence_number", nextSeq())
|
||||
partDone, _ = sjson.Set(partDone, "item_id", st.ReasoningItemID)
|
||||
partDone, _ = sjson.Set(partDone, "output_index", st.ReasoningIndex)
|
||||
partDone, _ = sjson.Set(partDone, "part.text", full)
|
||||
out = append(out, emitEvent("response.reasoning_summary_part.done", partDone))
|
||||
|
||||
itemDone := `{"type":"response.output_item.done","sequence_number":0,"output_index":0,"item":{"id":"","type":"reasoning","encrypted_content":"","summary":[{"type":"summary_text","text":""}]}}`
|
||||
itemDone, _ = sjson.Set(itemDone, "sequence_number", nextSeq())
|
||||
itemDone, _ = sjson.Set(itemDone, "item.id", st.ReasoningItemID)
|
||||
itemDone, _ = sjson.Set(itemDone, "output_index", st.ReasoningIndex)
|
||||
itemDone, _ = sjson.Set(itemDone, "item.summary.0.text", full)
|
||||
out = append(out, emitEvent("response.output_item.done", itemDone))
|
||||
|
||||
st.ReasoningClosed = true
|
||||
}
|
||||
|
||||
@@ -414,6 +423,25 @@ func ConvertGeminiResponseToOpenAIResponses(_ context.Context, modelName string,
|
||||
completed, _ = sjson.Set(completed, "response.output", outputs)
|
||||
}
|
||||
|
||||
// usage mapping
|
||||
if um := root.Get("usageMetadata"); um.Exists() {
|
||||
// input tokens = prompt + thoughts
|
||||
input := um.Get("promptTokenCount").Int() + um.Get("thoughtsTokenCount").Int()
|
||||
completed, _ = sjson.Set(completed, "response.usage.input_tokens", input)
|
||||
// cached_tokens not provided by Gemini; default to 0 for structure compatibility
|
||||
completed, _ = sjson.Set(completed, "response.usage.input_tokens_details.cached_tokens", 0)
|
||||
// output tokens
|
||||
if v := um.Get("candidatesTokenCount"); v.Exists() {
|
||||
completed, _ = sjson.Set(completed, "response.usage.output_tokens", v.Int())
|
||||
}
|
||||
if v := um.Get("thoughtsTokenCount"); v.Exists() {
|
||||
completed, _ = sjson.Set(completed, "response.usage.output_tokens_details.reasoning_tokens", v.Int())
|
||||
}
|
||||
if v := um.Get("totalTokenCount"); v.Exists() {
|
||||
completed, _ = sjson.Set(completed, "response.usage.total_tokens", v.Int())
|
||||
}
|
||||
}
|
||||
|
||||
out = append(out, emitEvent("response.completed", completed))
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,13 @@ type oaiToResponsesState struct {
|
||||
// function item done state
|
||||
FuncArgsDone map[int]bool
|
||||
FuncItemDone map[int]bool
|
||||
// usage aggregation
|
||||
PromptTokens int64
|
||||
CachedTokens int64
|
||||
CompletionTokens int64
|
||||
TotalTokens int64
|
||||
ReasoningTokens int64
|
||||
UsageSeen bool
|
||||
}
|
||||
|
||||
func emitRespEvent(event string, payload string) string {
|
||||
@@ -66,6 +73,35 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context,
|
||||
return []string{}
|
||||
}
|
||||
|
||||
if usage := root.Get("usage"); usage.Exists() {
|
||||
if v := usage.Get("prompt_tokens"); v.Exists() {
|
||||
st.PromptTokens = v.Int()
|
||||
st.UsageSeen = true
|
||||
}
|
||||
if v := usage.Get("prompt_tokens_details.cached_tokens"); v.Exists() {
|
||||
st.CachedTokens = v.Int()
|
||||
st.UsageSeen = true
|
||||
}
|
||||
if v := usage.Get("completion_tokens"); v.Exists() {
|
||||
st.CompletionTokens = v.Int()
|
||||
st.UsageSeen = true
|
||||
} else if v := usage.Get("output_tokens"); v.Exists() {
|
||||
st.CompletionTokens = v.Int()
|
||||
st.UsageSeen = true
|
||||
}
|
||||
if v := usage.Get("output_tokens_details.reasoning_tokens"); v.Exists() {
|
||||
st.ReasoningTokens = v.Int()
|
||||
st.UsageSeen = true
|
||||
} else if v := usage.Get("completion_tokens_details.reasoning_tokens"); v.Exists() {
|
||||
st.ReasoningTokens = v.Int()
|
||||
st.UsageSeen = true
|
||||
}
|
||||
if v := usage.Get("total_tokens"); v.Exists() {
|
||||
st.TotalTokens = v.Int()
|
||||
st.UsageSeen = true
|
||||
}
|
||||
}
|
||||
|
||||
nextSeq := func() int { st.Seq++; return st.Seq }
|
||||
var out []string
|
||||
|
||||
@@ -85,6 +121,12 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context,
|
||||
st.MsgItemDone = make(map[int]bool)
|
||||
st.FuncArgsDone = make(map[int]bool)
|
||||
st.FuncItemDone = make(map[int]bool)
|
||||
st.PromptTokens = 0
|
||||
st.CachedTokens = 0
|
||||
st.CompletionTokens = 0
|
||||
st.TotalTokens = 0
|
||||
st.ReasoningTokens = 0
|
||||
st.UsageSeen = false
|
||||
// response.created
|
||||
created := `{"type":"response.created","sequence_number":0,"response":{"id":"","object":"response","created_at":0,"status":"in_progress","background":false,"error":null}}`
|
||||
created, _ = sjson.Set(created, "sequence_number", nextSeq())
|
||||
@@ -503,6 +545,19 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context,
|
||||
if len(outputs) > 0 {
|
||||
completed, _ = sjson.Set(completed, "response.output", outputs)
|
||||
}
|
||||
if st.UsageSeen {
|
||||
completed, _ = sjson.Set(completed, "response.usage.input_tokens", st.PromptTokens)
|
||||
completed, _ = sjson.Set(completed, "response.usage.input_tokens_details.cached_tokens", st.CachedTokens)
|
||||
completed, _ = sjson.Set(completed, "response.usage.output_tokens", st.CompletionTokens)
|
||||
if st.ReasoningTokens > 0 {
|
||||
completed, _ = sjson.Set(completed, "response.usage.output_tokens_details.reasoning_tokens", st.ReasoningTokens)
|
||||
}
|
||||
total := st.TotalTokens
|
||||
if total == 0 {
|
||||
total = st.PromptTokens + st.CompletionTokens
|
||||
}
|
||||
completed, _ = sjson.Set(completed, "response.usage.total_tokens", total)
|
||||
}
|
||||
out = append(out, emitRespEvent("response.completed", completed))
|
||||
}
|
||||
|
||||
|
||||
@@ -7,13 +7,17 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
coreusage "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage"
|
||||
)
|
||||
|
||||
var statisticsEnabled atomic.Bool
|
||||
|
||||
func init() {
|
||||
statisticsEnabled.Store(true)
|
||||
coreusage.RegisterPlugin(NewLoggerPlugin())
|
||||
}
|
||||
|
||||
@@ -36,12 +40,21 @@ func NewLoggerPlugin() *LoggerPlugin { return &LoggerPlugin{stats: defaultReques
|
||||
// - ctx: The context for the usage record
|
||||
// - record: The usage record to aggregate
|
||||
func (p *LoggerPlugin) HandleUsage(ctx context.Context, record coreusage.Record) {
|
||||
if !statisticsEnabled.Load() {
|
||||
return
|
||||
}
|
||||
if p == nil || p.stats == nil {
|
||||
return
|
||||
}
|
||||
p.stats.Record(ctx, record)
|
||||
}
|
||||
|
||||
// SetStatisticsEnabled toggles whether in-memory statistics are recorded.
|
||||
func SetStatisticsEnabled(enabled bool) { statisticsEnabled.Store(enabled) }
|
||||
|
||||
// StatisticsEnabled reports the current recording state.
|
||||
func StatisticsEnabled() bool { return statisticsEnabled.Load() }
|
||||
|
||||
// RequestStatistics maintains aggregated request metrics in memory.
|
||||
type RequestStatistics struct {
|
||||
mu sync.RWMutex
|
||||
@@ -138,6 +151,9 @@ func (s *RequestStatistics) Record(ctx context.Context, record coreusage.Record)
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
if !statisticsEnabled.Load() {
|
||||
return
|
||||
}
|
||||
timestamp := record.RequestedAt
|
||||
if timestamp.IsZero() {
|
||||
timestamp = time.Now()
|
||||
|
||||
@@ -26,7 +26,7 @@ import (
|
||||
//
|
||||
// Returns:
|
||||
// - []string: All provider identifiers capable of serving the model, ordered by preference.
|
||||
func GetProviderName(modelName string, cfg *config.Config) []string {
|
||||
func GetProviderName(modelName string) []string {
|
||||
if modelName == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/net/proxy"
|
||||
)
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
// SetProxy configures the provided HTTP client with proxy settings from the configuration.
|
||||
// It supports SOCKS5, HTTP, and HTTPS proxies. The function modifies the client's transport
|
||||
// to route requests through the configured proxy server.
|
||||
func SetProxy(cfg *config.Config, httpClient *http.Client) *http.Client {
|
||||
func SetProxy(cfg *config.SDKConfig, httpClient *http.Client) *http.Client {
|
||||
var transport *http.Transport
|
||||
// Attempt to parse the proxy URL from the configuration.
|
||||
proxyURL, errParse := url.Parse(cfg.ProxyURL)
|
||||
|
||||
@@ -120,7 +120,7 @@ func GetIPAddress() string {
|
||||
func PrintSSHTunnelInstructions(port int) {
|
||||
ipAddress := GetIPAddress()
|
||||
border := "================================================================================"
|
||||
log.Infof("To authenticate from a remote machine, an SSH tunnel may be required.")
|
||||
fmt.Println("To authenticate from a remote machine, an SSH tunnel may be required.")
|
||||
fmt.Println(border)
|
||||
fmt.Println(" Run one of the following commands on your local machine (NOT the server):")
|
||||
fmt.Println()
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -30,23 +31,42 @@ func SetLogLevel(cfg *config.Config) {
|
||||
}
|
||||
}
|
||||
|
||||
// CountAuthFiles returns the number of JSON auth files located under the provided directory.
|
||||
// The function resolves leading tildes to the user's home directory and performs a case-insensitive
|
||||
// match on the ".json" suffix so that files saved with uppercase extensions are also counted.
|
||||
func CountAuthFiles(authDir string) int {
|
||||
// ResolveAuthDir normalizes the auth directory path for consistent reuse throughout the app.
|
||||
// It expands a leading tilde (~) to the user's home directory and returns a cleaned path.
|
||||
func ResolveAuthDir(authDir string) (string, error) {
|
||||
if authDir == "" {
|
||||
return 0
|
||||
return "", nil
|
||||
}
|
||||
if strings.HasPrefix(authDir, "~") {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
log.Debugf("countAuthFiles: failed to resolve home directory: %v", err)
|
||||
return 0
|
||||
return "", fmt.Errorf("resolve auth dir: %w", err)
|
||||
}
|
||||
authDir = filepath.Join(home, authDir[1:])
|
||||
remainder := strings.TrimPrefix(authDir, "~")
|
||||
remainder = strings.TrimLeft(remainder, "/\\")
|
||||
if remainder == "" {
|
||||
return filepath.Clean(home), nil
|
||||
}
|
||||
normalized := strings.ReplaceAll(remainder, "\\", "/")
|
||||
return filepath.Clean(filepath.Join(home, filepath.FromSlash(normalized))), nil
|
||||
}
|
||||
return filepath.Clean(authDir), nil
|
||||
}
|
||||
|
||||
// CountAuthFiles returns the number of JSON auth files located under the provided directory.
|
||||
// The function resolves leading tildes to the user's home directory and performs a case-insensitive
|
||||
// match on the ".json" suffix so that files saved with uppercase extensions are also counted.
|
||||
func CountAuthFiles(authDir string) int {
|
||||
dir, err := ResolveAuthDir(authDir)
|
||||
if err != nil {
|
||||
log.Debugf("countAuthFiles: failed to resolve auth directory: %v", err)
|
||||
return 0
|
||||
}
|
||||
if dir == "" {
|
||||
return 0
|
||||
}
|
||||
count := 0
|
||||
walkErr := filepath.WalkDir(authDir, func(path string, d fs.DirEntry, err error) error {
|
||||
walkErr := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
log.Debugf("countAuthFiles: error accessing %s: %v", path, err)
|
||||
return nil
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -52,6 +53,39 @@ type Watcher struct {
|
||||
dispatchCancel context.CancelFunc
|
||||
}
|
||||
|
||||
type stableIDGenerator struct {
|
||||
counters map[string]int
|
||||
}
|
||||
|
||||
func newStableIDGenerator() *stableIDGenerator {
|
||||
return &stableIDGenerator{counters: make(map[string]int)}
|
||||
}
|
||||
|
||||
func (g *stableIDGenerator) next(kind string, parts ...string) (string, string) {
|
||||
if g == nil {
|
||||
return kind + ":000000000000", "000000000000"
|
||||
}
|
||||
hasher := sha256.New()
|
||||
hasher.Write([]byte(kind))
|
||||
for _, part := range parts {
|
||||
trimmed := strings.TrimSpace(part)
|
||||
hasher.Write([]byte{0})
|
||||
hasher.Write([]byte(trimmed))
|
||||
}
|
||||
digest := hex.EncodeToString(hasher.Sum(nil))
|
||||
if len(digest) < 12 {
|
||||
digest = fmt.Sprintf("%012s", digest)
|
||||
}
|
||||
short := digest[:12]
|
||||
key := kind + ":" + short
|
||||
index := g.counters[key]
|
||||
g.counters[key] = index + 1
|
||||
if index > 0 {
|
||||
short = fmt.Sprintf("%s-%d", short, index)
|
||||
}
|
||||
return fmt.Sprintf("%s:%s", kind, short), short
|
||||
}
|
||||
|
||||
// AuthUpdateAction represents the type of change detected in auth sources.
|
||||
type AuthUpdateAction string
|
||||
|
||||
@@ -112,7 +146,7 @@ func (w *Watcher) Start(ctx context.Context) error {
|
||||
go w.processEvents(ctx)
|
||||
|
||||
// Perform an initial full reload based on current config and auth dir
|
||||
w.reloadClients()
|
||||
w.reloadClients(true)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -320,6 +354,20 @@ func normalizeAuth(a *coreauth.Auth) *coreauth.Auth {
|
||||
return clone
|
||||
}
|
||||
|
||||
// computeOpenAICompatModelsHash returns a stable hash for the compatibility models so that
|
||||
// changes to the model list trigger auth updates during hot reload.
|
||||
func computeOpenAICompatModelsHash(models []config.OpenAICompatibilityModel) string {
|
||||
if len(models) == 0 {
|
||||
return ""
|
||||
}
|
||||
data, err := json.Marshal(models)
|
||||
if err != nil || len(data) == 0 {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256(data)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// SetClients sets the file-based clients.
|
||||
// SetClients removed
|
||||
// SetAPIKeyClients removed
|
||||
@@ -380,7 +428,7 @@ func (w *Watcher) handleEvent(event fsnotify.Event) {
|
||||
log.Debugf("config file content unchanged (hash match), skipping reload")
|
||||
return
|
||||
}
|
||||
log.Infof("config file changed, reloading: %s", w.configPath)
|
||||
fmt.Printf("config file changed, reloading: %s\n", w.configPath)
|
||||
if w.reloadConfig() {
|
||||
w.clientsMutex.Lock()
|
||||
w.lastConfigHash = newHash
|
||||
@@ -390,7 +438,7 @@ func (w *Watcher) handleEvent(event fsnotify.Event) {
|
||||
}
|
||||
|
||||
// Handle auth directory changes incrementally (.json only)
|
||||
log.Infof("auth file changed (%s): %s, processing incrementally", event.Op.String(), filepath.Base(event.Name))
|
||||
fmt.Printf("auth file changed (%s): %s, processing incrementally\n", event.Op.String(), filepath.Base(event.Name))
|
||||
if event.Op&fsnotify.Create == fsnotify.Create || event.Op&fsnotify.Write == fsnotify.Write {
|
||||
w.addOrUpdateClient(event.Name)
|
||||
} else if event.Op&fsnotify.Remove == fsnotify.Remove {
|
||||
@@ -408,6 +456,7 @@ func (w *Watcher) handleEvent(event fsnotify.Event) {
|
||||
|
||||
// reloadConfig reloads the configuration and triggers a full reload
|
||||
func (w *Watcher) reloadConfig() bool {
|
||||
log.Debug("=========================== CONFIG RELOAD ============================")
|
||||
log.Debugf("starting config reload from: %s", w.configPath)
|
||||
|
||||
newConfig, errLoadConfig := config.LoadConfig(w.configPath)
|
||||
@@ -416,6 +465,12 @@ func (w *Watcher) reloadConfig() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
if resolvedAuthDir, errResolveAuthDir := util.ResolveAuthDir(newConfig.AuthDir); errResolveAuthDir != nil {
|
||||
log.Errorf("failed to resolve auth directory from config: %v", errResolveAuthDir)
|
||||
} else {
|
||||
newConfig.AuthDir = resolvedAuthDir
|
||||
}
|
||||
|
||||
w.clientsMutex.Lock()
|
||||
oldConfig := w.config
|
||||
w.config = newConfig
|
||||
@@ -477,17 +532,31 @@ func (w *Watcher) reloadConfig() bool {
|
||||
if oldConfig.RemoteManagement.AllowRemote != newConfig.RemoteManagement.AllowRemote {
|
||||
log.Debugf(" remote-management.allow-remote: %t -> %t", oldConfig.RemoteManagement.AllowRemote, newConfig.RemoteManagement.AllowRemote)
|
||||
}
|
||||
if oldConfig.LoggingToFile != newConfig.LoggingToFile {
|
||||
log.Debugf(" logging-to-file: %t -> %t", oldConfig.LoggingToFile, newConfig.LoggingToFile)
|
||||
}
|
||||
if oldConfig.UsageStatisticsEnabled != newConfig.UsageStatisticsEnabled {
|
||||
log.Debugf(" usage-statistics-enabled: %t -> %t", oldConfig.UsageStatisticsEnabled, newConfig.UsageStatisticsEnabled)
|
||||
}
|
||||
if changes := diffOpenAICompatibility(oldConfig.OpenAICompatibility, newConfig.OpenAICompatibility); len(changes) > 0 {
|
||||
log.Debugf(" openai-compatibility:")
|
||||
for _, change := range changes {
|
||||
log.Debugf(" %s", change)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
authDirChanged := oldConfig == nil || oldConfig.AuthDir != newConfig.AuthDir
|
||||
|
||||
log.Infof("config successfully reloaded, triggering client reload")
|
||||
// Reload clients with new config
|
||||
w.reloadClients()
|
||||
w.reloadClients(authDirChanged)
|
||||
return true
|
||||
}
|
||||
|
||||
// reloadClients performs a full scan and reload of all clients.
|
||||
func (w *Watcher) reloadClients() {
|
||||
log.Debugf("starting full client reload process")
|
||||
func (w *Watcher) reloadClients(rescanAuth bool) {
|
||||
log.Debugf("starting full client load process")
|
||||
|
||||
w.clientsMutex.RLock()
|
||||
cfg := w.config
|
||||
@@ -503,40 +572,60 @@ func (w *Watcher) reloadClients() {
|
||||
|
||||
// Create new API key clients based on the new config
|
||||
glAPIKeyCount, claudeAPIKeyCount, codexAPIKeyCount, openAICompatCount := BuildAPIKeyClients(cfg)
|
||||
log.Debugf("created %d new API key clients", 0)
|
||||
totalAPIKeyClients := glAPIKeyCount + claudeAPIKeyCount + codexAPIKeyCount + openAICompatCount
|
||||
log.Debugf("loaded %d API key clients", totalAPIKeyClients)
|
||||
|
||||
// Load file-based clients
|
||||
authFileCount := w.loadFileClients(cfg)
|
||||
log.Debugf("loaded %d new file-based clients", 0)
|
||||
var authFileCount int
|
||||
if rescanAuth {
|
||||
// Load file-based clients when explicitly requested (startup or authDir change)
|
||||
authFileCount = w.loadFileClients(cfg)
|
||||
log.Debugf("loaded %d file-based clients", authFileCount)
|
||||
} else {
|
||||
// Preserve existing auth hashes and only report current known count to avoid redundant scans.
|
||||
w.clientsMutex.RLock()
|
||||
authFileCount = len(w.lastAuthHashes)
|
||||
w.clientsMutex.RUnlock()
|
||||
log.Debugf("skipping auth directory rescan; retaining %d existing auth files", authFileCount)
|
||||
}
|
||||
|
||||
// no legacy file-based clients to unregister
|
||||
|
||||
// Update client maps
|
||||
w.clientsMutex.Lock()
|
||||
if rescanAuth {
|
||||
w.clientsMutex.Lock()
|
||||
|
||||
// Rebuild auth file hash cache for current clients
|
||||
w.lastAuthHashes = make(map[string]string)
|
||||
// Recompute hashes for current auth files
|
||||
_ = filepath.Walk(cfg.AuthDir, func(path string, info fs.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
// Rebuild auth file hash cache for current clients
|
||||
w.lastAuthHashes = make(map[string]string)
|
||||
if resolvedAuthDir, errResolveAuthDir := util.ResolveAuthDir(cfg.AuthDir); errResolveAuthDir != nil {
|
||||
log.Errorf("failed to resolve auth directory for hash cache: %v", errResolveAuthDir)
|
||||
} else if resolvedAuthDir != "" {
|
||||
_ = filepath.Walk(resolvedAuthDir, func(path string, info fs.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if !info.IsDir() && strings.HasSuffix(strings.ToLower(info.Name()), ".json") {
|
||||
if data, errReadFile := os.ReadFile(path); errReadFile == nil && len(data) > 0 {
|
||||
sum := sha256.Sum256(data)
|
||||
w.lastAuthHashes[path] = hex.EncodeToString(sum[:])
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
if !info.IsDir() && strings.HasSuffix(strings.ToLower(info.Name()), ".json") {
|
||||
if data, errReadFile := os.ReadFile(path); errReadFile == nil && len(data) > 0 {
|
||||
sum := sha256.Sum256(data)
|
||||
w.lastAuthHashes[path] = hex.EncodeToString(sum[:])
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
w.clientsMutex.Unlock()
|
||||
w.clientsMutex.Unlock()
|
||||
}
|
||||
|
||||
totalNewClients := authFileCount + glAPIKeyCount + claudeAPIKeyCount + codexAPIKeyCount + openAICompatCount
|
||||
|
||||
// Ensure consumers observe the new configuration before auth updates dispatch.
|
||||
if w.reloadCallback != nil {
|
||||
log.Debugf("triggering server update callback before auth refresh")
|
||||
w.reloadCallback(cfg)
|
||||
}
|
||||
|
||||
w.refreshAuthState()
|
||||
|
||||
log.Infof("full client reload complete - old: %d clients, new: %d clients (%d auth files + %d GL API keys + %d Claude API keys + %d Codex keys + %d OpenAI-compat)",
|
||||
0,
|
||||
log.Infof("full client load complete - %d clients (%d auth files + %d GL API keys + %d Claude API keys + %d Codex keys + %d OpenAI-compat)",
|
||||
totalNewClients,
|
||||
authFileCount,
|
||||
glAPIKeyCount,
|
||||
@@ -544,12 +633,6 @@ func (w *Watcher) reloadClients() {
|
||||
codexAPIKeyCount,
|
||||
openAICompatCount,
|
||||
)
|
||||
|
||||
// Trigger the callback to update the server
|
||||
if w.reloadCallback != nil {
|
||||
log.Debugf("triggering server update callback")
|
||||
w.reloadCallback(cfg)
|
||||
}
|
||||
}
|
||||
|
||||
// createClientFromFile creates a single client instance from a given token file path.
|
||||
@@ -621,6 +704,7 @@ func (w *Watcher) removeClient(path string) {
|
||||
func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
|
||||
out := make([]*coreauth.Auth, 0, 32)
|
||||
now := time.Now()
|
||||
idGen := newStableIDGenerator()
|
||||
// Also synthesize auth entries for OpenAI-compatibility providers directly from config
|
||||
w.clientsMutex.RLock()
|
||||
cfg := w.config
|
||||
@@ -628,14 +712,18 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
|
||||
if cfg != nil {
|
||||
// Gemini official API keys -> synthesize auths
|
||||
for i := range cfg.GlAPIKey {
|
||||
k := cfg.GlAPIKey[i]
|
||||
k := strings.TrimSpace(cfg.GlAPIKey[i])
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
id, token := idGen.next("gemini:apikey", k)
|
||||
a := &coreauth.Auth{
|
||||
ID: fmt.Sprintf("gemini:apikey:%d", i),
|
||||
ID: id,
|
||||
Provider: "gemini",
|
||||
Label: "gemini-apikey",
|
||||
Status: coreauth.StatusActive,
|
||||
Attributes: map[string]string{
|
||||
"source": fmt.Sprintf("config:gemini#%d", i),
|
||||
"source": fmt.Sprintf("config:gemini[%s]", token),
|
||||
"api_key": k,
|
||||
},
|
||||
CreatedAt: now,
|
||||
@@ -646,15 +734,20 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
|
||||
// Claude API keys -> synthesize auths
|
||||
for i := range cfg.ClaudeKey {
|
||||
ck := cfg.ClaudeKey[i]
|
||||
key := strings.TrimSpace(ck.APIKey)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
id, token := idGen.next("claude:apikey", key, ck.BaseURL)
|
||||
attrs := map[string]string{
|
||||
"source": fmt.Sprintf("config:claude#%d", i),
|
||||
"api_key": ck.APIKey,
|
||||
"source": fmt.Sprintf("config:claude[%s]", token),
|
||||
"api_key": key,
|
||||
}
|
||||
if ck.BaseURL != "" {
|
||||
attrs["base_url"] = ck.BaseURL
|
||||
}
|
||||
a := &coreauth.Auth{
|
||||
ID: fmt.Sprintf("claude:apikey:%d", i),
|
||||
ID: id,
|
||||
Provider: "claude",
|
||||
Label: "claude-apikey",
|
||||
Status: coreauth.StatusActive,
|
||||
@@ -667,15 +760,20 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
|
||||
// Codex API keys -> synthesize auths
|
||||
for i := range cfg.CodexKey {
|
||||
ck := cfg.CodexKey[i]
|
||||
key := strings.TrimSpace(ck.APIKey)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
id, token := idGen.next("codex:apikey", key, ck.BaseURL)
|
||||
attrs := map[string]string{
|
||||
"source": fmt.Sprintf("config:codex#%d", i),
|
||||
"api_key": ck.APIKey,
|
||||
"source": fmt.Sprintf("config:codex[%s]", token),
|
||||
"api_key": key,
|
||||
}
|
||||
if ck.BaseURL != "" {
|
||||
attrs["base_url"] = ck.BaseURL
|
||||
}
|
||||
a := &coreauth.Auth{
|
||||
ID: fmt.Sprintf("codex:apikey:%d", i),
|
||||
ID: id,
|
||||
Provider: "codex",
|
||||
Label: "codex-apikey",
|
||||
Status: coreauth.StatusActive,
|
||||
@@ -691,23 +789,32 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
|
||||
if providerName == "" {
|
||||
providerName = "openai-compatibility"
|
||||
}
|
||||
base := compat.BaseURL
|
||||
base := strings.TrimSpace(compat.BaseURL)
|
||||
for j := range compat.APIKeys {
|
||||
key := compat.APIKeys[j]
|
||||
key := strings.TrimSpace(compat.APIKeys[j])
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
idKind := fmt.Sprintf("openai-compatibility:%s", providerName)
|
||||
id, token := idGen.next(idKind, key, base)
|
||||
attrs := map[string]string{
|
||||
"source": fmt.Sprintf("config:%s[%s]", providerName, token),
|
||||
"base_url": base,
|
||||
"api_key": key,
|
||||
"compat_name": compat.Name,
|
||||
"provider_key": providerName,
|
||||
}
|
||||
if hash := computeOpenAICompatModelsHash(compat.Models); hash != "" {
|
||||
attrs["models_hash"] = hash
|
||||
}
|
||||
a := &coreauth.Auth{
|
||||
ID: fmt.Sprintf("openai-compatibility:%s:%d", compat.Name, j),
|
||||
Provider: providerName,
|
||||
Label: compat.Name,
|
||||
Status: coreauth.StatusActive,
|
||||
Attributes: map[string]string{
|
||||
"source": fmt.Sprintf("config:%s#%d", compat.Name, j),
|
||||
"base_url": base,
|
||||
"api_key": key,
|
||||
"compat_name": compat.Name,
|
||||
"provider_key": providerName,
|
||||
},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
ID: id,
|
||||
Provider: providerName,
|
||||
Label: compat.Name,
|
||||
Status: coreauth.StatusActive,
|
||||
Attributes: attrs,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
@@ -779,14 +886,13 @@ func (w *Watcher) loadFileClients(cfg *config.Config) int {
|
||||
authFileCount := 0
|
||||
successfulAuthCount := 0
|
||||
|
||||
authDir := cfg.AuthDir
|
||||
if strings.HasPrefix(authDir, "~") {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
log.Errorf("failed to get home directory: %v", err)
|
||||
return 0
|
||||
}
|
||||
authDir = filepath.Join(home, authDir[1:])
|
||||
authDir, errResolveAuthDir := util.ResolveAuthDir(cfg.AuthDir)
|
||||
if errResolveAuthDir != nil {
|
||||
log.Errorf("failed to resolve auth directory: %v", errResolveAuthDir)
|
||||
return 0
|
||||
}
|
||||
if authDir == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
errWalk := filepath.Walk(authDir, func(path string, info fs.FileInfo, err error) error {
|
||||
@@ -836,3 +942,114 @@ func BuildAPIKeyClients(cfg *config.Config) (int, int, int, int) {
|
||||
}
|
||||
return glAPIKeyCount, claudeAPIKeyCount, codexAPIKeyCount, openAICompatCount
|
||||
}
|
||||
|
||||
func diffOpenAICompatibility(oldList, newList []config.OpenAICompatibility) []string {
|
||||
changes := make([]string, 0)
|
||||
oldMap := make(map[string]config.OpenAICompatibility, len(oldList))
|
||||
oldLabels := make(map[string]string, len(oldList))
|
||||
for idx, entry := range oldList {
|
||||
key, label := openAICompatKey(entry, idx)
|
||||
oldMap[key] = entry
|
||||
oldLabels[key] = label
|
||||
}
|
||||
newMap := make(map[string]config.OpenAICompatibility, len(newList))
|
||||
newLabels := make(map[string]string, len(newList))
|
||||
for idx, entry := range newList {
|
||||
key, label := openAICompatKey(entry, idx)
|
||||
newMap[key] = entry
|
||||
newLabels[key] = label
|
||||
}
|
||||
keySet := make(map[string]struct{}, len(oldMap)+len(newMap))
|
||||
for key := range oldMap {
|
||||
keySet[key] = struct{}{}
|
||||
}
|
||||
for key := range newMap {
|
||||
keySet[key] = struct{}{}
|
||||
}
|
||||
orderedKeys := make([]string, 0, len(keySet))
|
||||
for key := range keySet {
|
||||
orderedKeys = append(orderedKeys, key)
|
||||
}
|
||||
sort.Strings(orderedKeys)
|
||||
for _, key := range orderedKeys {
|
||||
oldEntry, oldOk := oldMap[key]
|
||||
newEntry, newOk := newMap[key]
|
||||
label := oldLabels[key]
|
||||
if label == "" {
|
||||
label = newLabels[key]
|
||||
}
|
||||
switch {
|
||||
case !oldOk:
|
||||
changes = append(changes, fmt.Sprintf("provider added: %s (api-keys=%d, models=%d)", label, countNonEmptyStrings(newEntry.APIKeys), countOpenAIModels(newEntry.Models)))
|
||||
case !newOk:
|
||||
changes = append(changes, fmt.Sprintf("provider removed: %s (api-keys=%d, models=%d)", label, countNonEmptyStrings(oldEntry.APIKeys), countOpenAIModels(oldEntry.Models)))
|
||||
default:
|
||||
if detail := describeOpenAICompatibilityUpdate(oldEntry, newEntry); detail != "" {
|
||||
changes = append(changes, fmt.Sprintf("provider updated: %s %s", label, detail))
|
||||
}
|
||||
}
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
func describeOpenAICompatibilityUpdate(oldEntry, newEntry config.OpenAICompatibility) string {
|
||||
oldKeyCount := countNonEmptyStrings(oldEntry.APIKeys)
|
||||
newKeyCount := countNonEmptyStrings(newEntry.APIKeys)
|
||||
oldModelCount := countOpenAIModels(oldEntry.Models)
|
||||
newModelCount := countOpenAIModels(newEntry.Models)
|
||||
details := make([]string, 0, 2)
|
||||
if oldKeyCount != newKeyCount {
|
||||
details = append(details, fmt.Sprintf("api-keys %d -> %d", oldKeyCount, newKeyCount))
|
||||
}
|
||||
if oldModelCount != newModelCount {
|
||||
details = append(details, fmt.Sprintf("models %d -> %d", oldModelCount, newModelCount))
|
||||
}
|
||||
if len(details) == 0 {
|
||||
return ""
|
||||
}
|
||||
return "(" + strings.Join(details, ", ") + ")"
|
||||
}
|
||||
|
||||
func countNonEmptyStrings(values []string) int {
|
||||
count := 0
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func countOpenAIModels(models []config.OpenAICompatibilityModel) int {
|
||||
count := 0
|
||||
for _, model := range models {
|
||||
name := strings.TrimSpace(model.Name)
|
||||
alias := strings.TrimSpace(model.Alias)
|
||||
if name == "" && alias == "" {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func openAICompatKey(entry config.OpenAICompatibility, index int) (string, string) {
|
||||
name := strings.TrimSpace(entry.Name)
|
||||
if name != "" {
|
||||
return "name:" + name, name
|
||||
}
|
||||
base := strings.TrimSpace(entry.BaseURL)
|
||||
if base != "" {
|
||||
return "base:" + base, base
|
||||
}
|
||||
for _, model := range entry.Models {
|
||||
alias := strings.TrimSpace(model.Alias)
|
||||
if alias == "" {
|
||||
alias = strings.TrimSpace(model.Name)
|
||||
}
|
||||
if alias != "" {
|
||||
return "alias:" + alias, alias
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("index:%d", index), fmt.Sprintf("entry-%d", index+1)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
||||
)
|
||||
|
||||
// Provider validates credentials for incoming requests.
|
||||
@@ -23,7 +23,7 @@ type Result struct {
|
||||
}
|
||||
|
||||
// ProviderFactory builds a provider from configuration data.
|
||||
type ProviderFactory func(cfg *config.AccessProvider, root *config.Config) (Provider, error)
|
||||
type ProviderFactory func(cfg *config.AccessProvider, root *config.SDKConfig) (Provider, error)
|
||||
|
||||
var (
|
||||
registryMu sync.RWMutex
|
||||
@@ -40,7 +40,7 @@ func RegisterProvider(typ string, factory ProviderFactory) {
|
||||
registryMu.Unlock()
|
||||
}
|
||||
|
||||
func buildProvider(cfg *config.AccessProvider, root *config.Config) (Provider, error) {
|
||||
func BuildProvider(cfg *config.AccessProvider, root *config.SDKConfig) (Provider, error) {
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("access: nil provider config")
|
||||
}
|
||||
@@ -58,7 +58,7 @@ func buildProvider(cfg *config.AccessProvider, root *config.Config) (Provider, e
|
||||
}
|
||||
|
||||
// BuildProviders constructs providers declared in configuration.
|
||||
func BuildProviders(root *config.Config) ([]Provider, error) {
|
||||
func BuildProviders(root *config.SDKConfig) ([]Provider, error) {
|
||||
if root == nil {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -68,7 +68,7 @@ func BuildProviders(root *config.Config) ([]Provider, error) {
|
||||
if providerCfg.Type == "" {
|
||||
continue
|
||||
}
|
||||
provider, err := buildProvider(providerCfg, root)
|
||||
provider, err := BuildProvider(providerCfg, root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -77,7 +77,7 @@ func BuildProviders(root *config.Config) ([]Provider, error) {
|
||||
if len(providers) == 0 && len(root.APIKeys) > 0 {
|
||||
config.SyncInlineAPIKeys(root, root.APIKeys)
|
||||
if providerCfg := root.ConfigAPIKeyProvider(); providerCfg != nil {
|
||||
provider, err := buildProvider(providerCfg, root)
|
||||
provider, err := BuildProvider(providerCfg, root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -14,10 +14,10 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/api/handlers"
|
||||
. "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
@@ -14,10 +14,10 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/api/handlers"
|
||||
. "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
@@ -13,10 +13,10 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/api/handlers"
|
||||
. "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers"
|
||||
)
|
||||
|
||||
// GeminiAPIHandler contains the handlers for Gemini API endpoints.
|
||||
@@ -8,11 +8,11 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||
coreexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
||||
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
||||
"golang.org/x/net/context"
|
||||
)
|
||||
@@ -45,7 +45,7 @@ type BaseAPIHandler struct {
|
||||
AuthManager *coreauth.Manager
|
||||
|
||||
// Cfg holds the current application configuration.
|
||||
Cfg *config.Config
|
||||
Cfg *config.SDKConfig
|
||||
}
|
||||
|
||||
// NewBaseAPIHandlers creates a new API handlers instance.
|
||||
@@ -57,7 +57,7 @@ type BaseAPIHandler struct {
|
||||
//
|
||||
// Returns:
|
||||
// - *BaseAPIHandler: A new API handlers instance
|
||||
func NewBaseAPIHandlers(cfg *config.Config, authManager *coreauth.Manager) *BaseAPIHandler {
|
||||
func NewBaseAPIHandlers(cfg *config.SDKConfig, authManager *coreauth.Manager) *BaseAPIHandler {
|
||||
return &BaseAPIHandler{
|
||||
Cfg: cfg,
|
||||
AuthManager: authManager,
|
||||
@@ -70,7 +70,7 @@ func NewBaseAPIHandlers(cfg *config.Config, authManager *coreauth.Manager) *Base
|
||||
// Parameters:
|
||||
// - clients: The new slice of AI service clients
|
||||
// - cfg: The new application configuration
|
||||
func (h *BaseAPIHandler) UpdateClients(cfg *config.Config) { h.Cfg = cfg }
|
||||
func (h *BaseAPIHandler) UpdateClients(cfg *config.SDKConfig) { h.Cfg = cfg }
|
||||
|
||||
// GetAlt extracts the 'alt' parameter from the request query string.
|
||||
// It checks both 'alt' and '$alt' parameters and returns the appropriate value.
|
||||
@@ -133,7 +133,7 @@ func (h *BaseAPIHandler) GetContextWithCancel(handler interfaces.APIHandler, c *
|
||||
// ExecuteWithAuthManager executes a non-streaming request via the core auth manager.
|
||||
// This path is the only supported execution route.
|
||||
func (h *BaseAPIHandler) ExecuteWithAuthManager(ctx context.Context, handlerType, modelName string, rawJSON []byte, alt string) ([]byte, *interfaces.ErrorMessage) {
|
||||
providers := util.GetProviderName(modelName, h.Cfg)
|
||||
providers := util.GetProviderName(modelName)
|
||||
if len(providers) == 0 {
|
||||
return nil, &interfaces.ErrorMessage{StatusCode: http.StatusBadRequest, Error: fmt.Errorf("unknown provider for model %s", modelName)}
|
||||
}
|
||||
@@ -157,7 +157,7 @@ func (h *BaseAPIHandler) ExecuteWithAuthManager(ctx context.Context, handlerType
|
||||
// ExecuteCountWithAuthManager executes a non-streaming request via the core auth manager.
|
||||
// This path is the only supported execution route.
|
||||
func (h *BaseAPIHandler) ExecuteCountWithAuthManager(ctx context.Context, handlerType, modelName string, rawJSON []byte, alt string) ([]byte, *interfaces.ErrorMessage) {
|
||||
providers := util.GetProviderName(modelName, h.Cfg)
|
||||
providers := util.GetProviderName(modelName)
|
||||
if len(providers) == 0 {
|
||||
return nil, &interfaces.ErrorMessage{StatusCode: http.StatusBadRequest, Error: fmt.Errorf("unknown provider for model %s", modelName)}
|
||||
}
|
||||
@@ -181,7 +181,7 @@ func (h *BaseAPIHandler) ExecuteCountWithAuthManager(ctx context.Context, handle
|
||||
// ExecuteStreamWithAuthManager executes a streaming request via the core auth manager.
|
||||
// This path is the only supported execution route.
|
||||
func (h *BaseAPIHandler) ExecuteStreamWithAuthManager(ctx context.Context, handlerType, modelName string, rawJSON []byte, alt string) (<-chan []byte, <-chan *interfaces.ErrorMessage) {
|
||||
providers := util.GetProviderName(modelName, h.Cfg)
|
||||
providers := util.GetProviderName(modelName)
|
||||
if len(providers) == 0 {
|
||||
errChan := make(chan *interfaces.ErrorMessage, 1)
|
||||
errChan <- &interfaces.ErrorMessage{StatusCode: http.StatusBadRequest, Error: fmt.Errorf("unknown provider for model %s", modelName)}
|
||||
@@ -14,10 +14,10 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/api/handlers"
|
||||
. "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
@@ -14,10 +14,10 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/api/handlers"
|
||||
. "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
@@ -35,7 +36,7 @@ func (a *ClaudeAuthenticator) RefreshLead() *time.Duration {
|
||||
return &d
|
||||
}
|
||||
|
||||
func (a *ClaudeAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*TokenRecord, error) {
|
||||
func (a *ClaudeAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) {
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("cliproxy auth: configuration is required")
|
||||
}
|
||||
@@ -80,22 +81,22 @@ func (a *ClaudeAuthenticator) Login(ctx context.Context, cfg *config.Config, opt
|
||||
state = returnedState
|
||||
|
||||
if !opts.NoBrowser {
|
||||
log.Info("Opening browser for Claude authentication")
|
||||
fmt.Println("Opening browser for Claude authentication")
|
||||
if !browser.IsAvailable() {
|
||||
log.Warn("No browser available; please open the URL manually")
|
||||
util.PrintSSHTunnelInstructions(a.CallbackPort)
|
||||
log.Infof("Visit the following URL to continue authentication:\n%s", authURL)
|
||||
fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL)
|
||||
} else if err = browser.OpenURL(authURL); err != nil {
|
||||
log.Warnf("Failed to open browser automatically: %v", err)
|
||||
util.PrintSSHTunnelInstructions(a.CallbackPort)
|
||||
log.Infof("Visit the following URL to continue authentication:\n%s", authURL)
|
||||
fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL)
|
||||
}
|
||||
} else {
|
||||
util.PrintSSHTunnelInstructions(a.CallbackPort)
|
||||
log.Infof("Visit the following URL to continue authentication:\n%s", authURL)
|
||||
fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL)
|
||||
}
|
||||
|
||||
log.Info("Waiting for Claude authentication callback...")
|
||||
fmt.Println("Waiting for Claude authentication callback...")
|
||||
|
||||
result, err := oauthServer.WaitForCallback(5 * time.Minute)
|
||||
if err != nil {
|
||||
@@ -127,16 +128,17 @@ func (a *ClaudeAuthenticator) Login(ctx context.Context, cfg *config.Config, opt
|
||||
}
|
||||
|
||||
fileName := fmt.Sprintf("claude-%s.json", tokenStorage.Email)
|
||||
metadata := map[string]string{
|
||||
metadata := map[string]any{
|
||||
"email": tokenStorage.Email,
|
||||
}
|
||||
|
||||
log.Info("Claude authentication successful")
|
||||
fmt.Println("Claude authentication successful")
|
||||
if authBundle.APIKey != "" {
|
||||
log.Info("Claude API key obtained and stored")
|
||||
fmt.Println("Claude API key obtained and stored")
|
||||
}
|
||||
|
||||
return &TokenRecord{
|
||||
return &coreauth.Auth{
|
||||
ID: fileName,
|
||||
Provider: a.Provider(),
|
||||
FileName: fileName,
|
||||
Storage: tokenStorage,
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
@@ -35,7 +36,7 @@ func (a *CodexAuthenticator) RefreshLead() *time.Duration {
|
||||
return &d
|
||||
}
|
||||
|
||||
func (a *CodexAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*TokenRecord, error) {
|
||||
func (a *CodexAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) {
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("cliproxy auth: configuration is required")
|
||||
}
|
||||
@@ -79,22 +80,22 @@ func (a *CodexAuthenticator) Login(ctx context.Context, cfg *config.Config, opts
|
||||
}
|
||||
|
||||
if !opts.NoBrowser {
|
||||
log.Info("Opening browser for Codex authentication")
|
||||
fmt.Println("Opening browser for Codex authentication")
|
||||
if !browser.IsAvailable() {
|
||||
log.Warn("No browser available; please open the URL manually")
|
||||
util.PrintSSHTunnelInstructions(a.CallbackPort)
|
||||
log.Infof("Visit the following URL to continue authentication:\n%s", authURL)
|
||||
fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL)
|
||||
} else if err = browser.OpenURL(authURL); err != nil {
|
||||
log.Warnf("Failed to open browser automatically: %v", err)
|
||||
util.PrintSSHTunnelInstructions(a.CallbackPort)
|
||||
log.Infof("Visit the following URL to continue authentication:\n%s", authURL)
|
||||
fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL)
|
||||
}
|
||||
} else {
|
||||
util.PrintSSHTunnelInstructions(a.CallbackPort)
|
||||
log.Infof("Visit the following URL to continue authentication:\n%s", authURL)
|
||||
fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL)
|
||||
}
|
||||
|
||||
log.Info("Waiting for Codex authentication callback...")
|
||||
fmt.Println("Waiting for Codex authentication callback...")
|
||||
|
||||
result, err := oauthServer.WaitForCallback(5 * time.Minute)
|
||||
if err != nil {
|
||||
@@ -126,16 +127,17 @@ func (a *CodexAuthenticator) Login(ctx context.Context, cfg *config.Config, opts
|
||||
}
|
||||
|
||||
fileName := fmt.Sprintf("codex-%s.json", tokenStorage.Email)
|
||||
metadata := map[string]string{
|
||||
metadata := map[string]any{
|
||||
"email": tokenStorage.Email,
|
||||
}
|
||||
|
||||
log.Info("Codex authentication successful")
|
||||
fmt.Println("Codex authentication successful")
|
||||
if authBundle.APIKey != "" {
|
||||
log.Info("Codex API key obtained and stored")
|
||||
fmt.Println("Codex API key obtained and stored")
|
||||
}
|
||||
|
||||
return &TokenRecord{
|
||||
return &coreauth.Auth{
|
||||
ID: fileName,
|
||||
Provider: a.Provider(),
|
||||
FileName: fileName,
|
||||
Storage: tokenStorage,
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||
)
|
||||
|
||||
@@ -35,27 +34,71 @@ func (s *FileTokenStore) SetBaseDir(dir string) {
|
||||
s.dirLock.Unlock()
|
||||
}
|
||||
|
||||
// Save writes the token storage to the resolved file path.
|
||||
func (s *FileTokenStore) Save(ctx context.Context, cfg *config.Config, record *TokenRecord) (string, error) {
|
||||
if record == nil || record.Storage == nil {
|
||||
return "", fmt.Errorf("cliproxy auth: token record is incomplete")
|
||||
// Save persists token storage and metadata to the resolved auth file path.
|
||||
func (s *FileTokenStore) Save(ctx context.Context, auth *cliproxyauth.Auth) (string, error) {
|
||||
if auth == nil {
|
||||
return "", fmt.Errorf("auth filestore: auth is nil")
|
||||
}
|
||||
target := strings.TrimSpace(record.FileName)
|
||||
if target == "" {
|
||||
return "", fmt.Errorf("cliproxy auth: missing file name for provider %s", record.Provider)
|
||||
}
|
||||
if !filepath.IsAbs(target) {
|
||||
baseDir := s.baseDirFromConfig(cfg)
|
||||
if baseDir != "" {
|
||||
target = filepath.Join(baseDir, target)
|
||||
}
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err := record.Storage.SaveTokenToFile(target); err != nil {
|
||||
|
||||
path, err := s.resolveAuthPath(auth)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return target, nil
|
||||
if path == "" {
|
||||
return "", fmt.Errorf("auth filestore: missing file path attribute for %s", auth.ID)
|
||||
}
|
||||
|
||||
if auth.Disabled {
|
||||
if _, statErr := os.Stat(path); os.IsNotExist(statErr) {
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if err = os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return "", fmt.Errorf("auth filestore: create dir failed: %w", err)
|
||||
}
|
||||
|
||||
switch {
|
||||
case auth.Storage != nil:
|
||||
if err = auth.Storage.SaveTokenToFile(path); err != nil {
|
||||
return "", err
|
||||
}
|
||||
case auth.Metadata != nil:
|
||||
raw, errMarshal := json.Marshal(auth.Metadata)
|
||||
if errMarshal != nil {
|
||||
return "", fmt.Errorf("auth filestore: marshal metadata failed: %w", errMarshal)
|
||||
}
|
||||
if existing, errRead := os.ReadFile(path); errRead == nil {
|
||||
if jsonEqual(existing, raw) {
|
||||
return path, nil
|
||||
}
|
||||
} else if errRead != nil && !os.IsNotExist(errRead) {
|
||||
return "", fmt.Errorf("auth filestore: read existing failed: %w", errRead)
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if errWrite := os.WriteFile(tmp, raw, 0o600); errWrite != nil {
|
||||
return "", fmt.Errorf("auth filestore: write temp failed: %w", errWrite)
|
||||
}
|
||||
if errRename := os.Rename(tmp, path); errRename != nil {
|
||||
return "", fmt.Errorf("auth filestore: rename failed: %w", errRename)
|
||||
}
|
||||
default:
|
||||
return "", fmt.Errorf("auth filestore: nothing to persist for %s", auth.ID)
|
||||
}
|
||||
|
||||
if auth.Attributes == nil {
|
||||
auth.Attributes = make(map[string]string)
|
||||
}
|
||||
auth.Attributes["path"] = path
|
||||
|
||||
if strings.TrimSpace(auth.FileName) == "" {
|
||||
auth.FileName = auth.ID
|
||||
}
|
||||
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// List enumerates all auth JSON files under the configured directory.
|
||||
@@ -90,50 +133,6 @@ func (s *FileTokenStore) List(ctx context.Context) ([]*cliproxyauth.Auth, error)
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// SaveAuth writes the auth metadata back to its source file location.
|
||||
func (s *FileTokenStore) SaveAuth(ctx context.Context, auth *cliproxyauth.Auth) error {
|
||||
if auth == nil {
|
||||
return fmt.Errorf("auth filestore: auth is nil")
|
||||
}
|
||||
path, err := s.resolveAuthPath(auth)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if path == "" {
|
||||
return fmt.Errorf("auth filestore: missing file path attribute for %s", auth.ID)
|
||||
}
|
||||
// If the auth has been disabled and the original file was removed, avoid recreating it on disk.
|
||||
if auth.Disabled {
|
||||
if _, statErr := os.Stat(path); statErr != nil {
|
||||
if os.IsNotExist(statErr) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err = os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return fmt.Errorf("auth filestore: create dir failed: %w", err)
|
||||
}
|
||||
raw, err := json.Marshal(auth.Metadata)
|
||||
if err != nil {
|
||||
return fmt.Errorf("auth filestore: marshal metadata failed: %w", err)
|
||||
}
|
||||
if existing, errRead := os.ReadFile(path); errRead == nil {
|
||||
if jsonEqual(existing, raw) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if err = os.WriteFile(tmp, raw, 0o600); err != nil {
|
||||
return fmt.Errorf("auth filestore: write temp failed: %w", err)
|
||||
}
|
||||
if err = os.Rename(tmp, path); err != nil {
|
||||
return fmt.Errorf("auth filestore: rename failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes the auth file.
|
||||
func (s *FileTokenStore) Delete(ctx context.Context, id string) error {
|
||||
id = strings.TrimSpace(id)
|
||||
@@ -185,6 +184,7 @@ func (s *FileTokenStore) readAuthFile(path, baseDir string) (*cliproxyauth.Auth,
|
||||
auth := &cliproxyauth.Auth{
|
||||
ID: id,
|
||||
Provider: provider,
|
||||
FileName: id,
|
||||
Label: s.labelFor(metadata),
|
||||
Status: cliproxyauth.StatusActive,
|
||||
Attributes: map[string]string{"path": path},
|
||||
@@ -220,6 +220,15 @@ func (s *FileTokenStore) resolveAuthPath(auth *cliproxyauth.Auth) (string, error
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
if fileName := strings.TrimSpace(auth.FileName); fileName != "" {
|
||||
if filepath.IsAbs(fileName) {
|
||||
return fileName, nil
|
||||
}
|
||||
if dir := s.baseDirSnapshot(); dir != "" {
|
||||
return filepath.Join(dir, fileName), nil
|
||||
}
|
||||
return fileName, nil
|
||||
}
|
||||
if auth.ID == "" {
|
||||
return "", fmt.Errorf("auth filestore: missing id")
|
||||
}
|
||||
@@ -249,13 +258,6 @@ func (s *FileTokenStore) labelFor(metadata map[string]any) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *FileTokenStore) baseDirFromConfig(cfg *config.Config) string {
|
||||
if cfg != nil && strings.TrimSpace(cfg.AuthDir) != "" {
|
||||
return strings.TrimSpace(cfg.AuthDir)
|
||||
}
|
||||
return s.baseDirSnapshot()
|
||||
}
|
||||
|
||||
func (s *FileTokenStore) baseDirSnapshot() string {
|
||||
s.dirLock.RLock()
|
||||
defer s.dirLock.RUnlock()
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||
)
|
||||
|
||||
// GeminiWebAuthenticator provides a minimal wrapper so core components can treat
|
||||
@@ -16,7 +17,7 @@ func NewGeminiWebAuthenticator() *GeminiWebAuthenticator { return &GeminiWebAuth
|
||||
|
||||
func (a *GeminiWebAuthenticator) Provider() string { return "gemini-web" }
|
||||
|
||||
func (a *GeminiWebAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*TokenRecord, error) {
|
||||
func (a *GeminiWebAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) {
|
||||
_ = ctx
|
||||
_ = cfg
|
||||
_ = opts
|
||||
@@ -24,6 +25,6 @@ func (a *GeminiWebAuthenticator) Login(ctx context.Context, cfg *config.Config,
|
||||
}
|
||||
|
||||
func (a *GeminiWebAuthenticator) RefreshLead() *time.Duration {
|
||||
d := 15 * time.Minute
|
||||
d := time.Hour
|
||||
return &d
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini"
|
||||
// legacy client removed
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
log "github.com/sirupsen/logrus"
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||
)
|
||||
|
||||
// GeminiAuthenticator implements the login flow for Google Gemini CLI accounts.
|
||||
@@ -27,7 +27,7 @@ func (a *GeminiAuthenticator) RefreshLead() *time.Duration {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *GeminiAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*TokenRecord, error) {
|
||||
func (a *GeminiAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) {
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("cliproxy auth: configuration is required")
|
||||
}
|
||||
@@ -52,14 +52,15 @@ func (a *GeminiAuthenticator) Login(ctx context.Context, cfg *config.Config, opt
|
||||
// Skip onboarding here; rely on upstream configuration
|
||||
|
||||
fileName := fmt.Sprintf("%s-%s.json", ts.Email, ts.ProjectID)
|
||||
metadata := map[string]string{
|
||||
metadata := map[string]any{
|
||||
"email": ts.Email,
|
||||
"project_id": ts.ProjectID,
|
||||
}
|
||||
|
||||
log.Info("Gemini authentication successful")
|
||||
fmt.Println("Gemini authentication successful")
|
||||
|
||||
return &TokenRecord{
|
||||
return &coreauth.Auth{
|
||||
ID: fileName,
|
||||
Provider: a.Provider(),
|
||||
FileName: fileName,
|
||||
Storage: &ts,
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
baseauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||
)
|
||||
|
||||
var ErrRefreshNotSupported = errors.New("cliproxy auth: refresh not supported")
|
||||
@@ -20,22 +20,9 @@ type LoginOptions struct {
|
||||
Prompt func(prompt string) (string, error)
|
||||
}
|
||||
|
||||
// TokenRecord represents credential material produced by an authenticator.
|
||||
type TokenRecord struct {
|
||||
Provider string
|
||||
FileName string
|
||||
Storage baseauth.TokenStorage
|
||||
Metadata map[string]string
|
||||
}
|
||||
|
||||
// TokenStore persists token records.
|
||||
type TokenStore interface {
|
||||
Save(ctx context.Context, cfg *config.Config, record *TokenRecord) (string, error)
|
||||
}
|
||||
|
||||
// Authenticator manages login and optional refresh flows for a provider.
|
||||
type Authenticator interface {
|
||||
Provider() string
|
||||
Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*TokenRecord, error)
|
||||
Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error)
|
||||
RefreshLead() *time.Duration
|
||||
}
|
||||
|
||||
@@ -5,17 +5,18 @@ import (
|
||||
"fmt"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||
)
|
||||
|
||||
// Manager aggregates authenticators and coordinates persistence via a token store.
|
||||
type Manager struct {
|
||||
authenticators map[string]Authenticator
|
||||
store TokenStore
|
||||
store coreauth.Store
|
||||
}
|
||||
|
||||
// NewManager constructs a manager with the provided token store and authenticators.
|
||||
// If store is nil, the caller must set it later using SetStore.
|
||||
func NewManager(store TokenStore, authenticators ...Authenticator) *Manager {
|
||||
func NewManager(store coreauth.Store, authenticators ...Authenticator) *Manager {
|
||||
mgr := &Manager{
|
||||
authenticators: make(map[string]Authenticator),
|
||||
store: store,
|
||||
@@ -38,12 +39,12 @@ func (m *Manager) Register(a Authenticator) {
|
||||
}
|
||||
|
||||
// SetStore updates the token store used for persistence.
|
||||
func (m *Manager) SetStore(store TokenStore) {
|
||||
func (m *Manager) SetStore(store coreauth.Store) {
|
||||
m.store = store
|
||||
}
|
||||
|
||||
// Login executes the provider login flow and persists the resulting token record.
|
||||
func (m *Manager) Login(ctx context.Context, provider string, cfg *config.Config, opts *LoginOptions) (*TokenRecord, string, error) {
|
||||
// Login executes the provider login flow and persists the resulting auth record.
|
||||
func (m *Manager) Login(ctx context.Context, provider string, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, string, error) {
|
||||
auth, ok := m.authenticators[provider]
|
||||
if !ok {
|
||||
return nil, "", fmt.Errorf("cliproxy auth: authenticator %s not registered", provider)
|
||||
@@ -61,7 +62,13 @@ func (m *Manager) Login(ctx context.Context, provider string, cfg *config.Config
|
||||
return record, "", nil
|
||||
}
|
||||
|
||||
savedPath, err := m.store.Save(ctx, cfg, record)
|
||||
if cfg != nil {
|
||||
if dirSetter, ok := m.store.(interface{ SetBaseDir(string) }); ok {
|
||||
dirSetter.SetBaseDir(cfg.AuthDir)
|
||||
}
|
||||
}
|
||||
|
||||
savedPath, err := m.store.Save(ctx, record)
|
||||
if err != nil {
|
||||
return record, "", err
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/browser"
|
||||
// legacy client removed
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
@@ -30,7 +31,7 @@ func (a *QwenAuthenticator) RefreshLead() *time.Duration {
|
||||
return &d
|
||||
}
|
||||
|
||||
func (a *QwenAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*TokenRecord, error) {
|
||||
func (a *QwenAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) {
|
||||
if cfg == nil {
|
||||
return nil, fmt.Errorf("cliproxy auth: configuration is required")
|
||||
}
|
||||
@@ -51,19 +52,19 @@ func (a *QwenAuthenticator) Login(ctx context.Context, cfg *config.Config, opts
|
||||
authURL := deviceFlow.VerificationURIComplete
|
||||
|
||||
if !opts.NoBrowser {
|
||||
log.Info("Opening browser for Qwen authentication")
|
||||
fmt.Println("Opening browser for Qwen authentication")
|
||||
if !browser.IsAvailable() {
|
||||
log.Warn("No browser available; please open the URL manually")
|
||||
log.Infof("Visit the following URL to continue authentication:\n%s", authURL)
|
||||
fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL)
|
||||
} else if err = browser.OpenURL(authURL); err != nil {
|
||||
log.Warnf("Failed to open browser automatically: %v", err)
|
||||
log.Infof("Visit the following URL to continue authentication:\n%s", authURL)
|
||||
fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL)
|
||||
}
|
||||
} else {
|
||||
log.Infof("Visit the following URL to continue authentication:\n%s", authURL)
|
||||
fmt.Printf("Visit the following URL to continue authentication:\n%s\n", authURL)
|
||||
}
|
||||
|
||||
log.Info("Waiting for Qwen authentication...")
|
||||
fmt.Println("Waiting for Qwen authentication...")
|
||||
|
||||
tokenData, err := authSvc.PollForToken(deviceFlow.DeviceCode, deviceFlow.CodeVerifier)
|
||||
if err != nil {
|
||||
@@ -97,13 +98,14 @@ func (a *QwenAuthenticator) Login(ctx context.Context, cfg *config.Config, opts
|
||||
// no legacy client construction
|
||||
|
||||
fileName := fmt.Sprintf("qwen-%s.json", tokenStorage.Email)
|
||||
metadata := map[string]string{
|
||||
metadata := map[string]any{
|
||||
"email": tokenStorage.Email,
|
||||
}
|
||||
|
||||
log.Info("Qwen authentication successful")
|
||||
fmt.Println("Qwen authentication successful")
|
||||
|
||||
return &TokenRecord{
|
||||
return &coreauth.Auth{
|
||||
ID: fileName,
|
||||
Provider: a.Provider(),
|
||||
FileName: fileName,
|
||||
Storage: tokenStorage,
|
||||
|
||||
@@ -1,31 +1,35 @@
|
||||
package auth
|
||||
|
||||
import "sync"
|
||||
import (
|
||||
"sync"
|
||||
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||
)
|
||||
|
||||
var (
|
||||
storeMu sync.RWMutex
|
||||
registeredTokenStore TokenStore
|
||||
storeMu sync.RWMutex
|
||||
registeredStore coreauth.Store
|
||||
)
|
||||
|
||||
// RegisterTokenStore sets the global token store used by the authentication helpers.
|
||||
func RegisterTokenStore(store TokenStore) {
|
||||
func RegisterTokenStore(store coreauth.Store) {
|
||||
storeMu.Lock()
|
||||
registeredTokenStore = store
|
||||
registeredStore = store
|
||||
storeMu.Unlock()
|
||||
}
|
||||
|
||||
// GetTokenStore returns the globally registered token store.
|
||||
func GetTokenStore() TokenStore {
|
||||
func GetTokenStore() coreauth.Store {
|
||||
storeMu.RLock()
|
||||
s := registeredTokenStore
|
||||
s := registeredStore
|
||||
storeMu.RUnlock()
|
||||
if s != nil {
|
||||
return s
|
||||
}
|
||||
storeMu.Lock()
|
||||
defer storeMu.Unlock()
|
||||
if registeredTokenStore == nil {
|
||||
registeredTokenStore = NewFileTokenStore()
|
||||
if registeredStore == nil {
|
||||
registeredStore = NewFileTokenStore()
|
||||
}
|
||||
return registeredTokenStore
|
||||
return registeredStore
|
||||
}
|
||||
|
||||
@@ -818,7 +818,8 @@ func (m *Manager) persist(ctx context.Context, auth *Auth) error {
|
||||
if auth.Metadata == nil {
|
||||
return nil
|
||||
}
|
||||
return m.store.SaveAuth(ctx, auth)
|
||||
_, err := m.store.Save(ctx, auth)
|
||||
return err
|
||||
}
|
||||
|
||||
// StartAutoRefresh launches a background loop that evaluates auth freshness
|
||||
|
||||
@@ -2,6 +2,7 @@ package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -36,6 +37,10 @@ func (s *RoundRobinSelector) Pick(ctx context.Context, provider, model string, o
|
||||
if len(available) == 0 {
|
||||
return nil, &Error{Code: "auth_unavailable", Message: "no auth available"}
|
||||
}
|
||||
// Make round-robin deterministic even if caller's candidate order is unstable.
|
||||
if len(available) > 1 {
|
||||
sort.Slice(available, func(i, j int) bool { return available[i].ID < available[j].ID })
|
||||
}
|
||||
key := provider + ":" + model
|
||||
s.mu.Lock()
|
||||
index := s.cursors[key]
|
||||
@@ -57,21 +62,32 @@ func isAuthBlockedForModel(auth *Auth, model string, now time.Time) bool {
|
||||
if auth.Disabled || auth.Status == StatusDisabled {
|
||||
return true
|
||||
}
|
||||
if model != "" && len(auth.ModelStates) > 0 {
|
||||
if state, ok := auth.ModelStates[model]; ok && state != nil {
|
||||
if state.Status == StatusDisabled {
|
||||
return true
|
||||
}
|
||||
if state.Unavailable {
|
||||
if state.NextRetryAfter.IsZero() {
|
||||
return false
|
||||
}
|
||||
if state.NextRetryAfter.After(now) {
|
||||
// If a specific model is requested, prefer its per-model state over any aggregated
|
||||
// auth-level unavailable flag. This prevents a failure on one model (e.g., 429 quota)
|
||||
// from blocking other models of the same provider that have no errors.
|
||||
if model != "" {
|
||||
if len(auth.ModelStates) > 0 {
|
||||
if state, ok := auth.ModelStates[model]; ok && state != nil {
|
||||
if state.Status == StatusDisabled {
|
||||
return true
|
||||
}
|
||||
if state.Unavailable {
|
||||
if state.NextRetryAfter.IsZero() {
|
||||
return false
|
||||
}
|
||||
if state.NextRetryAfter.After(now) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Explicit state exists and is not blocking.
|
||||
return false
|
||||
}
|
||||
}
|
||||
// No explicit state for this model; do not block based on aggregated
|
||||
// auth-level unavailable status. Allow trying this model.
|
||||
return false
|
||||
}
|
||||
// No specific model context: fall back to auth-level unavailable window.
|
||||
if auth.Unavailable && auth.NextRetryAfter.After(now) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@ import "context"
|
||||
type Store interface {
|
||||
// List returns all auth records stored in the backend.
|
||||
List(ctx context.Context) ([]*Auth, error)
|
||||
// SaveAuth persists the provided auth record, replacing any existing one with same ID.
|
||||
SaveAuth(ctx context.Context, auth *Auth) error
|
||||
// Save persists the provided auth record, replacing any existing one with same ID.
|
||||
Save(ctx context.Context, auth *Auth) (string, error)
|
||||
// Delete removes the auth record identified by id.
|
||||
Delete(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
baseauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth"
|
||||
)
|
||||
|
||||
// Auth encapsulates the runtime state and metadata associated with a single credential.
|
||||
@@ -14,6 +16,10 @@ type Auth struct {
|
||||
ID string `json:"id"`
|
||||
// Provider is the upstream provider key (e.g. "gemini", "claude").
|
||||
Provider string `json:"provider"`
|
||||
// FileName stores the relative or absolute path of the backing auth file.
|
||||
FileName string `json:"-"`
|
||||
// Storage holds the token persistence implementation used during login flows.
|
||||
Storage baseauth.TokenStorage `json:"-"`
|
||||
// Label is an optional human readable label for logging.
|
||||
Label string `json:"label,omitempty"`
|
||||
// Status is the lifecycle status managed by the AuthManager.
|
||||
@@ -128,6 +134,7 @@ func (a *Auth) AccountInfo() (string, string) {
|
||||
if a == nil {
|
||||
return "", ""
|
||||
}
|
||||
// For Gemini Web, prefer explicit cookie label for stability.
|
||||
if strings.ToLower(a.Provider) == "gemini-web" {
|
||||
// Prefer explicit label written into auth file (e.g., gemini-web-<hash>)
|
||||
if a.Metadata != nil {
|
||||
@@ -145,6 +152,22 @@ func (a *Auth) AccountInfo() (string, string) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// For Gemini CLI, include project ID in the OAuth account info if present.
|
||||
if strings.ToLower(a.Provider) == "gemini-cli" {
|
||||
if a.Metadata != nil {
|
||||
email, _ := a.Metadata["email"].(string)
|
||||
email = strings.TrimSpace(email)
|
||||
if email != "" {
|
||||
if p, ok := a.Metadata["project_id"].(string); ok {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
return "oauth", email + " (" + p + ")"
|
||||
}
|
||||
}
|
||||
return "oauth", email
|
||||
}
|
||||
}
|
||||
}
|
||||
if a.Metadata != nil {
|
||||
if v, ok := a.Metadata["email"].(string); ok {
|
||||
return "oauth", v
|
||||
|
||||
@@ -184,7 +184,8 @@ func (b *Builder) Build() (*Service, error) {
|
||||
if accessManager == nil {
|
||||
accessManager = sdkaccess.NewManager()
|
||||
}
|
||||
providers, err := sdkaccess.BuildProviders(b.cfg)
|
||||
|
||||
providers, err := sdkaccess.BuildProviders(&b.cfg.SDKConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -196,11 +197,7 @@ func (b *Builder) Build() (*Service, error) {
|
||||
if dirSetter, ok := tokenStore.(interface{ SetBaseDir(string) }); ok && b.cfg != nil {
|
||||
dirSetter.SetBaseDir(b.cfg.AuthDir)
|
||||
}
|
||||
store, ok := tokenStore.(coreauth.Store)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("cliproxy: token store does not implement coreauth.Store")
|
||||
}
|
||||
coreManager = coreauth.NewManager(store, nil, nil)
|
||||
coreManager = coreauth.NewManager(tokenStore, nil, nil)
|
||||
}
|
||||
// Attach a default RoundTripper provider so providers can opt-in per-auth transports.
|
||||
coreManager.SetRoundTripperProvider(newDefaultRoundTripperProvider())
|
||||
|
||||
@@ -18,10 +18,8 @@ import (
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor"
|
||||
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/watcher"
|
||||
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
|
||||
_ "github.com/router-for-me/CLIProxyAPI/v6/sdk/access/providers/configapikey"
|
||||
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage"
|
||||
@@ -107,18 +105,6 @@ func newDefaultAuthManager() *sdkAuth.Manager {
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Service) refreshAccessProviders(cfg *config.Config) {
|
||||
if s == nil || s.accessManager == nil || cfg == nil {
|
||||
return
|
||||
}
|
||||
providers, err := sdkaccess.BuildProviders(cfg)
|
||||
if err != nil {
|
||||
log.Errorf("failed to rebuild request auth providers: %v", err)
|
||||
return
|
||||
}
|
||||
s.accessManager.SetProviders(providers)
|
||||
}
|
||||
|
||||
func (s *Service) ensureAuthUpdateQueue(ctx context.Context) {
|
||||
if s == nil {
|
||||
return
|
||||
@@ -310,7 +296,6 @@ func (s *Service) Run(ctx context.Context) error {
|
||||
// legacy clients removed; no caches to refresh
|
||||
|
||||
// handlers no longer depend on legacy clients; pass nil slice initially
|
||||
s.refreshAccessProviders(s.cfg)
|
||||
s.server = api.NewServer(s.cfg, s.coreManager, s.accessManager, s.configPath, s.serverOptions...)
|
||||
|
||||
if s.authManager == nil {
|
||||
@@ -331,7 +316,7 @@ func (s *Service) Run(ctx context.Context) error {
|
||||
}()
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
log.Info("API server started successfully")
|
||||
fmt.Println("API server started successfully")
|
||||
|
||||
if s.hooks.OnAfterStart != nil {
|
||||
s.hooks.OnAfterStart(s)
|
||||
@@ -347,7 +332,6 @@ func (s *Service) Run(ctx context.Context) error {
|
||||
if newCfg == nil {
|
||||
return
|
||||
}
|
||||
s.refreshAccessProviders(newCfg)
|
||||
if s.server != nil {
|
||||
s.server.UpdateClients(newCfg)
|
||||
}
|
||||
@@ -382,17 +366,6 @@ func (s *Service) Run(ctx context.Context) error {
|
||||
log.Infof("core auth auto-refresh started (interval=%s)", interval)
|
||||
}
|
||||
|
||||
authFileCount := util.CountAuthFiles(s.cfg.AuthDir)
|
||||
totalNewClients := authFileCount + apiKeyResult.GeminiKeyCount + apiKeyResult.ClaudeKeyCount + apiKeyResult.CodexKeyCount + apiKeyResult.OpenAICompatCount
|
||||
log.Infof("full client load complete - %d clients (%d auth files + %d GL API keys + %d Claude API keys + %d Codex keys + %d OpenAI-compat)",
|
||||
totalNewClients,
|
||||
authFileCount,
|
||||
apiKeyResult.GeminiKeyCount,
|
||||
apiKeyResult.ClaudeKeyCount,
|
||||
apiKeyResult.CodexKeyCount,
|
||||
apiKeyResult.OpenAICompatCount,
|
||||
)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
log.Debug("service context cancelled, shutting down...")
|
||||
@@ -509,22 +482,35 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) {
|
||||
if s.cfg != nil {
|
||||
providerKey := provider
|
||||
compatName := strings.TrimSpace(a.Provider)
|
||||
isCompatAuth := false
|
||||
if strings.EqualFold(providerKey, "openai-compatibility") {
|
||||
isCompatAuth = true
|
||||
if a.Attributes != nil {
|
||||
if v := strings.TrimSpace(a.Attributes["compat_name"]); v != "" {
|
||||
compatName = v
|
||||
}
|
||||
if v := strings.TrimSpace(a.Attributes["provider_key"]); v != "" {
|
||||
providerKey = strings.ToLower(v)
|
||||
isCompatAuth = true
|
||||
}
|
||||
}
|
||||
if providerKey == "openai-compatibility" && compatName != "" {
|
||||
providerKey = strings.ToLower(compatName)
|
||||
}
|
||||
} else if a.Attributes != nil {
|
||||
if v := strings.TrimSpace(a.Attributes["compat_name"]); v != "" {
|
||||
compatName = v
|
||||
isCompatAuth = true
|
||||
}
|
||||
if v := strings.TrimSpace(a.Attributes["provider_key"]); v != "" {
|
||||
providerKey = strings.ToLower(v)
|
||||
isCompatAuth = true
|
||||
}
|
||||
}
|
||||
for i := range s.cfg.OpenAICompatibility {
|
||||
compat := &s.cfg.OpenAICompatibility[i]
|
||||
if strings.EqualFold(compat.Name, compatName) {
|
||||
isCompatAuth = true
|
||||
// Convert compatibility models to registry models
|
||||
ms := make([]*ModelInfo, 0, len(compat.Models))
|
||||
for j := range compat.Models {
|
||||
@@ -544,10 +530,18 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) {
|
||||
providerKey = "openai-compatibility"
|
||||
}
|
||||
GlobalModelRegistry().RegisterClient(a.ID, providerKey, ms)
|
||||
} else {
|
||||
// Ensure stale registrations are cleared when model list becomes empty.
|
||||
GlobalModelRegistry().UnregisterClient(a.ID)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
if isCompatAuth {
|
||||
// No matching provider found or models removed entirely; drop any prior registration.
|
||||
GlobalModelRegistry().UnregisterClient(a.ID)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(models) > 0 {
|
||||
|
||||
89
sdk/config/config.go
Normal file
89
sdk/config/config.go
Normal file
@@ -0,0 +1,89 @@
|
||||
// Package config provides configuration management for the CLI Proxy API server.
|
||||
// It handles loading and parsing YAML configuration files, and provides structured
|
||||
// access to application settings including server port, authentication directory,
|
||||
// debug settings, proxy configuration, and API keys.
|
||||
package config
|
||||
|
||||
// SDKConfig represents the application's configuration, loaded from a YAML file.
|
||||
type SDKConfig struct {
|
||||
// ProxyURL is the URL of an optional proxy server to use for outbound requests.
|
||||
ProxyURL string `yaml:"proxy-url" json:"proxy-url"`
|
||||
|
||||
// RequestLog enables or disables detailed request logging functionality.
|
||||
RequestLog bool `yaml:"request-log" json:"request-log"`
|
||||
|
||||
// APIKeys is a list of keys for authenticating clients to this proxy server.
|
||||
APIKeys []string `yaml:"api-keys" json:"api-keys"`
|
||||
|
||||
// Access holds request authentication provider configuration.
|
||||
Access AccessConfig `yaml:"auth" json:"auth"`
|
||||
}
|
||||
|
||||
// AccessConfig groups request authentication providers.
|
||||
type AccessConfig struct {
|
||||
// Providers lists configured authentication providers.
|
||||
Providers []AccessProvider `yaml:"providers" json:"providers"`
|
||||
}
|
||||
|
||||
// AccessProvider describes a request authentication provider entry.
|
||||
type AccessProvider struct {
|
||||
// Name is the instance identifier for the provider.
|
||||
Name string `yaml:"name" json:"name"`
|
||||
|
||||
// Type selects the provider implementation registered via the SDK.
|
||||
Type string `yaml:"type" json:"type"`
|
||||
|
||||
// SDK optionally names a third-party SDK module providing this provider.
|
||||
SDK string `yaml:"sdk,omitempty" json:"sdk,omitempty"`
|
||||
|
||||
// APIKeys lists inline keys for providers that require them.
|
||||
APIKeys []string `yaml:"api-keys,omitempty" json:"api-keys,omitempty"`
|
||||
|
||||
// Config passes provider-specific options to the implementation.
|
||||
Config map[string]any `yaml:"config,omitempty" json:"config,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
// AccessProviderTypeConfigAPIKey is the built-in provider validating inline API keys.
|
||||
AccessProviderTypeConfigAPIKey = "config-api-key"
|
||||
|
||||
// DefaultAccessProviderName is applied when no provider name is supplied.
|
||||
DefaultAccessProviderName = "config-inline"
|
||||
)
|
||||
|
||||
// SyncInlineAPIKeys updates the inline API key provider and top-level APIKeys field.
|
||||
func SyncInlineAPIKeys(cfg *SDKConfig, keys []string) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
cloned := append([]string(nil), keys...)
|
||||
cfg.APIKeys = cloned
|
||||
if provider := cfg.ConfigAPIKeyProvider(); provider != nil {
|
||||
if provider.Name == "" {
|
||||
provider.Name = DefaultAccessProviderName
|
||||
}
|
||||
provider.APIKeys = cloned
|
||||
return
|
||||
}
|
||||
cfg.Access.Providers = append(cfg.Access.Providers, AccessProvider{
|
||||
Name: DefaultAccessProviderName,
|
||||
Type: AccessProviderTypeConfigAPIKey,
|
||||
APIKeys: cloned,
|
||||
})
|
||||
}
|
||||
|
||||
// ConfigAPIKeyProvider returns the first inline API key provider if present.
|
||||
func (c *SDKConfig) ConfigAPIKeyProvider() *AccessProvider {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
for i := range c.Access.Providers {
|
||||
if c.Access.Providers[i].Type == AccessProviderTypeConfigAPIKey {
|
||||
if c.Access.Providers[i].Name == "" {
|
||||
c.Access.Providers[i].Name = DefaultAccessProviderName
|
||||
}
|
||||
return &c.Access.Providers[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user