mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-02 04:20:50 +08:00
Compare commits
80 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4576f9915b | ||
|
|
c945e35983 | ||
|
|
1cd275f4c1 | ||
|
|
4bc1ed6031 | ||
|
|
78989d6c0d | ||
|
|
d6aa1e5ba0 | ||
|
|
50c1c50dbd | ||
|
|
5123cfd47e | ||
|
|
9072accc43 | ||
|
|
0d8134aabe | ||
|
|
4fdbdf7925 | ||
|
|
50c84485c3 | ||
|
|
f335aeeedb | ||
|
|
32a8102d71 | ||
|
|
61f6a612e3 | ||
|
|
42087d5387 | ||
|
|
f2710c03ab | ||
|
|
39abde2413 | ||
|
|
0aa8706ef7 | ||
|
|
5fd4a8b974 | ||
|
|
06e6f0a5f2 | ||
|
|
80f6d6fe7c | ||
|
|
3be6175aec | ||
|
|
599986495b | ||
|
|
cb83985cc7 | ||
|
|
6ec028808f | ||
|
|
71faa19bb4 | ||
|
|
b5ad978d44 | ||
|
|
0508c9fbce | ||
|
|
92bb642e98 | ||
|
|
af82855bed | ||
|
|
a83978f769 | ||
|
|
2513d908be | ||
|
|
4c033b3af7 | ||
|
|
843a81f68d | ||
|
|
f6e713ab6b | ||
|
|
1834c65116 | ||
|
|
fc6aa8ef77 | ||
|
|
c3f88126e6 | ||
|
|
b895018ff5 | ||
|
|
9c6832cc22 | ||
|
|
1ada33ab1d | ||
|
|
78738ca3f0 | ||
|
|
ac01c74c02 | ||
|
|
02e28bbbe9 | ||
|
|
b9c7b9eea5 | ||
|
|
57195fa0f5 | ||
|
|
11f090c223 | ||
|
|
829dd06b42 | ||
|
|
20787cd107 | ||
|
|
1aa568ce45 | ||
|
|
b2cdbbdd47 | ||
|
|
8056af42a3 | ||
|
|
01be94a0de | ||
|
|
d1933075c3 | ||
|
|
a602ae859b | ||
|
|
c5d7137d66 | ||
|
|
d45ebff66b | ||
|
|
d6f671250e | ||
|
|
6d822cf309 | ||
|
|
d03a75dba5 | ||
|
|
9ff21b67a8 | ||
|
|
5546c9d872 | ||
|
|
fb760718e2 | ||
|
|
d6721e4e75 | ||
|
|
514f5a8ad4 | ||
|
|
a68e0dd8aa | ||
|
|
75d7763c5c | ||
|
|
9bb7df7af7 | ||
|
|
43665cb649 | ||
|
|
39337627b9 | ||
|
|
4bc8a52771 | ||
|
|
b727e4e12e | ||
|
|
93588919e5 | ||
|
|
31659c790d | ||
|
|
c62ecc2442 | ||
|
|
b1fee5d266 | ||
|
|
4a10cfacc3 | ||
|
|
bbdd68a8b4 | ||
|
|
ac3ecd567c |
@@ -17,9 +17,6 @@ MANAGEMENT_API.md
|
||||
MANAGEMENT_API_CN.md
|
||||
LICENSE
|
||||
|
||||
# Example configuration
|
||||
config.example.yaml
|
||||
|
||||
# Runtime data folders (should be mounted as volumes)
|
||||
auths/*
|
||||
logs/*
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -14,4 +14,7 @@ GEMINI.md
|
||||
*.exe
|
||||
temp/*
|
||||
cli-proxy-api
|
||||
static/*
|
||||
static/*
|
||||
.env
|
||||
pgstore/*
|
||||
gitstore/*
|
||||
@@ -22,6 +22,8 @@ RUN mkdir /CLIProxyAPI
|
||||
|
||||
COPY --from=builder ./app/CLIProxyAPI /CLIProxyAPI/CLIProxyAPI
|
||||
|
||||
COPY config.example.yaml /CLIProxyAPI/config.example.yaml
|
||||
|
||||
WORKDIR /CLIProxyAPI
|
||||
|
||||
EXPOSE 8317
|
||||
|
||||
3
LICENSE
3
LICENSE
@@ -1,6 +1,7 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Luis Pater
|
||||
Copyright (c) 2025-2005.9 Luis Pater
|
||||
Copyright (c) 2025.9-present Router-For.ME
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -639,19 +639,6 @@ These endpoints initiate provider login flows and return a URL to open in a brow
|
||||
{ "status": "ok", "url": "https://..." }
|
||||
```
|
||||
|
||||
- POST `/gemini-web-token` — Save Gemini Web cookies directly
|
||||
- Request:
|
||||
```bash
|
||||
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"secure_1psid": "<__Secure-1PSID>", "secure_1psidts": "<__Secure-1PSIDTS>", "label": "<LABEL>"}' \
|
||||
http://localhost:8317/v0/management/gemini-web-token
|
||||
```
|
||||
- Response:
|
||||
```json
|
||||
{ "status": "ok", "file": "gemini-web-<hash>.json" }
|
||||
```
|
||||
|
||||
- GET `/qwen-auth-url` — Start Qwen login (device flow)
|
||||
- Request:
|
||||
```bash
|
||||
|
||||
@@ -639,19 +639,6 @@
|
||||
{ "status": "ok", "url": "https://..." }
|
||||
```
|
||||
|
||||
- POST `/gemini-web-token` — 直接保存 Gemini Web Cookie
|
||||
- 请求:
|
||||
```bash
|
||||
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"secure_1psid": "<__Secure-1PSID>", "secure_1psidts": "<__Secure-1PSIDTS>", "label": "<LABEL>"}' \
|
||||
http://localhost:8317/v0/management/gemini-web-token
|
||||
```
|
||||
- 响应:
|
||||
```json
|
||||
{ "status": "ok", "file": "gemini-web-<hash>.json" }
|
||||
```
|
||||
|
||||
- GET `/qwen-auth-url` — 开始 Qwen 登录(设备授权流程)
|
||||
- 请求:
|
||||
```bash
|
||||
|
||||
130
README.md
130
README.md
@@ -17,7 +17,6 @@ Chinese providers have now been added: [Qwen Code](https://github.com/QwenLM/qwe
|
||||
- Claude Code support via OAuth login
|
||||
- Qwen Code support via OAuth login
|
||||
- iFlow support via OAuth login
|
||||
- Gemini Web support via cookie-based login
|
||||
- Streaming and non-streaming responses
|
||||
- Function calling/tools support
|
||||
- Multimodal input support (text and images)
|
||||
@@ -62,6 +61,12 @@ Chinese providers have now been added: [Qwen Code](https://github.com/QwenLM/qwe
|
||||
go build -o cli-proxy-api.exe ./cmd/server
|
||||
```
|
||||
|
||||
### Installation via Homebrew
|
||||
|
||||
```bash
|
||||
brew install cliproxyapi
|
||||
brew services start cliproxyapi
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -93,13 +98,6 @@ You can authenticate for Gemini, OpenAI, Claude, Qwen, and/or iFlow. All can coe
|
||||
|
||||
Options: add `--no-browser` to print the login URL instead of opening a browser. The local OAuth callback uses port `8085`.
|
||||
|
||||
- Gemini Web (via Cookies):
|
||||
This method authenticates by simulating a browser, using cookies obtained from the Gemini website.
|
||||
```bash
|
||||
./cli-proxy-api --gemini-web-auth
|
||||
```
|
||||
You will be prompted to enter your `__Secure-1PSID` and `__Secure-1PSIDTS` values. Please retrieve these cookies from your browser's developer tools.
|
||||
|
||||
- OpenAI (Codex/GPT via OAuth):
|
||||
```bash
|
||||
./cli-proxy-api --codex-login
|
||||
@@ -258,6 +256,8 @@ console.log(await claudeResponse.json());
|
||||
- gemini-2.5-pro
|
||||
- gemini-2.5-flash
|
||||
- gemini-2.5-flash-lite
|
||||
- gemini-2.5-flash-image
|
||||
- gemini-2.5-flash-image-preview
|
||||
- gpt-5
|
||||
- gpt-5-codex
|
||||
- claude-opus-4-1-20250805
|
||||
@@ -276,6 +276,7 @@ console.log(await claudeResponse.json());
|
||||
- deepseek-v3
|
||||
- kimi-k2
|
||||
- glm-4.5
|
||||
- glm-4.6
|
||||
- tstars2.0
|
||||
- And other iFlow-supported models
|
||||
- Gemini models auto-switch to preview variants when needed
|
||||
@@ -325,11 +326,6 @@ The server uses a YAML configuration file (`config.yaml`) located in the project
|
||||
| `openai-compatibility.*.models` | object[] | [] | The actual model name. |
|
||||
| `openai-compatibility.*.models.*.name` | string | "" | The models supported by the provider. |
|
||||
| `openai-compatibility.*.models.*.alias` | string | "" | The alias used in the API. |
|
||||
| `gemini-web` | object | {} | Configuration specific to the Gemini Web client. |
|
||||
| `gemini-web.context` | boolean | true | Enables conversation context reuse for continuous dialogue. |
|
||||
| `gemini-web.code-mode` | boolean | false | Enables code mode for optimized responses in coding-related tasks. |
|
||||
| `gemini-web.max-chars-per-request` | integer | 1,000,000 | The maximum number of characters to send to Gemini Web in a single request. |
|
||||
| `gemini-web.disable-continuation-hint` | boolean | false | Disables the continuation hint for split prompts. |
|
||||
|
||||
### Example Configuration File
|
||||
|
||||
@@ -354,6 +350,11 @@ remote-management:
|
||||
# Authentication directory (supports ~ for home directory). If you use Windows, please set the directory like this: `C:/cli-proxy-api/`
|
||||
auth-dir: "~/.cli-proxy-api"
|
||||
|
||||
# API keys for authentication
|
||||
api-keys:
|
||||
- "your-api-key-1"
|
||||
- "your-api-key-2"
|
||||
|
||||
# Enable debug logging
|
||||
debug: false
|
||||
|
||||
@@ -374,12 +375,6 @@ 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
|
||||
|
||||
# Gemini Web client configuration
|
||||
gemini-web:
|
||||
context: true # Enable conversation context reuse
|
||||
code-mode: false # Enable code mode
|
||||
max-chars-per-request: 1000000 # Max characters per request
|
||||
|
||||
# API keys for official Generative Language API
|
||||
generative-language-api-key:
|
||||
- "AIzaSy...01"
|
||||
@@ -418,6 +413,49 @@ openai-compatibility:
|
||||
alias: "kimi-k2" # The alias used in the API.
|
||||
```
|
||||
|
||||
### Git-backed Configuration and Token Store
|
||||
|
||||
The application can be configured to use a Git repository as a backend for storing both the `config.yaml` file and the authentication tokens from the `auth-dir`. This allows for centralized management and versioning of your configuration.
|
||||
|
||||
To enable this feature, set the `GITSTORE_GIT_URL` environment variable to the URL of your Git repository.
|
||||
|
||||
**Environment Variables**
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|-------------------------|----------|---------------------------|---------------------------------------------------------------------------------------------------------|
|
||||
| `MANAGEMENT_PASSWORD` | Yes | | The password for management webui. |
|
||||
| `GITSTORE_GIT_URL` | Yes | | The HTTPS URL of the Git repository to use. |
|
||||
| `GITSTORE_LOCAL_PATH` | No | Current working directory | The local path where the Git repository will be cloned. Inside Docker, this defaults to `/CLIProxyAPI`. |
|
||||
| `GITSTORE_GIT_USERNAME` | No | | The username for Git authentication. |
|
||||
| `GITSTORE_GIT_TOKEN` | No | | The personal access token (or password) for Git authentication. |
|
||||
|
||||
**How it Works**
|
||||
|
||||
1. **Cloning:** On startup, the application clones the remote Git repository to the `GITSTORE_LOCAL_PATH`.
|
||||
2. **Configuration:** It then looks for a `config.yaml` inside a `config` directory within the cloned repository.
|
||||
3. **Bootstrapping:** If `config/config.yaml` does not exist in the repository, the application will copy the local `config.example.yaml` to that location, commit, and push it to the remote repository as an initial configuration. You must have `config.example.yaml` available.
|
||||
4. **Token Sync:** The `auth-dir` is also managed within this repository. Any changes to authentication tokens (e.g., through a new login) are automatically committed and pushed to the remote Git repository.
|
||||
|
||||
### PostgreSQL-backed Configuration and Token Store
|
||||
|
||||
You can also persist configuration and authentication data in PostgreSQL when running CLIProxyAPI in hosted environments that favor managed databases over local files.
|
||||
|
||||
**Environment Variables**
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|-----------------------|----------|-----------------------|---------------------------------------------------------------------------------------------------------------|
|
||||
| `MANAGEMENT_PASSWORD` | Yes | | Password for the management web UI (required when remote management is enabled). |
|
||||
| `PGSTORE_DSN` | Yes | | PostgreSQL connection string (e.g. `postgresql://user:pass@host:5432/db`). |
|
||||
| `PGSTORE_SCHEMA` | No | public | Schema where the tables will be created. Leave empty to use the default schema. |
|
||||
| `PGSTORE_LOCAL_PATH` | No | Current working directory | Root directory for the local mirror; the server writes to `<value>/pgstore`. If unset and CWD is unavailable, `/tmp/pgstore` is used. |
|
||||
|
||||
**How it Works**
|
||||
|
||||
1. **Initialization:** On startup the server connects via `PGSTORE_DSN`, ensures the schema exists, and creates the `config_store` / `auth_store` tables when missing.
|
||||
2. **Local Mirror:** A writable cache at `<PGSTORE_LOCAL_PATH or CWD>/pgstore` mirrors `config/config.yaml` and `auths/` so the rest of the application can reuse the existing file-based logic.
|
||||
3. **Bootstrapping:** If no configuration row exists, `config.example.yaml` seeds the database using the fixed identifier `config`.
|
||||
4. **Token Sync:** Changes flow both ways—file updates are written to PostgreSQL and database records are mirrored back to disk so watchers and management APIs continue to operate.
|
||||
|
||||
### OpenAI Compatibility Providers
|
||||
|
||||
Configure upstream OpenAI-compatible providers (e.g., OpenRouter) via `openai-compatibility`.
|
||||
@@ -590,12 +628,6 @@ Run the following command to login (Gemini OAuth on port 8085):
|
||||
docker run --rm -p 8085:8085 -v /path/to/your/config.yaml:/CLIProxyAPI/config.yaml -v /path/to/your/auth-dir:/root/.cli-proxy-api eceasy/cli-proxy-api:latest /CLIProxyAPI/CLIProxyAPI --login
|
||||
```
|
||||
|
||||
Run the following command to login (Gemini Web Cookies):
|
||||
|
||||
```bash
|
||||
docker run -it --rm -v /path/to/your/config.yaml:/CLIProxyAPI/config.yaml -v /path/to/your/auth-dir:/root/.cli-proxy-api eceasy/cli-proxy-api:latest /CLIProxyAPI/CLIProxyAPI --gemini-web-auth
|
||||
```
|
||||
|
||||
Run the following command to login (OpenAI OAuth on port 1455):
|
||||
|
||||
```bash
|
||||
@@ -626,6 +658,18 @@ Run the following command to start the server:
|
||||
docker run --rm -p 8317:8317 -v /path/to/your/config.yaml:/CLIProxyAPI/config.yaml -v /path/to/your/auth-dir:/root/.cli-proxy-api eceasy/cli-proxy-api:latest
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> To use the Git-backed configuration store with Docker, you can pass the `GITSTORE_*` environment variables using the `-e` flag. For example:
|
||||
>
|
||||
> ```bash
|
||||
> docker run --rm -p 8317:8317 \
|
||||
> -e GITSTORE_GIT_URL="https://github.com/your/config-repo.git" \
|
||||
> -e GITSTORE_GIT_TOKEN="your_personal_access_token" \
|
||||
> -v /path/to/your/git-store:/CLIProxyAPI/remote \
|
||||
> eceasy/cli-proxy-api:latest
|
||||
> ```
|
||||
> In this case, you may not need to mount `config.yaml` or `auth-dir` directly, as they will be managed by the Git store inside the container at the `GITSTORE_LOCAL_PATH` (which defaults to `/CLIProxyAPI` and we are setting it to `/CLIProxyAPI/remote` in this example).
|
||||
|
||||
## Run with Docker Compose
|
||||
|
||||
1. Clone the repository and navigate into the directory:
|
||||
@@ -641,6 +685,27 @@ docker run --rm -p 8317:8317 -v /path/to/your/config.yaml:/CLIProxyAPI/config.ya
|
||||
```
|
||||
*(Note for Windows users: You can use `copy config.example.yaml config.yaml` in CMD or PowerShell.)*
|
||||
|
||||
To use the Git-backed configuration store, you can add the `GITSTORE_*` environment variables to your `docker-compose.yml` file under the `cli-proxy-api` service definition. For example:
|
||||
```yaml
|
||||
services:
|
||||
cli-proxy-api:
|
||||
image: eceasy/cli-proxy-api:latest
|
||||
container_name: cli-proxy-api
|
||||
ports:
|
||||
- "8317:8317"
|
||||
- "8085:8085"
|
||||
- "1455:1455"
|
||||
- "54545:54545"
|
||||
- "11451:11451"
|
||||
environment:
|
||||
- GITSTORE_GIT_URL=https://github.com/your/config-repo.git
|
||||
- GITSTORE_GIT_TOKEN=your_personal_access_token
|
||||
volumes:
|
||||
- ./git-store:/CLIProxyAPI/remote # GITSTORE_LOCAL_PATH
|
||||
restart: unless-stopped
|
||||
```
|
||||
When using the Git store, you may not need to mount `config.yaml` or `auth-dir` directly.
|
||||
|
||||
3. Start the service:
|
||||
- **For most users (recommended):**
|
||||
Run the following command to start the service using the pre-built image from Docker Hub. The service will run in the background.
|
||||
@@ -666,10 +731,6 @@ docker run --rm -p 8317:8317 -v /path/to/your/config.yaml:/CLIProxyAPI/config.ya
|
||||
```bash
|
||||
docker compose exec cli-proxy-api /CLIProxyAPI/CLIProxyAPI -no-browser --login
|
||||
```
|
||||
- **Gemini Web**:
|
||||
```bash
|
||||
docker compose exec cli-proxy-api /CLIProxyAPI/CLIProxyAPI --gemini-web-auth
|
||||
```
|
||||
- **OpenAI (Codex)**:
|
||||
```bash
|
||||
docker compose exec cli-proxy-api /CLIProxyAPI/CLIProxyAPI -no-browser --codex-login
|
||||
@@ -719,6 +780,17 @@ Contributions are welcome! Please feel free to submit a Pull Request.
|
||||
4. Push to the branch (`git push origin feature/amazing-feature`)
|
||||
5. Open a Pull Request
|
||||
|
||||
## Who is with us?
|
||||
|
||||
Those projects are based on CLIProxyAPI:
|
||||
|
||||
### [vibeproxy](https://github.com/automazeio/vibeproxy)
|
||||
|
||||
Native macOS menu bar app to use your Claude Code & ChatGPT subscriptions with AI coding tools - no API keys needed
|
||||
|
||||
> [!NOTE]
|
||||
> If you developed a project based on CLIProxyAPI, please open a PR to add it to this list.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
132
README_CN.md
132
README_CN.md
@@ -37,7 +37,6 @@
|
||||
- 新增 Claude Code 支持(OAuth 登录)
|
||||
- 新增 Qwen Code 支持(OAuth 登录)
|
||||
- 新增 iFlow 支持(OAuth 登录)
|
||||
- 新增 Gemini Web 支持(通过 Cookie 登录)
|
||||
- 支持流式与非流式响应
|
||||
- 函数调用/工具支持
|
||||
- 多模态输入(文本、图片)
|
||||
@@ -76,6 +75,13 @@
|
||||
go build -o cli-proxy-api ./cmd/server
|
||||
```
|
||||
|
||||
### 通过 Homebrew 安装
|
||||
|
||||
```bash
|
||||
brew install cliproxyapi
|
||||
brew services start cliproxyapi
|
||||
```
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 图形客户端与官方 WebUI
|
||||
@@ -106,13 +112,6 @@ CLIProxyAPI 的基于 Web 的管理中心。
|
||||
|
||||
选项:加上 `--no-browser` 可打印登录地址而不自动打开浏览器。本地 OAuth 回调端口为 `8085`。
|
||||
|
||||
- Gemini Web (通过 Cookie):
|
||||
此方法通过模拟浏览器行为,使用从 Gemini 网站获取的 Cookie 进行身份验证。
|
||||
```bash
|
||||
./cli-proxy-api --gemini-web-auth
|
||||
```
|
||||
程序将提示您输入 `__Secure-1PSID` 和 `__Secure-1PSIDTS` 的值。请从您的浏览器开发者工具中获取这些 Cookie。
|
||||
|
||||
- OpenAI(Codex/GPT,OAuth):
|
||||
```bash
|
||||
./cli-proxy-api --codex-login
|
||||
@@ -270,6 +269,8 @@ console.log(await claudeResponse.json());
|
||||
- gemini-2.5-pro
|
||||
- gemini-2.5-flash
|
||||
- gemini-2.5-flash-lite
|
||||
- gemini-2.5-flash-image
|
||||
- gemini-2.5-flash-image-preview
|
||||
- gpt-5
|
||||
- gpt-5-codex
|
||||
- claude-opus-4-1-20250805
|
||||
@@ -288,6 +289,7 @@ console.log(await claudeResponse.json());
|
||||
- deepseek-v3
|
||||
- kimi-k2
|
||||
- glm-4.5
|
||||
- glm-4.6
|
||||
- tstars2.0
|
||||
- 以及其他 iFlow 支持的模型
|
||||
- Gemini 模型在需要时自动切换到对应的 preview 版本
|
||||
@@ -337,11 +339,6 @@ console.log(await claudeResponse.json());
|
||||
| `openai-compatibility.*.models` | object[] | [] | 实际的模型名称。 |
|
||||
| `openai-compatibility.*.models.*.name` | string | "" | 提供商支持的模型。 |
|
||||
| `openai-compatibility.*.models.*.alias` | string | "" | 在API中使用的别名。 |
|
||||
| `gemini-web` | object | {} | Gemini Web 客户端的特定配置。 |
|
||||
| `gemini-web.context` | boolean | true | 是否启用会话上下文重用,以实现连续对话。 |
|
||||
| `gemini-web.code-mode` | boolean | false | 是否启用代码模式,优化代码相关任务的响应。 |
|
||||
| `gemini-web.max-chars-per-request` | integer | 1,000,000 | 单次请求发送给 Gemini Web 的最大字符数。 |
|
||||
| `gemini-web.disable-continuation-hint` | boolean | false | 当提示被拆分时,是否禁用连续提示的暗示。 |
|
||||
|
||||
### 配置文件示例
|
||||
|
||||
@@ -365,6 +362,11 @@ remote-management:
|
||||
# 身份验证目录(支持 ~ 表示主目录)。如果你使用Windows,建议设置成`C:/cli-proxy-api/`。
|
||||
auth-dir: "~/.cli-proxy-api"
|
||||
|
||||
# 请求认证使用的API密钥
|
||||
api-keys:
|
||||
- "your-api-key-1"
|
||||
- "your-api-key-2"
|
||||
|
||||
# 启用调试日志
|
||||
debug: false
|
||||
|
||||
@@ -386,12 +388,6 @@ quota-exceeded:
|
||||
switch-project: true # 当配额超限时是否自动切换到另一个项目
|
||||
switch-preview-model: true # 当配额超限时是否自动切换到预览模型
|
||||
|
||||
# Gemini Web 客户端配置
|
||||
gemini-web:
|
||||
context: true # 启用会话上下文重用
|
||||
code-mode: false # 启用代码模式
|
||||
max-chars-per-request: 1000000 # 单次请求最大字符数
|
||||
|
||||
# AIStduio Gemini API 的 API 密钥
|
||||
generative-language-api-key:
|
||||
- "AIzaSy...01"
|
||||
@@ -430,6 +426,49 @@ openai-compatibility:
|
||||
alias: "kimi-k2" # 在API中使用的别名。
|
||||
```
|
||||
|
||||
### Git 支持的配置与令牌存储
|
||||
|
||||
应用程序可配置为使用 Git 仓库作为后端,用于存储 `config.yaml` 配置文件和来自 `auth-dir` 目录的身份验证令牌。这允许对您的配置进行集中管理和版本控制。
|
||||
|
||||
要启用此功能,请将 `GITSTORE_GIT_URL` 环境变量设置为您的 Git 仓库的 URL。
|
||||
|
||||
**环境变量**
|
||||
|
||||
| 变量 | 必需 | 默认值 | 描述 |
|
||||
|-------------------------|----|--------|----------------------------------------------------|
|
||||
| `MANAGEMENT_PASSWORD` | 是 | | 控制面板密码 |
|
||||
| `GITSTORE_GIT_URL` | 是 | | 要使用的 Git 仓库的 HTTPS URL。 |
|
||||
| `GITSTORE_LOCAL_PATH` | 否 | 当前工作目录 | 将克隆 Git 仓库的本地路径。在 Docker 内部,此路径默认为 `/CLIProxyAPI`。 |
|
||||
| `GITSTORE_GIT_USERNAME` | 否 | | 用于 Git 身份验证的用户名。 |
|
||||
| `GITSTORE_GIT_TOKEN` | 否 | | 用于 Git 身份验证的个人访问令牌(或密码)。 |
|
||||
|
||||
**工作原理**
|
||||
|
||||
1. **克隆:** 启动时,应用程序会将远程 Git 仓库克隆到 `GITSTORE_LOCAL_PATH`。
|
||||
2. **配置:** 然后,它会在克隆的仓库内的 `config` 目录中查找 `config.yaml` 文件。
|
||||
3. **引导:** 如果仓库中不存在 `config/config.yaml`,应用程序会将本地的 `config.example.yaml` 复制到该位置,然后提交并推送到远程仓库作为初始配置。您必须确保 `config.example.yaml` 文件可用。
|
||||
4. **令牌同步:** `auth-dir` 也在此仓库中管理。对身份验证令牌的任何更改(例如,通过新的登录)都会自动提交并推送到远程 Git 仓库。
|
||||
|
||||
### PostgreSQL 支持的配置与令牌存储
|
||||
|
||||
在托管环境中运行服务时,可以选择使用 PostgreSQL 来保存配置与令牌,借助托管数据库减轻本地文件管理压力。
|
||||
|
||||
**环境变量**
|
||||
|
||||
| 变量 | 必需 | 默认值 | 描述 |
|
||||
|-------------------------|----|---------------|----------------------------------------------------------------------|
|
||||
| `MANAGEMENT_PASSWORD` | 是 | | 管理面板密码(启用远程管理时必需)。 |
|
||||
| `PGSTORE_DSN` | 是 | | PostgreSQL 连接串,例如 `postgresql://user:pass@host:5432/db`。 |
|
||||
| `PGSTORE_SCHEMA` | 否 | public | 创建表时使用的 schema;留空则使用默认 schema。 |
|
||||
| `PGSTORE_LOCAL_PATH` | 否 | 当前工作目录 | 本地镜像根目录,服务将在 `<值>/pgstore` 下写入缓存;若无法获取工作目录则退回 `/tmp/pgstore`。 |
|
||||
|
||||
**工作原理**
|
||||
|
||||
1. **初始化:** 启动时通过 `PGSTORE_DSN` 连接数据库,确保 schema 存在,并在缺失时创建 `config_store` 与 `auth_store`。
|
||||
2. **本地镜像:** 在 `<PGSTORE_LOCAL_PATH 或当前工作目录>/pgstore` 下建立可写缓存,复用 `config/config.yaml` 与 `auths/` 目录。
|
||||
3. **引导:** 若数据库中无配置记录,会使用 `config.example.yaml` 初始化,并以固定标识 `config` 写入。
|
||||
4. **令牌同步:** 配置与令牌的更改会写入 PostgreSQL,同时数据库中的内容也会反向同步至本地镜像,便于文件监听与管理接口继续工作。
|
||||
|
||||
### OpenAI 兼容上游提供商
|
||||
|
||||
通过 `openai-compatibility` 配置上游 OpenAI 兼容提供商(例如 OpenRouter)。
|
||||
@@ -598,12 +637,6 @@ auth.json:
|
||||
docker run --rm -p 8085:8085 -v /path/to/your/config.yaml:/CLIProxyAPI/config.yaml -v /path/to/your/auth-dir:/root/.cli-proxy-api eceasy/cli-proxy-api:latest /CLIProxyAPI/CLIProxyAPI --login
|
||||
```
|
||||
|
||||
运行以下命令进行登录(Gemini Web Cookie):
|
||||
|
||||
```bash
|
||||
docker run -it --rm -v /path/to/your/config.yaml:/CLIProxyAPI/config.yaml -v /path/to/your/auth-dir:/root/.cli-proxy-api eceasy/cli-proxy-api:latest /CLIProxyAPI/CLIProxyAPI --gemini-web-auth
|
||||
```
|
||||
|
||||
运行以下命令进行登录(OpenAI OAuth,端口 1455):
|
||||
|
||||
```bash
|
||||
@@ -635,6 +668,18 @@ docker run --rm -p 11451:11451 -v /path/to/your/config.yaml:/CLIProxyAPI/config.
|
||||
docker run --rm -p 8317:8317 -v /path/to/your/config.yaml:/CLIProxyAPI/config.yaml -v /path/to/your/auth-dir:/root/.cli-proxy-api eceasy/cli-proxy-api:latest
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> 要在 Docker 中使用 Git 支持的配置存储,您可以使用 `-e` 标志传递 `GITSTORE_*` 环境变量。例如:
|
||||
>
|
||||
> ```bash
|
||||
> docker run --rm -p 8317:8317 \
|
||||
> -e GITSTORE_GIT_URL="https://github.com/your/config-repo.git" \
|
||||
> -e GITSTORE_GIT_TOKEN="your_personal_access_token" \
|
||||
> -v /path/to/your/git-store:/CLIProxyAPI/remote \
|
||||
> eceasy/cli-proxy-api:latest
|
||||
> ```
|
||||
> 在这种情况下,您可能不需要直接挂载 `config.yaml` 或 `auth-dir`,因为它们将由容器内的 Git 存储在 `GITSTORE_LOCAL_PATH`(默认为 `/CLIProxyAPI`,在此示例中我们将其设置为 `/CLIProxyAPI/remote`)进行管理。
|
||||
|
||||
## 使用 Docker Compose 运行
|
||||
|
||||
1. 克隆仓库并进入目录:
|
||||
@@ -650,6 +695,27 @@ docker run --rm -p 8317:8317 -v /path/to/your/config.yaml:/CLIProxyAPI/config.ya
|
||||
```
|
||||
*(Windows 用户请注意:您可以在 CMD 或 PowerShell 中使用 `copy config.example.yaml config.yaml`。)*
|
||||
|
||||
要在 Docker Compose 中使用 Git 支持的配置存储,您可以将 `GITSTORE_*` 环境变量添加到 `docker-compose.yml` 文件中的 `cli-proxy-api` 服务定义下。例如:
|
||||
```yaml
|
||||
services:
|
||||
cli-proxy-api:
|
||||
image: eceasy/cli-proxy-api:latest
|
||||
container_name: cli-proxy-api
|
||||
ports:
|
||||
- "8317:8317"
|
||||
- "8085:8085"
|
||||
- "1455:1455"
|
||||
- "54545:54545"
|
||||
- "11451:11451"
|
||||
environment:
|
||||
- GITSTORE_GIT_URL=https://github.com/your/config-repo.git
|
||||
- GITSTORE_GIT_TOKEN=your_personal_access_token
|
||||
volumes:
|
||||
- ./git-store:/CLIProxyAPI/remote # GITSTORE_LOCAL_PATH
|
||||
restart: unless-stopped
|
||||
```
|
||||
在使用 Git 存储时,您可能不需要直接挂载 `config.yaml` 或 `auth-dir`。
|
||||
|
||||
3. 启动服务:
|
||||
- **适用于大多数用户(推荐):**
|
||||
运行以下命令,使用 Docker Hub 上的预构建镜像启动服务。服务将在后台运行。
|
||||
@@ -675,10 +741,6 @@ docker run --rm -p 8317:8317 -v /path/to/your/config.yaml:/CLIProxyAPI/config.ya
|
||||
```bash
|
||||
docker compose exec cli-proxy-api /CLIProxyAPI/CLIProxyAPI -no-browser --login
|
||||
```
|
||||
- **Gemini Web**:
|
||||
```bash
|
||||
docker compose exec cli-proxy-api /CLIProxyAPI/CLIProxyAPI --gemini-web-auth
|
||||
```
|
||||
- **OpenAI (Codex)**:
|
||||
```bash
|
||||
docker compose exec cli-proxy-api /CLIProxyAPI/CLIProxyAPI -no-browser --codex-login
|
||||
@@ -728,6 +790,18 @@ docker run --rm -p 8317:8317 -v /path/to/your/config.yaml:/CLIProxyAPI/config.ya
|
||||
4. 推送到分支(`git push origin feature/amazing-feature`)
|
||||
5. 打开 Pull Request
|
||||
|
||||
## 谁与我们在一起?
|
||||
|
||||
这些项目基于 CLIProxyAPI:
|
||||
|
||||
### [vibeproxy](https://github.com/automazeio/vibeproxy)
|
||||
|
||||
一个原生 macOS 菜单栏应用,让您可以使用 Claude Code & ChatGPT 订阅服务和 AI 编程工具,无需 API 密钥。
|
||||
|
||||
> [!NOTE]
|
||||
> 如果你开发了基于 CLIProxyAPI 的项目,请提交一个 PR(拉取请求)将其添加到此列表中。
|
||||
|
||||
|
||||
## 许可证
|
||||
|
||||
此项目根据 MIT 许可证授权 - 有关详细信息,请参阅 [LICENSE](LICENSE) 文件。
|
||||
|
||||
@@ -4,15 +4,22 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
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/misc"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/store"
|
||||
_ "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"
|
||||
@@ -21,9 +28,10 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
Version = "dev"
|
||||
Commit = "none"
|
||||
BuildDate = "unknown"
|
||||
Version = "dev"
|
||||
Commit = "none"
|
||||
BuildDate = "unknown"
|
||||
DefaultConfigPath = ""
|
||||
)
|
||||
|
||||
// init initializes the shared logger setup.
|
||||
@@ -43,7 +51,6 @@ func main() {
|
||||
var claudeLogin bool
|
||||
var qwenLogin bool
|
||||
var iflowLogin bool
|
||||
var geminiWebAuth bool
|
||||
var noBrowser bool
|
||||
var projectID string
|
||||
var configPath string
|
||||
@@ -55,10 +62,9 @@ func main() {
|
||||
flag.BoolVar(&claudeLogin, "claude-login", false, "Login to Claude using OAuth")
|
||||
flag.BoolVar(&qwenLogin, "qwen-login", false, "Login to Qwen using OAuth")
|
||||
flag.BoolVar(&iflowLogin, "iflow-login", false, "Login to iFlow using OAuth")
|
||||
flag.BoolVar(&geminiWebAuth, "gemini-web-auth", false, "Auth Gemini Web using cookies")
|
||||
flag.BoolVar(&noBrowser, "no-browser", false, "Don't open browser automatically for OAuth")
|
||||
flag.StringVar(&projectID, "project_id", "", "Project ID (Gemini only, not required)")
|
||||
flag.StringVar(&configPath, "config", "", "Configure File Path")
|
||||
flag.StringVar(&configPath, "config", DefaultConfigPath, "Configure File Path")
|
||||
flag.StringVar(&password, "password", "", "")
|
||||
|
||||
flag.CommandLine.Usage = func() {
|
||||
@@ -94,26 +100,175 @@ func main() {
|
||||
// Core application variables.
|
||||
var err error
|
||||
var cfg *config.Config
|
||||
var wd string
|
||||
var isCloudDeploy bool
|
||||
var (
|
||||
usePostgresStore bool
|
||||
pgStoreDSN string
|
||||
pgStoreSchema string
|
||||
pgStoreLocalPath string
|
||||
pgStoreInst *store.PostgresStore
|
||||
gitStoreLocalPath string
|
||||
useGitStore bool
|
||||
gitStoreRemoteURL string
|
||||
gitStoreUser string
|
||||
gitStorePassword string
|
||||
gitStoreInst *store.GitTokenStore
|
||||
gitStoreRoot string
|
||||
)
|
||||
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to get working directory: %v", err)
|
||||
}
|
||||
|
||||
lookupEnv := func(keys ...string) (string, bool) {
|
||||
for _, key := range keys {
|
||||
if value, ok := os.LookupEnv(key); ok {
|
||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
||||
return trimmed, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
if value, ok := lookupEnv("PGSTORE_DSN", "pgstore_dsn"); ok {
|
||||
usePostgresStore = true
|
||||
pgStoreDSN = value
|
||||
}
|
||||
if usePostgresStore {
|
||||
if value, ok := lookupEnv("PGSTORE_SCHEMA", "pgstore_schema"); ok {
|
||||
pgStoreSchema = value
|
||||
}
|
||||
if value, ok := lookupEnv("PGSTORE_LOCAL_PATH", "pgstore_local_path"); ok {
|
||||
pgStoreLocalPath = value
|
||||
}
|
||||
useGitStore = false
|
||||
}
|
||||
if value, ok := lookupEnv("GITSTORE_GIT_URL", "gitstore_git_url"); ok {
|
||||
useGitStore = true
|
||||
gitStoreRemoteURL = value
|
||||
}
|
||||
if value, ok := lookupEnv("GITSTORE_GIT_USERNAME", "gitstore_git_username"); ok {
|
||||
gitStoreUser = value
|
||||
}
|
||||
if value, ok := lookupEnv("GITSTORE_GIT_TOKEN", "gitstore_git_token"); ok {
|
||||
gitStorePassword = value
|
||||
}
|
||||
if value, ok := lookupEnv("GITSTORE_LOCAL_PATH", "gitstore_local_path"); ok {
|
||||
gitStoreLocalPath = value
|
||||
}
|
||||
|
||||
// Check for cloud deploy mode only on first execution
|
||||
// Read env var name in uppercase: DEPLOY
|
||||
deployEnv := os.Getenv("DEPLOY")
|
||||
if deployEnv == "cloud" {
|
||||
isCloudDeploy = true
|
||||
}
|
||||
|
||||
// Determine and load the configuration file.
|
||||
// If a config path is provided via flags, it is used directly.
|
||||
// Otherwise, it defaults to "config.yaml" in the current working directory.
|
||||
// Prefer the Postgres store when configured, otherwise fallback to git or local files.
|
||||
var configFilePath string
|
||||
if configPath != "" {
|
||||
if usePostgresStore {
|
||||
if pgStoreLocalPath == "" {
|
||||
pgStoreLocalPath = wd
|
||||
}
|
||||
pgStoreLocalPath = filepath.Join(pgStoreLocalPath, "pgstore")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
pgStoreInst, err = store.NewPostgresStore(ctx, store.PostgresStoreConfig{
|
||||
DSN: pgStoreDSN,
|
||||
Schema: pgStoreSchema,
|
||||
SpoolDir: pgStoreLocalPath,
|
||||
})
|
||||
cancel()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to initialize postgres token store: %v", err)
|
||||
}
|
||||
examplePath := filepath.Join(wd, "config.example.yaml")
|
||||
ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second)
|
||||
if errBootstrap := pgStoreInst.Bootstrap(ctx, examplePath); errBootstrap != nil {
|
||||
cancel()
|
||||
log.Fatalf("failed to bootstrap postgres-backed config: %v", errBootstrap)
|
||||
}
|
||||
cancel()
|
||||
configFilePath = pgStoreInst.ConfigPath()
|
||||
cfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy)
|
||||
if err == nil {
|
||||
cfg.AuthDir = pgStoreInst.AuthDir()
|
||||
log.Infof("postgres-backed token store enabled, workspace path: %s", pgStoreInst.WorkDir())
|
||||
}
|
||||
} else if useGitStore {
|
||||
if gitStoreLocalPath == "" {
|
||||
gitStoreLocalPath = wd
|
||||
}
|
||||
gitStoreRoot = filepath.Join(gitStoreLocalPath, "gitstore")
|
||||
authDir := filepath.Join(gitStoreRoot, "auths")
|
||||
gitStoreInst = store.NewGitTokenStore(gitStoreRemoteURL, gitStoreUser, gitStorePassword)
|
||||
gitStoreInst.SetBaseDir(authDir)
|
||||
if errRepo := gitStoreInst.EnsureRepository(); errRepo != nil {
|
||||
log.Fatalf("failed to prepare git token store: %v", errRepo)
|
||||
}
|
||||
configFilePath = gitStoreInst.ConfigPath()
|
||||
if configFilePath == "" {
|
||||
configFilePath = filepath.Join(gitStoreRoot, "config", "config.yaml")
|
||||
}
|
||||
if _, statErr := os.Stat(configFilePath); errors.Is(statErr, fs.ErrNotExist) {
|
||||
examplePath := filepath.Join(wd, "config.example.yaml")
|
||||
if _, errExample := os.Stat(examplePath); errExample != nil {
|
||||
log.Fatalf("failed to find template config file: %v", errExample)
|
||||
}
|
||||
if errCopy := misc.CopyConfigTemplate(examplePath, configFilePath); errCopy != nil {
|
||||
log.Fatalf("failed to bootstrap git-backed config: %v", errCopy)
|
||||
}
|
||||
if errCommit := gitStoreInst.PersistConfig(context.Background()); errCommit != nil {
|
||||
log.Fatalf("failed to commit initial git-backed config: %v", errCommit)
|
||||
}
|
||||
log.Infof("git-backed config initialized from template: %s", configFilePath)
|
||||
} else if statErr != nil {
|
||||
log.Fatalf("failed to inspect git-backed config: %v", statErr)
|
||||
}
|
||||
cfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy)
|
||||
if err == nil {
|
||||
cfg.AuthDir = gitStoreInst.AuthDir()
|
||||
log.Infof("git-backed token store enabled, repository path: %s", gitStoreRoot)
|
||||
}
|
||||
} else if configPath != "" {
|
||||
configFilePath = configPath
|
||||
cfg, err = config.LoadConfig(configPath)
|
||||
cfg, err = config.LoadConfigOptional(configPath, isCloudDeploy)
|
||||
} else {
|
||||
wd, err = os.Getwd()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to get working directory: %v", err)
|
||||
}
|
||||
configFilePath = filepath.Join(wd, "config.yaml")
|
||||
cfg, err = config.LoadConfig(configFilePath)
|
||||
cfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy)
|
||||
}
|
||||
if err != nil {
|
||||
log.Fatalf("failed to load config: %v", err)
|
||||
}
|
||||
if cfg == nil {
|
||||
cfg = &config.Config{}
|
||||
}
|
||||
|
||||
// In cloud deploy mode, check if we have a valid configuration
|
||||
var configFileExists bool
|
||||
if isCloudDeploy {
|
||||
if info, errStat := os.Stat(configFilePath); errStat != nil {
|
||||
// Don't mislead: API server will not start until configuration is provided.
|
||||
log.Info("Cloud deploy mode: No configuration file detected; standing by for configuration")
|
||||
configFileExists = false
|
||||
} else if info.IsDir() {
|
||||
log.Info("Cloud deploy mode: Config path is a directory; standing by for configuration")
|
||||
configFileExists = false
|
||||
} else if cfg.Port == 0 {
|
||||
// LoadConfigOptional returns empty config when file is empty or invalid.
|
||||
// Config file exists but is empty or invalid; treat as missing config
|
||||
log.Info("Cloud deploy mode: Configuration file is empty or invalid; standing by for valid configuration")
|
||||
configFileExists = false
|
||||
} else {
|
||||
log.Info("Cloud deploy mode: Configuration file detected; starting service")
|
||||
configFileExists = true
|
||||
}
|
||||
}
|
||||
usage.SetStatisticsEnabled(cfg.UsageStatisticsEnabled)
|
||||
|
||||
if err = logging.ConfigureLogOutput(cfg.LoggingToFile); err != nil {
|
||||
@@ -137,7 +292,13 @@ func main() {
|
||||
}
|
||||
|
||||
// Register the shared token store once so all components use the same persistence backend.
|
||||
sdkAuth.RegisterTokenStore(sdkAuth.NewFileTokenStore())
|
||||
if usePostgresStore {
|
||||
sdkAuth.RegisterTokenStore(pgStoreInst)
|
||||
} else if useGitStore {
|
||||
sdkAuth.RegisterTokenStore(gitStoreInst)
|
||||
} else {
|
||||
sdkAuth.RegisterTokenStore(sdkAuth.NewFileTokenStore())
|
||||
}
|
||||
|
||||
// Register built-in access providers before constructing services.
|
||||
configaccess.Register()
|
||||
@@ -157,9 +318,13 @@ func main() {
|
||||
cmd.DoQwenLogin(cfg, options)
|
||||
} else if iflowLogin {
|
||||
cmd.DoIFlowLogin(cfg, options)
|
||||
} else if geminiWebAuth {
|
||||
cmd.DoGeminiWebAuth(cfg)
|
||||
} else {
|
||||
// In cloud deploy mode without config file, just wait for shutdown signals
|
||||
if isCloudDeploy && !configFileExists {
|
||||
// No config file available, just wait for shutdown
|
||||
cmd.WaitForCloudDeploy()
|
||||
return
|
||||
}
|
||||
// Start the main proxy service
|
||||
cmd.StartService(cfg, configFilePath, password)
|
||||
}
|
||||
|
||||
@@ -79,21 +79,3 @@ quota-exceeded:
|
||||
# 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
|
||||
|
||||
@@ -10,6 +10,8 @@ services:
|
||||
COMMIT: ${COMMIT:-none}
|
||||
BUILD_DATE: ${BUILD_DATE:-unknown}
|
||||
container_name: cli-proxy-api
|
||||
environment:
|
||||
DEPLOY: ${DEPLOY:-}
|
||||
ports:
|
||||
- "8317:8317"
|
||||
- "8085:8085"
|
||||
@@ -20,5 +22,4 @@ services:
|
||||
- ./config.yaml:/CLIProxyAPI/config.yaml
|
||||
- ./auths:/root/.cli-proxy-api
|
||||
- ./logs:/CLIProxyAPI/logs
|
||||
- ./conv:/CLIProxyAPI/conv
|
||||
restart: unless-stopped
|
||||
|
||||
34
go.mod
34
go.mod
@@ -1,49 +1,65 @@
|
||||
module github.com/router-for-me/CLIProxyAPI/v6
|
||||
|
||||
go 1.24
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/fsnotify/fsnotify v1.9.0
|
||||
github.com/gin-gonic/gin v1.10.1
|
||||
github.com/go-git/go-git/v6 v6.0.0-20251009132922-75a182125145
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.7.6
|
||||
github.com/klauspost/compress v1.17.3
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966
|
||||
github.com/tidwall/gjson v1.18.0
|
||||
github.com/tidwall/sjson v1.2.5
|
||||
go.etcd.io/bbolt v1.3.8
|
||||
golang.org/x/crypto v0.36.0
|
||||
golang.org/x/net v0.37.1-0.20250305215238-2914f4677317
|
||||
golang.org/x/crypto v0.43.0
|
||||
golang.org/x/net v0.46.0
|
||||
golang.org/x/oauth2 v0.30.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go/compute/metadata v0.3.0 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.3.0 // indirect
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cloudflare/circl v1.6.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-git/gcfg/v2 v2.0.2 // indirect
|
||||
github.com/go-git/go-billy/v6 v6.0.0-20250627091229-31e2a16eef30 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.17.3 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/kevinburke/ssh_config v1.4.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/pjbgf/sha1cd v0.5.0 // indirect
|
||||
github.com/sergi/go-diff v1.4.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
|
||||
)
|
||||
|
||||
87
go.sum
87
go.sum
@@ -1,16 +1,32 @@
|
||||
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
|
||||
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
|
||||
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
|
||||
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
|
||||
github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
|
||||
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
|
||||
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
@@ -19,6 +35,16 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
||||
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||
github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo=
|
||||
github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs=
|
||||
github.com/go-git/go-billy/v6 v6.0.0-20250627091229-31e2a16eef30 h1:4KqVJTL5eanN8Sgg3BV6f2/QzfZEFbCd+rTak1fGRRA=
|
||||
github.com/go-git/go-billy/v6 v6.0.0-20250627091229-31e2a16eef30/go.mod h1:snwvGrbywVFy2d6KJdQ132zapq4aLyzLMgpo79XdEfM=
|
||||
github.com/go-git/go-git-fixtures/v5 v5.1.1 h1:OH8i1ojV9bWfr0ZfasfpgtUXQHQyVS8HXik/V1C099w=
|
||||
github.com/go-git/go-git-fixtures/v5 v5.1.1/go.mod h1:Altk43lx3b1ks+dVoAG2300o5WWUnktvfY3VI6bcaXU=
|
||||
github.com/go-git/go-git/v6 v6.0.0-20251009132922-75a182125145 h1:C/oVxHd6KkkuvthQ/StZfHzZK07gl6xjfCfT3derko0=
|
||||
github.com/go-git/go-git/v6 v6.0.0-20251009132922-75a182125145/go.mod h1:gR+xpbL+o1wuJJDwRN4pOkpNwDS0D24Eo4AD5Aau2DY=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
@@ -29,19 +55,37 @@ github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBEx
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
|
||||
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ=
|
||||
github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
|
||||
github.com/klauspost/compress v1.17.3 h1:qkRjuerhUU1EmXLYGkSH6EZL+vPSxIrYjLNAK4slzwA=
|
||||
github.com/klauspost/compress v1.17.3/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
@@ -53,8 +97,14 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
|
||||
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
||||
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=
|
||||
@@ -64,13 +114,15 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
@@ -84,32 +136,37 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
go.etcd.io/bbolt v1.3.8 h1:xs88BrvEv273UsB79e0hcVrlUWmS0a8upikMFhSyAtA=
|
||||
go.etcd.io/bbolt v1.3.8/go.mod h1:N9Mkw9X8x5fupy0IKsmuqVtoGDyxsaDlbk4Rd05IAQw=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||
golang.org/x/net v0.37.1-0.20250305215238-2914f4677317 h1:wneCP+2d9NUmndnyTmY7VwUNYiP26xiN/AtdcojQ1lI=
|
||||
golang.org/x/net v0.37.1-0.20250305215238-2914f4677317/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
|
||||
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -51,9 +51,10 @@ func ReconcileProviders(oldCfg, newCfg *config.Config, existing []sdkaccess.Prov
|
||||
continue
|
||||
}
|
||||
|
||||
forceRebuild := strings.EqualFold(strings.TrimSpace(providerCfg.Type), sdkConfig.AccessProviderTypeConfigAPIKey)
|
||||
if oldCfgProvider, ok := oldCfgMap[key]; ok {
|
||||
isAliased := oldCfgProvider == providerCfg
|
||||
if !isAliased && providerConfigEqual(oldCfgProvider, providerCfg) {
|
||||
if !forceRebuild && !isAliased && providerConfigEqual(oldCfgProvider, providerCfg) {
|
||||
if existingProvider, okExisting := existingMap[key]; okExisting {
|
||||
result = append(result, existingProvider)
|
||||
finalIDs[key] = struct{}{}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
package management
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -24,7 +23,7 @@ import (
|
||||
geminiAuth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini"
|
||||
iflowauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/iflow"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/qwen"
|
||||
// legacy client removed
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
||||
@@ -42,9 +41,14 @@ var (
|
||||
var lastRefreshKeys = []string{"last_refresh", "lastRefresh", "last_refreshed_at", "lastRefreshedAt"}
|
||||
|
||||
const (
|
||||
anthropicCallbackPort = 54545
|
||||
geminiCallbackPort = 8085
|
||||
codexCallbackPort = 1455
|
||||
anthropicCallbackPort = 54545
|
||||
geminiCallbackPort = 8085
|
||||
codexCallbackPort = 1455
|
||||
geminiCLIEndpoint = "https://cloudcode-pa.googleapis.com"
|
||||
geminiCLIVersion = "v1internal"
|
||||
geminiCLIUserAgent = "google-api-nodejs-client/9.15.1"
|
||||
geminiCLIApiClient = "gl-node/22.17.0"
|
||||
geminiCLIClientMetadata = "ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI"
|
||||
)
|
||||
|
||||
type callbackForwarder struct {
|
||||
@@ -248,7 +252,9 @@ func (h *Handler) ListAuthFiles(c *gin.Context) {
|
||||
full := filepath.Join(h.cfg.AuthDir, name)
|
||||
if data, errRead := os.ReadFile(full); errRead == nil {
|
||||
typeValue := gjson.GetBytes(data, "type").String()
|
||||
emailValue := gjson.GetBytes(data, "email").String()
|
||||
fileData["type"] = typeValue
|
||||
fileData["email"] = emailValue
|
||||
}
|
||||
|
||||
files = append(files, fileData)
|
||||
@@ -764,6 +770,8 @@ func (h *Handler) RequestGeminiCLIToken(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
requestedProjectID := strings.TrimSpace(projectID)
|
||||
|
||||
// Create token storage (mirrors internal/auth/gemini createTokenStorage)
|
||||
httpClient := conf.Client(ctx, token)
|
||||
req, errNewRequest := http.NewRequestWithContext(ctx, "GET", "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", nil)
|
||||
@@ -823,13 +831,14 @@ func (h *Handler) RequestGeminiCLIToken(c *gin.Context) {
|
||||
|
||||
ts := geminiAuth.GeminiTokenStorage{
|
||||
Token: ifToken,
|
||||
ProjectID: projectID,
|
||||
ProjectID: requestedProjectID,
|
||||
Email: email,
|
||||
Auto: requestedProjectID == "",
|
||||
}
|
||||
|
||||
// Initialize authenticated HTTP client via GeminiAuth to honor proxy settings
|
||||
gemAuth := geminiAuth.NewGeminiAuth()
|
||||
_, errGetClient := gemAuth.GetAuthenticatedClient(ctx, &ts, h.cfg, true)
|
||||
gemClient, errGetClient := gemAuth.GetAuthenticatedClient(ctx, &ts, h.cfg, true)
|
||||
if errGetClient != nil {
|
||||
log.Fatalf("failed to get authenticated client: %v", errGetClient)
|
||||
oauthStatus[state] = "Failed to get authenticated client"
|
||||
@@ -837,15 +846,44 @@ func (h *Handler) RequestGeminiCLIToken(c *gin.Context) {
|
||||
}
|
||||
fmt.Println("Authentication successful.")
|
||||
|
||||
if errEnsure := ensureGeminiProjectAndOnboard(ctx, gemClient, &ts, requestedProjectID); errEnsure != nil {
|
||||
log.Errorf("Failed to complete Gemini CLI onboarding: %v", errEnsure)
|
||||
oauthStatus[state] = "Failed to complete Gemini CLI onboarding"
|
||||
return
|
||||
}
|
||||
|
||||
if strings.TrimSpace(ts.ProjectID) == "" {
|
||||
log.Error("Onboarding did not return a project ID")
|
||||
oauthStatus[state] = "Failed to resolve project ID"
|
||||
return
|
||||
}
|
||||
|
||||
isChecked, errCheck := checkCloudAPIIsEnabled(ctx, gemClient, ts.ProjectID)
|
||||
if errCheck != nil {
|
||||
log.Errorf("Failed to verify Cloud AI API status: %v", errCheck)
|
||||
oauthStatus[state] = "Failed to verify Cloud AI API status"
|
||||
return
|
||||
}
|
||||
ts.Checked = isChecked
|
||||
if !isChecked {
|
||||
log.Error("Cloud AI API is not enabled for the selected project")
|
||||
oauthStatus[state] = "Cloud AI API not enabled"
|
||||
return
|
||||
}
|
||||
|
||||
recordMetadata := map[string]any{
|
||||
"email": ts.Email,
|
||||
"project_id": ts.ProjectID,
|
||||
"auto": ts.Auto,
|
||||
"checked": ts.Checked,
|
||||
}
|
||||
|
||||
record := &coreauth.Auth{
|
||||
ID: fmt.Sprintf("gemini-%s.json", ts.Email),
|
||||
ID: fmt.Sprintf("gemini-%s-%s.json", ts.Email, ts.ProjectID),
|
||||
Provider: "gemini",
|
||||
FileName: fmt.Sprintf("gemini-%s.json", ts.Email),
|
||||
FileName: fmt.Sprintf("gemini-%s-%s.json", ts.Email, ts.ProjectID),
|
||||
Storage: &ts,
|
||||
Metadata: map[string]any{
|
||||
"email": ts.Email,
|
||||
"project_id": ts.ProjectID,
|
||||
},
|
||||
Metadata: recordMetadata,
|
||||
}
|
||||
savedPath, errSave := h.saveTokenRecord(ctx, record)
|
||||
if errSave != nil {
|
||||
@@ -862,65 +900,6 @@ func (h *Handler) RequestGeminiCLIToken(c *gin.Context) {
|
||||
c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state})
|
||||
}
|
||||
|
||||
func (h *Handler) CreateGeminiWebToken(c *gin.Context) {
|
||||
ctx := c.Request.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"})
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
if payload.Secure1PSIDTS == "" {
|
||||
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))
|
||||
hash := hex.EncodeToString(sha.Sum(nil))
|
||||
fileName := fmt.Sprintf("gemini-web-%s.json", hash[:16])
|
||||
|
||||
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 := &coreauth.Auth{
|
||||
ID: fileName,
|
||||
Provider: "gemini-web",
|
||||
FileName: fileName,
|
||||
Storage: tokenStorage,
|
||||
}
|
||||
|
||||
savedPath, errSave := h.saveTokenRecord(ctx, record)
|
||||
if errSave != nil {
|
||||
log.Errorf("Failed to save Gemini Web token: %v", errSave)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save token"})
|
||||
return
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
@@ -1331,6 +1310,292 @@ func (h *Handler) RequestIFlowToken(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "url": authURL, "state": state})
|
||||
}
|
||||
|
||||
type projectSelectionRequiredError struct{}
|
||||
|
||||
func (e *projectSelectionRequiredError) Error() string {
|
||||
return "gemini cli: project selection required"
|
||||
}
|
||||
|
||||
func ensureGeminiProjectAndOnboard(ctx context.Context, httpClient *http.Client, storage *geminiAuth.GeminiTokenStorage, requestedProject string) error {
|
||||
if storage == nil {
|
||||
return fmt.Errorf("gemini storage is nil")
|
||||
}
|
||||
|
||||
trimmedRequest := strings.TrimSpace(requestedProject)
|
||||
if trimmedRequest == "" {
|
||||
projects, errProjects := fetchGCPProjects(ctx, httpClient)
|
||||
if errProjects != nil {
|
||||
return fmt.Errorf("fetch project list: %w", errProjects)
|
||||
}
|
||||
if len(projects) == 0 {
|
||||
return fmt.Errorf("no Google Cloud projects available for this account")
|
||||
}
|
||||
trimmedRequest = strings.TrimSpace(projects[0].ProjectID)
|
||||
if trimmedRequest == "" {
|
||||
return fmt.Errorf("resolved project id is empty")
|
||||
}
|
||||
storage.Auto = true
|
||||
} else {
|
||||
storage.Auto = false
|
||||
}
|
||||
|
||||
if err := performGeminiCLISetup(ctx, httpClient, storage, trimmedRequest); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if strings.TrimSpace(storage.ProjectID) == "" {
|
||||
storage.ProjectID = trimmedRequest
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func performGeminiCLISetup(ctx context.Context, httpClient *http.Client, storage *geminiAuth.GeminiTokenStorage, requestedProject string) error {
|
||||
metadata := map[string]string{
|
||||
"ideType": "IDE_UNSPECIFIED",
|
||||
"platform": "PLATFORM_UNSPECIFIED",
|
||||
"pluginType": "GEMINI",
|
||||
}
|
||||
|
||||
trimmedRequest := strings.TrimSpace(requestedProject)
|
||||
explicitProject := trimmedRequest != ""
|
||||
|
||||
loadReqBody := map[string]any{
|
||||
"metadata": metadata,
|
||||
}
|
||||
if explicitProject {
|
||||
loadReqBody["cloudaicompanionProject"] = trimmedRequest
|
||||
}
|
||||
|
||||
var loadResp map[string]any
|
||||
if errLoad := callGeminiCLI(ctx, httpClient, "loadCodeAssist", loadReqBody, &loadResp); errLoad != nil {
|
||||
return fmt.Errorf("load code assist: %w", errLoad)
|
||||
}
|
||||
|
||||
tierID := "legacy-tier"
|
||||
if tiers, okTiers := loadResp["allowedTiers"].([]any); okTiers {
|
||||
for _, rawTier := range tiers {
|
||||
tier, okTier := rawTier.(map[string]any)
|
||||
if !okTier {
|
||||
continue
|
||||
}
|
||||
if isDefault, okDefault := tier["isDefault"].(bool); okDefault && isDefault {
|
||||
if id, okID := tier["id"].(string); okID && strings.TrimSpace(id) != "" {
|
||||
tierID = strings.TrimSpace(id)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
projectID := trimmedRequest
|
||||
if projectID == "" {
|
||||
if id, okProject := loadResp["cloudaicompanionProject"].(string); okProject {
|
||||
projectID = strings.TrimSpace(id)
|
||||
}
|
||||
if projectID == "" {
|
||||
if projectMap, okProject := loadResp["cloudaicompanionProject"].(map[string]any); okProject {
|
||||
if id, okID := projectMap["id"].(string); okID {
|
||||
projectID = strings.TrimSpace(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if projectID == "" {
|
||||
return &projectSelectionRequiredError{}
|
||||
}
|
||||
|
||||
onboardReqBody := map[string]any{
|
||||
"tierId": tierID,
|
||||
"metadata": metadata,
|
||||
"cloudaicompanionProject": projectID,
|
||||
}
|
||||
|
||||
storage.ProjectID = projectID
|
||||
|
||||
for {
|
||||
var onboardResp map[string]any
|
||||
if errOnboard := callGeminiCLI(ctx, httpClient, "onboardUser", onboardReqBody, &onboardResp); errOnboard != nil {
|
||||
return fmt.Errorf("onboard user: %w", errOnboard)
|
||||
}
|
||||
|
||||
if done, okDone := onboardResp["done"].(bool); okDone && done {
|
||||
responseProjectID := ""
|
||||
if resp, okResp := onboardResp["response"].(map[string]any); okResp {
|
||||
switch projectValue := resp["cloudaicompanionProject"].(type) {
|
||||
case map[string]any:
|
||||
if id, okID := projectValue["id"].(string); okID {
|
||||
responseProjectID = strings.TrimSpace(id)
|
||||
}
|
||||
case string:
|
||||
responseProjectID = strings.TrimSpace(projectValue)
|
||||
}
|
||||
}
|
||||
|
||||
finalProjectID := projectID
|
||||
if responseProjectID != "" {
|
||||
if explicitProject && !strings.EqualFold(responseProjectID, projectID) {
|
||||
log.Warnf("Gemini onboarding returned project %s instead of requested %s; keeping requested project ID.", responseProjectID, projectID)
|
||||
} else {
|
||||
finalProjectID = responseProjectID
|
||||
}
|
||||
}
|
||||
|
||||
storage.ProjectID = strings.TrimSpace(finalProjectID)
|
||||
if storage.ProjectID == "" {
|
||||
storage.ProjectID = strings.TrimSpace(projectID)
|
||||
}
|
||||
if storage.ProjectID == "" {
|
||||
return fmt.Errorf("onboard user completed without project id")
|
||||
}
|
||||
log.Infof("Onboarding complete. Using Project ID: %s", storage.ProjectID)
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Println("Onboarding in progress, waiting 5 seconds...")
|
||||
time.Sleep(5 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func callGeminiCLI(ctx context.Context, httpClient *http.Client, endpoint string, body any, result any) error {
|
||||
endPointURL := fmt.Sprintf("%s/%s:%s", geminiCLIEndpoint, geminiCLIVersion, endpoint)
|
||||
if strings.HasPrefix(endpoint, "operations/") {
|
||||
endPointURL = fmt.Sprintf("%s/%s", geminiCLIEndpoint, endpoint)
|
||||
}
|
||||
|
||||
var reader io.Reader
|
||||
if body != nil {
|
||||
rawBody, errMarshal := json.Marshal(body)
|
||||
if errMarshal != nil {
|
||||
return fmt.Errorf("marshal request body: %w", errMarshal)
|
||||
}
|
||||
reader = bytes.NewReader(rawBody)
|
||||
}
|
||||
|
||||
req, errRequest := http.NewRequestWithContext(ctx, http.MethodPost, endPointURL, reader)
|
||||
if errRequest != nil {
|
||||
return fmt.Errorf("create request: %w", errRequest)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", geminiCLIUserAgent)
|
||||
req.Header.Set("X-Goog-Api-Client", geminiCLIApiClient)
|
||||
req.Header.Set("Client-Metadata", geminiCLIClientMetadata)
|
||||
|
||||
resp, errDo := httpClient.Do(req)
|
||||
if errDo != nil {
|
||||
return fmt.Errorf("execute request: %w", errDo)
|
||||
}
|
||||
defer func() {
|
||||
if errClose := resp.Body.Close(); errClose != nil {
|
||||
log.Errorf("response body close error: %v", errClose)
|
||||
}
|
||||
}()
|
||||
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("api request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(bodyBytes)))
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
return nil
|
||||
}
|
||||
|
||||
if errDecode := json.NewDecoder(resp.Body).Decode(result); errDecode != nil {
|
||||
return fmt.Errorf("decode response body: %w", errDecode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func fetchGCPProjects(ctx context.Context, httpClient *http.Client) ([]interfaces.GCPProjectProjects, error) {
|
||||
req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, "https://cloudresourcemanager.googleapis.com/v1/projects", nil)
|
||||
if errRequest != nil {
|
||||
return nil, fmt.Errorf("could not create project list request: %w", errRequest)
|
||||
}
|
||||
|
||||
resp, errDo := httpClient.Do(req)
|
||||
if errDo != nil {
|
||||
return nil, fmt.Errorf("failed to execute project list request: %w", errDo)
|
||||
}
|
||||
defer func() {
|
||||
if errClose := resp.Body.Close(); errClose != nil {
|
||||
log.Errorf("response body close error: %v", errClose)
|
||||
}
|
||||
}()
|
||||
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("project list request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(bodyBytes)))
|
||||
}
|
||||
|
||||
var projects interfaces.GCPProject
|
||||
if errDecode := json.NewDecoder(resp.Body).Decode(&projects); errDecode != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal project list: %w", errDecode)
|
||||
}
|
||||
|
||||
return projects.Projects, nil
|
||||
}
|
||||
|
||||
func checkCloudAPIIsEnabled(ctx context.Context, httpClient *http.Client, projectID string) (bool, error) {
|
||||
serviceUsageURL := "https://serviceusage.googleapis.com"
|
||||
requiredServices := []string{
|
||||
"cloudaicompanion.googleapis.com",
|
||||
}
|
||||
for _, service := range requiredServices {
|
||||
checkURL := fmt.Sprintf("%s/v1/projects/%s/services/%s", serviceUsageURL, projectID, service)
|
||||
req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, checkURL, nil)
|
||||
if errRequest != nil {
|
||||
return false, fmt.Errorf("failed to create request: %w", errRequest)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", geminiCLIUserAgent)
|
||||
resp, errDo := httpClient.Do(req)
|
||||
if errDo != nil {
|
||||
return false, fmt.Errorf("failed to execute request: %w", errDo)
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
if gjson.GetBytes(bodyBytes, "state").String() == "ENABLED" {
|
||||
_ = resp.Body.Close()
|
||||
continue
|
||||
}
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
|
||||
enableURL := fmt.Sprintf("%s/v1/projects/%s/services/%s:enable", serviceUsageURL, projectID, service)
|
||||
req, errRequest = http.NewRequestWithContext(ctx, http.MethodPost, enableURL, strings.NewReader("{}"))
|
||||
if errRequest != nil {
|
||||
return false, fmt.Errorf("failed to create request: %w", errRequest)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", geminiCLIUserAgent)
|
||||
resp, errDo = httpClient.Do(req)
|
||||
if errDo != nil {
|
||||
return false, fmt.Errorf("failed to execute request: %w", errDo)
|
||||
}
|
||||
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
errMessage := string(bodyBytes)
|
||||
errMessageResult := gjson.GetBytes(bodyBytes, "error.message")
|
||||
if errMessageResult.Exists() {
|
||||
errMessage = errMessageResult.String()
|
||||
}
|
||||
if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusCreated {
|
||||
_ = resp.Body.Close()
|
||||
continue
|
||||
} else if resp.StatusCode == http.StatusBadRequest {
|
||||
_ = resp.Body.Close()
|
||||
if strings.Contains(strings.ToLower(errMessage), "already enabled") {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return false, fmt.Errorf("project activation required: %s", errMessage)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (h *Handler) GetAuthStatus(c *gin.Context) {
|
||||
state := c.Query("state")
|
||||
if err, ok := oauthStatus[state]; ok {
|
||||
|
||||
@@ -227,7 +227,14 @@ func (h *Handler) PutOpenAICompat(c *gin.Context) {
|
||||
for i := range arr {
|
||||
normalizeOpenAICompatibilityEntry(&arr[i])
|
||||
}
|
||||
h.cfg.OpenAICompatibility = arr
|
||||
// Filter out providers with empty base-url -> remove provider entirely
|
||||
filtered := make([]config.OpenAICompatibility, 0, len(arr))
|
||||
for i := range arr {
|
||||
if strings.TrimSpace(arr[i].BaseURL) != "" {
|
||||
filtered = append(filtered, arr[i])
|
||||
}
|
||||
}
|
||||
h.cfg.OpenAICompatibility = filtered
|
||||
h.persist(c)
|
||||
}
|
||||
func (h *Handler) PatchOpenAICompat(c *gin.Context) {
|
||||
@@ -241,6 +248,32 @@ func (h *Handler) PatchOpenAICompat(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
normalizeOpenAICompatibilityEntry(body.Value)
|
||||
// If base-url becomes empty, delete the provider instead of updating
|
||||
if strings.TrimSpace(body.Value.BaseURL) == "" {
|
||||
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.OpenAICompatibility) {
|
||||
h.cfg.OpenAICompatibility = append(h.cfg.OpenAICompatibility[:*body.Index], h.cfg.OpenAICompatibility[*body.Index+1:]...)
|
||||
h.persist(c)
|
||||
return
|
||||
}
|
||||
if body.Name != nil {
|
||||
out := make([]config.OpenAICompatibility, 0, len(h.cfg.OpenAICompatibility))
|
||||
removed := false
|
||||
for i := range h.cfg.OpenAICompatibility {
|
||||
if !removed && h.cfg.OpenAICompatibility[i].Name == *body.Name {
|
||||
removed = true
|
||||
continue
|
||||
}
|
||||
out = append(out, h.cfg.OpenAICompatibility[i])
|
||||
}
|
||||
if removed {
|
||||
h.cfg.OpenAICompatibility = out
|
||||
h.persist(c)
|
||||
return
|
||||
}
|
||||
}
|
||||
c.JSON(404, gin.H{"error": "item not found"})
|
||||
return
|
||||
}
|
||||
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.OpenAICompatibility) {
|
||||
h.cfg.OpenAICompatibility[*body.Index] = *body.Value
|
||||
h.persist(c)
|
||||
@@ -302,7 +335,17 @@ func (h *Handler) PutCodexKeys(c *gin.Context) {
|
||||
}
|
||||
arr = obj.Items
|
||||
}
|
||||
h.cfg.CodexKey = arr
|
||||
// Filter out codex entries with empty base-url (treat as removed)
|
||||
filtered := make([]config.CodexKey, 0, len(arr))
|
||||
for i := range arr {
|
||||
entry := arr[i]
|
||||
entry.BaseURL = strings.TrimSpace(entry.BaseURL)
|
||||
if entry.BaseURL == "" {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, entry)
|
||||
}
|
||||
h.cfg.CodexKey = filtered
|
||||
h.persist(c)
|
||||
}
|
||||
func (h *Handler) PatchCodexKey(c *gin.Context) {
|
||||
@@ -315,19 +358,44 @@ func (h *Handler) PatchCodexKey(c *gin.Context) {
|
||||
c.JSON(400, gin.H{"error": "invalid body"})
|
||||
return
|
||||
}
|
||||
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.CodexKey) {
|
||||
h.cfg.CodexKey[*body.Index] = *body.Value
|
||||
h.persist(c)
|
||||
return
|
||||
}
|
||||
if body.Match != nil {
|
||||
for i := range h.cfg.CodexKey {
|
||||
if h.cfg.CodexKey[i].APIKey == *body.Match {
|
||||
h.cfg.CodexKey[i] = *body.Value
|
||||
// If base-url becomes empty, delete instead of update
|
||||
if strings.TrimSpace(body.Value.BaseURL) == "" {
|
||||
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.CodexKey) {
|
||||
h.cfg.CodexKey = append(h.cfg.CodexKey[:*body.Index], h.cfg.CodexKey[*body.Index+1:]...)
|
||||
h.persist(c)
|
||||
return
|
||||
}
|
||||
if body.Match != nil {
|
||||
out := make([]config.CodexKey, 0, len(h.cfg.CodexKey))
|
||||
removed := false
|
||||
for i := range h.cfg.CodexKey {
|
||||
if !removed && h.cfg.CodexKey[i].APIKey == *body.Match {
|
||||
removed = true
|
||||
continue
|
||||
}
|
||||
out = append(out, h.cfg.CodexKey[i])
|
||||
}
|
||||
if removed {
|
||||
h.cfg.CodexKey = out
|
||||
h.persist(c)
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.CodexKey) {
|
||||
h.cfg.CodexKey[*body.Index] = *body.Value
|
||||
h.persist(c)
|
||||
return
|
||||
}
|
||||
if body.Match != nil {
|
||||
for i := range h.cfg.CodexKey {
|
||||
if h.cfg.CodexKey[i].APIKey == *body.Match {
|
||||
h.cfg.CodexKey[i] = *body.Value
|
||||
h.persist(c)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
c.JSON(404, gin.H{"error": "item not found"})
|
||||
}
|
||||
@@ -359,6 +427,8 @@ func normalizeOpenAICompatibilityEntry(entry *config.OpenAICompatibility) {
|
||||
if entry == nil {
|
||||
return
|
||||
}
|
||||
// Trim base-url; empty base-url indicates provider should be removed by sanitization
|
||||
entry.BaseURL = strings.TrimSpace(entry.BaseURL)
|
||||
existing := make(map[string]struct{}, len(entry.APIKeyEntries))
|
||||
for i := range entry.APIKeyEntries {
|
||||
trimmed := strings.TrimSpace(entry.APIKeyEntries[i].APIKey)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"crypto/subtle"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -25,28 +26,33 @@ type attemptInfo struct {
|
||||
|
||||
// Handler aggregates config reference, persistence path and helpers.
|
||||
type Handler struct {
|
||||
cfg *config.Config
|
||||
configFilePath string
|
||||
mu sync.Mutex
|
||||
|
||||
attemptsMu sync.Mutex
|
||||
failedAttempts map[string]*attemptInfo // keyed by client IP
|
||||
authManager *coreauth.Manager
|
||||
usageStats *usage.RequestStatistics
|
||||
tokenStore coreauth.Store
|
||||
|
||||
localPassword string
|
||||
cfg *config.Config
|
||||
configFilePath string
|
||||
mu sync.Mutex
|
||||
attemptsMu sync.Mutex
|
||||
failedAttempts map[string]*attemptInfo // keyed by client IP
|
||||
authManager *coreauth.Manager
|
||||
usageStats *usage.RequestStatistics
|
||||
tokenStore coreauth.Store
|
||||
localPassword string
|
||||
allowRemoteOverride bool
|
||||
envSecret string
|
||||
}
|
||||
|
||||
// NewHandler creates a new management handler instance.
|
||||
func NewHandler(cfg *config.Config, configFilePath string, manager *coreauth.Manager) *Handler {
|
||||
envSecret, _ := os.LookupEnv("MANAGEMENT_PASSWORD")
|
||||
envSecret = strings.TrimSpace(envSecret)
|
||||
|
||||
return &Handler{
|
||||
cfg: cfg,
|
||||
configFilePath: configFilePath,
|
||||
failedAttempts: make(map[string]*attemptInfo),
|
||||
authManager: manager,
|
||||
usageStats: usage.GetRequestStatistics(),
|
||||
tokenStore: sdkAuth.GetTokenStore(),
|
||||
cfg: cfg,
|
||||
configFilePath: configFilePath,
|
||||
failedAttempts: make(map[string]*attemptInfo),
|
||||
authManager: manager,
|
||||
usageStats: usage.GetRequestStatistics(),
|
||||
tokenStore: sdkAuth.GetTokenStore(),
|
||||
allowRemoteOverride: envSecret != "",
|
||||
envSecret: envSecret,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +78,19 @@ func (h *Handler) Middleware() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
clientIP := c.ClientIP()
|
||||
localClient := clientIP == "127.0.0.1" || clientIP == "::1"
|
||||
cfg := h.cfg
|
||||
var (
|
||||
allowRemote bool
|
||||
secretHash string
|
||||
)
|
||||
if cfg != nil {
|
||||
allowRemote = cfg.RemoteManagement.AllowRemote
|
||||
secretHash = cfg.RemoteManagement.SecretKey
|
||||
}
|
||||
if h.allowRemoteOverride {
|
||||
allowRemote = true
|
||||
}
|
||||
envSecret := h.envSecret
|
||||
|
||||
fail := func() {}
|
||||
if !localClient {
|
||||
@@ -92,7 +111,7 @@ func (h *Handler) Middleware() gin.HandlerFunc {
|
||||
}
|
||||
h.attemptsMu.Unlock()
|
||||
|
||||
if !h.cfg.RemoteManagement.AllowRemote {
|
||||
if !allowRemote {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "remote management disabled"})
|
||||
return
|
||||
}
|
||||
@@ -112,8 +131,7 @@ func (h *Handler) Middleware() gin.HandlerFunc {
|
||||
h.attemptsMu.Unlock()
|
||||
}
|
||||
}
|
||||
secret := h.cfg.RemoteManagement.SecretKey
|
||||
if secret == "" {
|
||||
if secretHash == "" && envSecret == "" {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "remote management key not set"})
|
||||
return
|
||||
}
|
||||
@@ -149,7 +167,20 @@ func (h *Handler) Middleware() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(secret), []byte(provided)); err != nil {
|
||||
if envSecret != "" && subtle.ConstantTimeCompare([]byte(provided), []byte(envSecret)) == 1 {
|
||||
if !localClient {
|
||||
h.attemptsMu.Lock()
|
||||
if ai := h.failedAttempts[clientIP]; ai != nil {
|
||||
ai.count = 0
|
||||
ai.blockedUntil = time.Time{}
|
||||
}
|
||||
h.attemptsMu.Unlock()
|
||||
}
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
if secretHash == "" || bcrypt.CompareHashAndPassword([]byte(secretHash), []byte(provided)) != nil {
|
||||
if !localClient {
|
||||
fail()
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ package middleware
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
|
||||
@@ -17,6 +18,12 @@ import (
|
||||
// logger, the middleware has minimal overhead.
|
||||
func RequestLoggingMiddleware(logger logging.RequestLogger) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
if strings.HasPrefix(path, "/v0/management") || path == "/keep-alive" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// Early return if logging is disabled (zero overhead)
|
||||
if !logger.IsEnabled() {
|
||||
c.Next()
|
||||
|
||||
@@ -34,6 +34,8 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const oauthCallbackSuccessHTML = `<html><head><meta charset="utf-8"><title>Authentication successful</title><script>setTimeout(function(){window.close();},5000);</script></head><body><h1>Authentication successful!</h1><p>You can close this window.</p><p>This window will close automatically in 5 seconds.</p></body></html>`
|
||||
|
||||
type serverOptionConfig struct {
|
||||
extraMiddleware []gin.HandlerFunc
|
||||
engineConfigurator func(*gin.Engine)
|
||||
@@ -124,6 +126,9 @@ type Server struct {
|
||||
// configFilePath is the absolute path to the YAML config file for persistence.
|
||||
configFilePath string
|
||||
|
||||
// currentPath is the absolute path to the current working directory.
|
||||
currentPath string
|
||||
|
||||
// management handler
|
||||
mgmt *managementHandlers.Handler
|
||||
|
||||
@@ -132,6 +137,9 @@ type Server struct {
|
||||
// managementRoutesEnabled controls whether management endpoints serve real handlers.
|
||||
managementRoutesEnabled atomic.Bool
|
||||
|
||||
// envManagementSecret indicates whether MANAGEMENT_PASSWORD is configured.
|
||||
envManagementSecret bool
|
||||
|
||||
localPassword string
|
||||
|
||||
keepAliveEnabled bool
|
||||
@@ -191,16 +199,26 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
|
||||
}
|
||||
|
||||
engine.Use(corsMiddleware())
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
wd = configFilePath
|
||||
}
|
||||
|
||||
envAdminPassword, envAdminPasswordSet := os.LookupEnv("MANAGEMENT_PASSWORD")
|
||||
envAdminPassword = strings.TrimSpace(envAdminPassword)
|
||||
envManagementSecret := envAdminPasswordSet && envAdminPassword != ""
|
||||
|
||||
// Create server instance
|
||||
s := &Server{
|
||||
engine: engine,
|
||||
handlers: handlers.NewBaseAPIHandlers(&cfg.SDKConfig, authManager),
|
||||
cfg: cfg,
|
||||
accessManager: accessManager,
|
||||
requestLogger: requestLogger,
|
||||
loggerToggle: toggle,
|
||||
configFilePath: configFilePath,
|
||||
engine: engine,
|
||||
handlers: handlers.NewBaseAPIHandlers(&cfg.SDKConfig, authManager),
|
||||
cfg: cfg,
|
||||
accessManager: accessManager,
|
||||
requestLogger: requestLogger,
|
||||
loggerToggle: toggle,
|
||||
configFilePath: configFilePath,
|
||||
currentPath: wd,
|
||||
envManagementSecret: envManagementSecret,
|
||||
}
|
||||
s.applyAccessConfig(nil, cfg)
|
||||
// Initialize management handler
|
||||
@@ -216,9 +234,10 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
|
||||
optionState.routerConfigurator(engine, s.handlers, cfg)
|
||||
}
|
||||
|
||||
// Register management routes only when a secret is present at startup.
|
||||
s.managementRoutesEnabled.Store(cfg.RemoteManagement.SecretKey != "")
|
||||
if cfg.RemoteManagement.SecretKey != "" {
|
||||
// Register management routes when configuration or environment secrets are available.
|
||||
hasManagementSecret := cfg.RemoteManagement.SecretKey != "" || envManagementSecret
|
||||
s.managementRoutesEnabled.Store(hasManagementSecret)
|
||||
if hasManagementSecret {
|
||||
s.registerManagementRoutes()
|
||||
}
|
||||
|
||||
@@ -270,7 +289,6 @@ func (s *Server) setupRoutes() {
|
||||
s.engine.GET("/", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "CLI Proxy API Server",
|
||||
"version": "1.0.0",
|
||||
"endpoints": []string{
|
||||
"POST /v1/chat/completions",
|
||||
"POST /v1/completions",
|
||||
@@ -293,7 +311,7 @@ func (s *Server) setupRoutes() {
|
||||
_ = os.WriteFile(file, []byte(fmt.Sprintf(`{"code":"%s","state":"%s","error":"%s"}`, code, state, errStr)), 0o600)
|
||||
}
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
c.String(http.StatusOK, "<html><body><h1>Authentication successful!</h1><p>You can close this window.</p></body></html>")
|
||||
c.String(http.StatusOK, oauthCallbackSuccessHTML)
|
||||
})
|
||||
|
||||
s.engine.GET("/codex/callback", func(c *gin.Context) {
|
||||
@@ -305,7 +323,7 @@ func (s *Server) setupRoutes() {
|
||||
_ = os.WriteFile(file, []byte(fmt.Sprintf(`{"code":"%s","state":"%s","error":"%s"}`, code, state, errStr)), 0o600)
|
||||
}
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
c.String(http.StatusOK, "<html><body><h1>Authentication successful!</h1><p>You can close this window.</p></body></html>")
|
||||
c.String(http.StatusOK, oauthCallbackSuccessHTML)
|
||||
})
|
||||
|
||||
s.engine.GET("/google/callback", func(c *gin.Context) {
|
||||
@@ -317,7 +335,7 @@ func (s *Server) setupRoutes() {
|
||||
_ = os.WriteFile(file, []byte(fmt.Sprintf(`{"code":"%s","state":"%s","error":"%s"}`, code, state, errStr)), 0o600)
|
||||
}
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
c.String(http.StatusOK, "<html><body><h1>Authentication successful!</h1><p>You can close this window.</p></body></html>")
|
||||
c.String(http.StatusOK, oauthCallbackSuccessHTML)
|
||||
})
|
||||
|
||||
s.engine.GET("/iflow/callback", func(c *gin.Context) {
|
||||
@@ -329,7 +347,7 @@ func (s *Server) setupRoutes() {
|
||||
_ = os.WriteFile(file, []byte(fmt.Sprintf(`{"code":"%s","state":"%s","error":"%s"}`, code, state, errStr)), 0o600)
|
||||
}
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
c.String(http.StatusOK, "<html><body><h1>Authentication successful!</h1><p>You can close this window.</p></body></html>")
|
||||
c.String(http.StatusOK, oauthCallbackSuccessHTML)
|
||||
})
|
||||
|
||||
// Management routes are registered lazily by registerManagementRoutes when a secret is configured.
|
||||
@@ -417,7 +435,6 @@ func (s *Server) registerManagementRoutes() {
|
||||
mgmt.GET("/anthropic-auth-url", s.mgmt.RequestAnthropicToken)
|
||||
mgmt.GET("/codex-auth-url", s.mgmt.RequestCodexToken)
|
||||
mgmt.GET("/gemini-cli-auth-url", s.mgmt.RequestGeminiCLIToken)
|
||||
mgmt.POST("/gemini-web-token", s.mgmt.CreateGeminiWebToken)
|
||||
mgmt.GET("/qwen-auth-url", s.mgmt.RequestQwenToken)
|
||||
mgmt.GET("/iflow-auth-url", s.mgmt.RequestIFlowToken)
|
||||
mgmt.GET("/get-auth-status", s.mgmt.GetAuthStatus)
|
||||
@@ -440,8 +457,7 @@ func (s *Server) serveManagementControlPanel(c *gin.Context) {
|
||||
c.AbortWithStatus(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
filePath := managementasset.FilePath(s.configFilePath)
|
||||
filePath := managementasset.FilePath(s.currentPath)
|
||||
if strings.TrimSpace(filePath) == "" {
|
||||
c.AbortWithStatus(http.StatusNotFound)
|
||||
return
|
||||
@@ -449,7 +465,7 @@ func (s *Server) serveManagementControlPanel(c *gin.Context) {
|
||||
|
||||
if _, err := os.Stat(filePath); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
go managementasset.EnsureLatestManagementHTML(context.Background(), managementasset.StaticDir(s.configFilePath), cfg.ProxyURL)
|
||||
go managementasset.EnsureLatestManagementHTML(context.Background(), managementasset.StaticDir(s.currentPath), cfg.ProxyURL)
|
||||
c.AbortWithStatus(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
@@ -690,22 +706,31 @@ func (s *Server) UpdateClients(cfg *config.Config) {
|
||||
prevSecretEmpty = oldCfg.RemoteManagement.SecretKey == ""
|
||||
}
|
||||
newSecretEmpty := cfg.RemoteManagement.SecretKey == ""
|
||||
switch {
|
||||
case prevSecretEmpty && !newSecretEmpty:
|
||||
if s.envManagementSecret {
|
||||
s.registerManagementRoutes()
|
||||
if s.managementRoutesEnabled.CompareAndSwap(false, true) {
|
||||
log.Info("management routes enabled after secret key update")
|
||||
log.Info("management routes enabled via MANAGEMENT_PASSWORD")
|
||||
} else {
|
||||
s.managementRoutesEnabled.Store(true)
|
||||
}
|
||||
case !prevSecretEmpty && newSecretEmpty:
|
||||
if s.managementRoutesEnabled.CompareAndSwap(true, false) {
|
||||
log.Info("management routes disabled after secret key removal")
|
||||
} else {
|
||||
s.managementRoutesEnabled.Store(false)
|
||||
} else {
|
||||
switch {
|
||||
case prevSecretEmpty && !newSecretEmpty:
|
||||
s.registerManagementRoutes()
|
||||
if s.managementRoutesEnabled.CompareAndSwap(false, true) {
|
||||
log.Info("management routes enabled after secret key update")
|
||||
} else {
|
||||
s.managementRoutesEnabled.Store(true)
|
||||
}
|
||||
case !prevSecretEmpty && newSecretEmpty:
|
||||
if s.managementRoutesEnabled.CompareAndSwap(true, false) {
|
||||
log.Info("management routes disabled after secret key removal")
|
||||
} else {
|
||||
s.managementRoutesEnabled.Store(false)
|
||||
}
|
||||
default:
|
||||
s.managementRoutesEnabled.Store(!newSecretEmpty)
|
||||
}
|
||||
default:
|
||||
s.managementRoutesEnabled.Store(!newSecretEmpty)
|
||||
}
|
||||
|
||||
s.applyAccessConfig(oldCfg, cfg)
|
||||
@@ -713,7 +738,7 @@ func (s *Server) UpdateClients(cfg *config.Config) {
|
||||
s.handlers.UpdateClients(&cfg.SDKConfig)
|
||||
|
||||
if !cfg.RemoteManagement.DisableControlPanel {
|
||||
staticDir := managementasset.StaticDir(s.configFilePath)
|
||||
staticDir := managementasset.StaticDir(s.currentPath)
|
||||
go managementasset.EnsureLatestManagementHTML(context.Background(), staticDir, cfg.ProxyURL)
|
||||
}
|
||||
if s.mgmt != nil {
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
// Package gemini provides authentication and token management functionality
|
||||
// for Google's Gemini AI services. It handles OAuth2 token storage, serialization,
|
||||
// and retrieval for maintaining authenticated sessions with the Gemini API.
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// GeminiWebTokenStorage stores cookie information for Google Gemini Web authentication.
|
||||
type GeminiWebTokenStorage struct {
|
||||
Secure1PSID string `json:"secure_1psid"`
|
||||
Secure1PSIDTS string `json:"secure_1psidts"`
|
||||
Type string `json:"type"`
|
||||
LastRefresh string `json:"last_refresh,omitempty"`
|
||||
// Label is a stable account identifier used for logging, e.g. "gemini-web-<hash>".
|
||||
// It is derived from the auth file name when not explicitly set.
|
||||
Label string `json:"label,omitempty"`
|
||||
}
|
||||
|
||||
// SaveTokenToFile serializes the Gemini Web token storage to a JSON file.
|
||||
func (ts *GeminiWebTokenStorage) SaveTokenToFile(authFilePath string) error {
|
||||
misc.LogSavingCredentials(authFilePath)
|
||||
ts.Type = "gemini-web"
|
||||
// Auto-derive a stable label from the file name if missing.
|
||||
if ts.Label == "" {
|
||||
base := filepath.Base(authFilePath)
|
||||
if strings.HasSuffix(strings.ToLower(base), ".json") {
|
||||
base = strings.TrimSuffix(base, filepath.Ext(base))
|
||||
}
|
||||
if base != "" {
|
||||
ts.Label = base
|
||||
}
|
||||
}
|
||||
if ts.LastRefresh == "" {
|
||||
ts.LastRefresh = time.Now().Format(time.RFC3339)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(authFilePath), 0700); err != nil {
|
||||
return fmt.Errorf("failed to create directory: %v", err)
|
||||
}
|
||||
|
||||
f, err := os.Create(authFilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create token file: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if errClose := f.Close(); errClose != nil {
|
||||
log.Errorf("failed to close file: %v", errClose)
|
||||
}
|
||||
}()
|
||||
|
||||
if err = json.NewEncoder(f).Encode(ts); err != nil {
|
||||
return fmt.Errorf("failed to write token to file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -138,6 +138,7 @@ func (ia *IFlowAuth) doTokenRequest(ctx context.Context, req *http.Request) (*IF
|
||||
}
|
||||
|
||||
if tokenResp.AccessToken == "" {
|
||||
log.Debug(string(body))
|
||||
return nil, fmt.Errorf("iflow token: missing access token in response")
|
||||
}
|
||||
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
// Package cmd provides command-line interface functionality for the CLI Proxy API.
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"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"
|
||||
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.
|
||||
// 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"])
|
||||
|
||||
// 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 == "" {
|
||||
if cookieProvided && !isMacOS {
|
||||
fmt.Println("!! Cookie missing __Secure-1PSID.")
|
||||
}
|
||||
fmt.Print("Enter __Secure-1PSID: ")
|
||||
v, _ := reader.ReadString('\n')
|
||||
secure1psid = strings.TrimSpace(v)
|
||||
}
|
||||
if secure1psidts == "" {
|
||||
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
|
||||
}
|
||||
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
|
||||
hasher := sha256.New()
|
||||
hasher.Write([]byte(secure1psid))
|
||||
hash := hex.EncodeToString(hasher.Sum(nil))
|
||||
fileName := fmt.Sprintf("gemini-web-%s.json", hash[:16])
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
tokenStorage := &gemini.GeminiWebTokenStorage{
|
||||
Secure1PSID: secure1psid,
|
||||
Secure1PSIDTS: secure1psidts,
|
||||
Label: label,
|
||||
}
|
||||
record := &coreauth.Auth{
|
||||
ID: fileName,
|
||||
Provider: "gemini-web",
|
||||
FileName: fileName,
|
||||
Storage: tokenStorage,
|
||||
}
|
||||
store := sdkAuth.GetTokenStore()
|
||||
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.Println("!! Failed to save Gemini Web token to file:", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("==> Successfully saved Gemini Web token!")
|
||||
fmt.Println("==> Saved to:", savedPath)
|
||||
}
|
||||
@@ -53,3 +53,17 @@ func StartService(cfg *config.Config, configPath string, localPassword string) {
|
||||
log.Fatalf("proxy service exited with error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// WaitForCloudDeploy waits indefinitely for shutdown signals in cloud deploy mode
|
||||
// when no configuration file is available.
|
||||
func WaitForCloudDeploy() {
|
||||
// Clarify that we are intentionally idle for configuration and not running the API server.
|
||||
log.Info("Cloud deploy mode: No config found; standing by for configuration. API server is not started. Press Ctrl+C to exit.")
|
||||
|
||||
ctxSignal, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
// Block until shutdown signal is received
|
||||
<-ctxSignal.Done()
|
||||
log.Info("Cloud deploy mode: Shutdown signal received; exiting")
|
||||
}
|
||||
|
||||
@@ -5,8 +5,11 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
@@ -51,33 +54,6 @@ type Config struct {
|
||||
|
||||
// RemoteManagement nests management-related options under 'remote-management'.
|
||||
RemoteManagement RemoteManagement `yaml:"remote-management" json:"-"`
|
||||
|
||||
// GeminiWeb groups configuration for Gemini Web client
|
||||
GeminiWeb GeminiWebConfig `yaml:"gemini-web" json:"gemini-web"`
|
||||
}
|
||||
|
||||
// GeminiWebConfig nests Gemini Web related options under 'gemini-web'.
|
||||
type GeminiWebConfig struct {
|
||||
// Context enables JSON-based conversation reuse.
|
||||
// Defaults to true if not set in YAML (see LoadConfig).
|
||||
Context bool `yaml:"context" json:"context"`
|
||||
|
||||
// CodeMode, when true, enables coding mode behaviors for Gemini Web:
|
||||
// - Attach the predefined "Coding partner" Gem
|
||||
// - Enable XML wrapping hint for tool markup
|
||||
// - Merge <think> content into visible content for tool-friendly output
|
||||
CodeMode bool `yaml:"code-mode" json:"code-mode"`
|
||||
|
||||
// MaxCharsPerRequest caps the number of characters (runes) sent to
|
||||
// Gemini Web in a single request. Long prompts will be split into
|
||||
// multiple requests with a continuation hint, and only the final
|
||||
// request will carry any files. When unset or <=0, a conservative
|
||||
// default of 1,000,000 will be used.
|
||||
MaxCharsPerRequest int `yaml:"max-chars-per-request" json:"max-chars-per-request"`
|
||||
|
||||
// DisableContinuationHint, when true, disables the continuation hint for split prompts.
|
||||
// The hint is enabled by default.
|
||||
DisableContinuationHint bool `yaml:"disable-continuation-hint,omitempty" json:"disable-continuation-hint,omitempty"`
|
||||
}
|
||||
|
||||
// RemoteManagement holds management API configuration under 'remote-management'.
|
||||
@@ -178,19 +154,40 @@ type OpenAICompatibilityModel struct {
|
||||
// - *Config: The loaded configuration
|
||||
// - error: An error if the configuration could not be loaded
|
||||
func LoadConfig(configFile string) (*Config, error) {
|
||||
return LoadConfigOptional(configFile, false)
|
||||
}
|
||||
|
||||
// LoadConfigOptional reads YAML from configFile.
|
||||
// If optional is true and the file is missing, it returns an empty Config.
|
||||
// If optional is true and the file is empty or invalid, it returns an empty Config.
|
||||
func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
|
||||
// Read the entire configuration file into memory.
|
||||
data, err := os.ReadFile(configFile)
|
||||
if err != nil {
|
||||
if optional {
|
||||
if os.IsNotExist(err) || errors.Is(err, syscall.EISDIR) {
|
||||
// Missing and optional: return empty config (cloud deploy standby).
|
||||
return &Config{}, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read config file: %w", err)
|
||||
}
|
||||
|
||||
// In cloud deploy mode (optional=true), if file is empty or contains only whitespace, return empty config.
|
||||
if optional && len(data) == 0 {
|
||||
return &Config{}, nil
|
||||
}
|
||||
|
||||
// Unmarshal the YAML data into the Config struct.
|
||||
var cfg Config
|
||||
// Set defaults before unmarshal so that absent keys keep defaults.
|
||||
cfg.LoggingToFile = true
|
||||
cfg.UsageStatisticsEnabled = true
|
||||
cfg.GeminiWeb.Context = true
|
||||
cfg.LoggingToFile = false
|
||||
cfg.UsageStatisticsEnabled = false
|
||||
if err = yaml.Unmarshal(data, &cfg); err != nil {
|
||||
if optional {
|
||||
// In cloud deploy mode, if YAML parsing fails, return empty config instead of error.
|
||||
return &Config{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to parse config file: %w", err)
|
||||
}
|
||||
|
||||
@@ -211,10 +208,55 @@ func LoadConfig(configFile string) (*Config, error) {
|
||||
// Sync request authentication providers with inline API keys for backwards compatibility.
|
||||
syncInlineAccessProvider(&cfg)
|
||||
|
||||
// Sanitize OpenAI compatibility providers: drop entries without base-url
|
||||
sanitizeOpenAICompatibility(&cfg)
|
||||
|
||||
// Sanitize Codex keys: drop entries without base-url
|
||||
sanitizeCodexKeys(&cfg)
|
||||
|
||||
// Return the populated configuration struct.
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// sanitizeOpenAICompatibility removes OpenAI-compatibility provider entries that are
|
||||
// not actionable, specifically those missing a BaseURL. It trims whitespace before
|
||||
// evaluation and preserves the relative order of remaining entries.
|
||||
func sanitizeOpenAICompatibility(cfg *Config) {
|
||||
if cfg == nil || len(cfg.OpenAICompatibility) == 0 {
|
||||
return
|
||||
}
|
||||
out := make([]OpenAICompatibility, 0, len(cfg.OpenAICompatibility))
|
||||
for i := range cfg.OpenAICompatibility {
|
||||
e := cfg.OpenAICompatibility[i]
|
||||
e.Name = strings.TrimSpace(e.Name)
|
||||
e.BaseURL = strings.TrimSpace(e.BaseURL)
|
||||
if e.BaseURL == "" {
|
||||
// Skip providers with no base-url; treated as removed
|
||||
continue
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
cfg.OpenAICompatibility = out
|
||||
}
|
||||
|
||||
// sanitizeCodexKeys removes Codex API key entries missing a BaseURL.
|
||||
// It trims whitespace and preserves order for remaining entries.
|
||||
func sanitizeCodexKeys(cfg *Config) {
|
||||
if cfg == nil || len(cfg.CodexKey) == 0 {
|
||||
return
|
||||
}
|
||||
out := make([]CodexKey, 0, len(cfg.CodexKey))
|
||||
for i := range cfg.CodexKey {
|
||||
e := cfg.CodexKey[i]
|
||||
e.BaseURL = strings.TrimSpace(e.BaseURL)
|
||||
if e.BaseURL == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
cfg.CodexKey = out
|
||||
}
|
||||
|
||||
func syncInlineAccessProvider(cfg *Config) {
|
||||
if cfg == nil {
|
||||
return
|
||||
@@ -284,6 +326,7 @@ func SaveConfigPreserveComments(configFile string, cfg *Config) error {
|
||||
|
||||
// Merge generated into original in-place, preserving comments/order of existing nodes.
|
||||
mergeMappingPreserve(original.Content[0], generated.Content[0])
|
||||
normalizeCollectionNodeStyles(original.Content[0])
|
||||
|
||||
// Write back.
|
||||
f, err := os.Create(configFile)
|
||||
@@ -524,3 +567,30 @@ func removeMapKey(mapNode *yaml.Node, key string) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeCollectionNodeStyles forces YAML collections to use block notation, keeping
|
||||
// lists and maps readable. Empty sequences retain flow style ([]) so empty list markers
|
||||
// remain compact.
|
||||
func normalizeCollectionNodeStyles(node *yaml.Node) {
|
||||
if node == nil {
|
||||
return
|
||||
}
|
||||
switch node.Kind {
|
||||
case yaml.MappingNode:
|
||||
node.Style = 0
|
||||
for i := range node.Content {
|
||||
normalizeCollectionNodeStyles(node.Content[i])
|
||||
}
|
||||
case yaml.SequenceNode:
|
||||
if len(node.Content) == 0 {
|
||||
node.Style = yaml.FlowStyle
|
||||
} else {
|
||||
node.Style = 0
|
||||
}
|
||||
for i := range node.Content {
|
||||
normalizeCollectionNodeStyles(node.Content[i])
|
||||
}
|
||||
default:
|
||||
// Scalars keep their existing style to preserve quoting
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,6 @@ const (
|
||||
// GeminiCLI represents the Google Gemini CLI provider identifier.
|
||||
GeminiCLI = "gemini-cli"
|
||||
|
||||
// GeminiWeb represents the Google Gemini Web provider identifier.
|
||||
GeminiWeb = "gemini-web"
|
||||
|
||||
// Codex represents the OpenAI Codex provider identifier.
|
||||
Codex = "codex"
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||
@@ -23,11 +24,17 @@ const (
|
||||
managementReleaseURL = "https://api.github.com/repos/router-for-me/Cli-Proxy-API-Management-Center/releases/latest"
|
||||
managementAssetName = "management.html"
|
||||
httpUserAgent = "CLIProxyAPI-management-updater"
|
||||
updateCheckInterval = 3 * time.Hour
|
||||
)
|
||||
|
||||
// ManagementFileName exposes the control panel asset filename.
|
||||
const ManagementFileName = managementAssetName
|
||||
|
||||
var (
|
||||
lastUpdateCheckMu sync.Mutex
|
||||
lastUpdateCheckTime time.Time
|
||||
)
|
||||
|
||||
func newHTTPClient(proxyURL string) *http.Client {
|
||||
client := &http.Client{Timeout: 15 * time.Second}
|
||||
|
||||
@@ -53,7 +60,15 @@ func StaticDir(configFilePath string) string {
|
||||
if configFilePath == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
base := filepath.Dir(configFilePath)
|
||||
fileInfo, err := os.Stat(configFilePath)
|
||||
if err == nil {
|
||||
if fileInfo.IsDir() {
|
||||
base = configFilePath
|
||||
}
|
||||
}
|
||||
|
||||
return filepath.Join(base, "static")
|
||||
}
|
||||
|
||||
@@ -68,6 +83,7 @@ func FilePath(configFilePath string) string {
|
||||
|
||||
// EnsureLatestManagementHTML checks the latest management.html asset and updates the local copy when needed.
|
||||
// The function is designed to run in a background goroutine and will never panic.
|
||||
// It enforces a 3-hour rate limit to avoid frequent checks on config/auth file changes.
|
||||
func EnsureLatestManagementHTML(ctx context.Context, staticDir string, proxyURL string) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
@@ -79,6 +95,18 @@ func EnsureLatestManagementHTML(ctx context.Context, staticDir string, proxyURL
|
||||
return
|
||||
}
|
||||
|
||||
// Rate limiting: check only once every 3 hours
|
||||
lastUpdateCheckMu.Lock()
|
||||
now := time.Now()
|
||||
timeSinceLastCheck := now.Sub(lastUpdateCheckTime)
|
||||
if timeSinceLastCheck < updateCheckInterval {
|
||||
lastUpdateCheckMu.Unlock()
|
||||
log.Debugf("management asset update check skipped: last check was %v ago (interval: %v)", timeSinceLastCheck.Round(time.Second), updateCheckInterval)
|
||||
return
|
||||
}
|
||||
lastUpdateCheckTime = now
|
||||
lastUpdateCheckMu.Unlock()
|
||||
|
||||
if err := os.MkdirAll(staticDir, 0o755); err != nil {
|
||||
log.WithError(err).Warn("failed to prepare static directory for management asset")
|
||||
return
|
||||
@@ -131,6 +159,10 @@ func fetchLatestAsset(ctx context.Context, client *http.Client) (*releaseAsset,
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github+json")
|
||||
req.Header.Set("User-Agent", httpUserAgent)
|
||||
gitURL := strings.ToLower(strings.TrimSpace(os.Getenv("GITSTORE_GIT_URL")))
|
||||
if tok := strings.TrimSpace(os.Getenv("GITSTORE_GIT_TOKEN")); tok != "" && strings.Contains(gitURL, "github.com") {
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
|
||||
40
internal/misc/copy-example-config.go
Normal file
40
internal/misc/copy-example-config.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package misc
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func CopyConfigTemplate(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if errClose := in.Close(); errClose != nil {
|
||||
log.WithError(errClose).Warn("failed to close source config file")
|
||||
}
|
||||
}()
|
||||
|
||||
if err = os.MkdirAll(filepath.Dir(dst), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if errClose := out.Close(); errClose != nil {
|
||||
log.WithError(errClose).Warn("failed to close destination config file")
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err = io.Copy(out, in); err != nil {
|
||||
return err
|
||||
}
|
||||
return out.Sync()
|
||||
}
|
||||
@@ -1,882 +0,0 @@
|
||||
package geminiwebapi
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// GeminiClient is the async http client interface (Go port)
|
||||
type GeminiClient struct {
|
||||
Cookies map[string]string
|
||||
Proxy string
|
||||
Running bool
|
||||
httpClient *http.Client
|
||||
AccessToken string
|
||||
Timeout time.Duration
|
||||
insecure bool
|
||||
}
|
||||
|
||||
// HTTP bootstrap utilities -------------------------------------------------
|
||||
type httpOptions struct {
|
||||
ProxyURL string
|
||||
Insecure bool
|
||||
FollowRedirects bool
|
||||
}
|
||||
|
||||
func newHTTPClient(opts httpOptions) *http.Client {
|
||||
transport := &http.Transport{}
|
||||
if opts.ProxyURL != "" {
|
||||
if pu, err := url.Parse(opts.ProxyURL); err == nil {
|
||||
transport.Proxy = http.ProxyURL(pu)
|
||||
}
|
||||
}
|
||||
if opts.Insecure {
|
||||
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
|
||||
}
|
||||
jar, _ := cookiejar.New(nil)
|
||||
client := &http.Client{Transport: transport, Timeout: 60 * time.Second, Jar: jar}
|
||||
if !opts.FollowRedirects {
|
||||
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
}
|
||||
return client
|
||||
}
|
||||
|
||||
func applyHeaders(req *http.Request, headers http.Header) {
|
||||
for k, v := range headers {
|
||||
for _, vv := range v {
|
||||
req.Header.Add(k, vv)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applyCookies(req *http.Request, cookies map[string]string) {
|
||||
for k, v := range cookies {
|
||||
req.AddCookie(&http.Cookie{Name: k, Value: v})
|
||||
}
|
||||
}
|
||||
|
||||
func sendInitRequest(cookies map[string]string, proxy string, insecure bool) (*http.Response, map[string]string, error) {
|
||||
client := newHTTPClient(httpOptions{ProxyURL: proxy, Insecure: insecure, FollowRedirects: true})
|
||||
req, _ := http.NewRequest(http.MethodGet, EndpointInit, nil)
|
||||
applyHeaders(req, HeadersGemini)
|
||||
applyCookies(req, cookies)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return resp, nil, &AuthError{Msg: resp.Status}
|
||||
}
|
||||
outCookies := map[string]string{}
|
||||
for _, c := range resp.Cookies() {
|
||||
outCookies[c.Name] = c.Value
|
||||
}
|
||||
for k, v := range cookies {
|
||||
outCookies[k] = v
|
||||
}
|
||||
return resp, outCookies, nil
|
||||
}
|
||||
|
||||
func getAccessToken(baseCookies map[string]string, proxy string, verbose bool, insecure bool) (string, map[string]string, error) {
|
||||
extraCookies := map[string]string{}
|
||||
{
|
||||
client := newHTTPClient(httpOptions{ProxyURL: proxy, Insecure: insecure, FollowRedirects: true})
|
||||
req, _ := http.NewRequest(http.MethodGet, EndpointGoogle, nil)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
if verbose {
|
||||
log.Debugf("priming google cookies failed: %v", err)
|
||||
}
|
||||
} else if resp != nil {
|
||||
if u, err := url.Parse(EndpointGoogle); err == nil {
|
||||
for _, c := range client.Jar.Cookies(u) {
|
||||
extraCookies[c.Name] = c.Value
|
||||
}
|
||||
}
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
trySets := make([]map[string]string, 0, 8)
|
||||
|
||||
if v1, ok1 := baseCookies["__Secure-1PSID"]; ok1 {
|
||||
if v2, ok2 := baseCookies["__Secure-1PSIDTS"]; ok2 {
|
||||
merged := map[string]string{"__Secure-1PSID": v1, "__Secure-1PSIDTS": v2}
|
||||
if nid, ok := baseCookies["NID"]; ok {
|
||||
merged["NID"] = nid
|
||||
}
|
||||
trySets = append(trySets, merged)
|
||||
} else if verbose {
|
||||
log.Debug("Skipping base cookies: __Secure-1PSIDTS missing")
|
||||
}
|
||||
}
|
||||
|
||||
if len(extraCookies) > 0 {
|
||||
trySets = append(trySets, extraCookies)
|
||||
}
|
||||
|
||||
reToken := regexp.MustCompile(`"SNlM0e":"([^"]+)"`)
|
||||
|
||||
for _, cookies := range trySets {
|
||||
resp, mergedCookies, err := sendInitRequest(cookies, proxy, insecure)
|
||||
if err != nil {
|
||||
if verbose {
|
||||
log.Warnf("Failed init request: %v", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
matches := reToken.FindStringSubmatch(string(body))
|
||||
if len(matches) >= 2 {
|
||||
token := matches[1]
|
||||
if verbose {
|
||||
fmt.Println("Gemini access token acquired.")
|
||||
}
|
||||
return token, mergedCookies, nil
|
||||
}
|
||||
}
|
||||
return "", nil, &AuthError{Msg: "Failed to retrieve token."}
|
||||
}
|
||||
|
||||
func rotate1PSIDTS(cookies map[string]string, proxy string, insecure bool) (string, error) {
|
||||
_, ok := cookies["__Secure-1PSID"]
|
||||
if !ok {
|
||||
return "", &AuthError{Msg: "__Secure-1PSID missing"}
|
||||
}
|
||||
|
||||
// Reuse shared HTTP client helper for consistency.
|
||||
client := newHTTPClient(httpOptions{ProxyURL: proxy, Insecure: insecure, FollowRedirects: true})
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPost, EndpointRotateCookies, strings.NewReader("[000,\"-0000000000000000000\"]"))
|
||||
applyHeaders(req, HeadersRotateCookies)
|
||||
applyCookies(req, cookies)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
return "", &AuthError{Msg: "unauthorized"}
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", errors.New(resp.Status)
|
||||
}
|
||||
|
||||
for _, c := range resp.Cookies() {
|
||||
if c.Name == "__Secure-1PSIDTS" {
|
||||
return c.Value, nil
|
||||
}
|
||||
}
|
||||
// Fallback: check cookie jar in case the Set-Cookie was on a redirect hop
|
||||
if u, err := url.Parse(EndpointRotateCookies); err == nil && client.Jar != nil {
|
||||
for _, c := range client.Jar.Cookies(u) {
|
||||
if c.Name == "__Secure-1PSIDTS" && c.Value != "" {
|
||||
return c.Value, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// MaskToken28 masks a sensitive token for safe logging. Keep middle partially visible.
|
||||
func MaskToken28(s string) string {
|
||||
n := len(s)
|
||||
if n == 0 {
|
||||
return ""
|
||||
}
|
||||
if n < 20 {
|
||||
return strings.Repeat("*", n)
|
||||
}
|
||||
midStart := n/2 - 2
|
||||
if midStart < 8 {
|
||||
midStart = 8
|
||||
}
|
||||
if midStart+4 > n-8 {
|
||||
midStart = n - 8 - 4
|
||||
if midStart < 8 {
|
||||
midStart = 8
|
||||
}
|
||||
}
|
||||
prefixByte := s[:8]
|
||||
middle := s[midStart : midStart+4]
|
||||
suffix := s[n-8:]
|
||||
return prefixByte + strings.Repeat("*", 4) + middle + strings.Repeat("*", 4) + suffix
|
||||
}
|
||||
|
||||
var NanoBananaModel = map[string]struct{}{
|
||||
"gemini-2.5-flash-image-preview": {},
|
||||
}
|
||||
|
||||
// NewGeminiClient creates a client. Pass empty strings to auto-detect via browser cookies (not implemented in Go port).
|
||||
func NewGeminiClient(secure1psid string, secure1psidts string, proxy string, opts ...func(*GeminiClient)) *GeminiClient {
|
||||
c := &GeminiClient{
|
||||
Cookies: map[string]string{},
|
||||
Proxy: proxy,
|
||||
Running: false,
|
||||
Timeout: 300 * time.Second,
|
||||
insecure: false,
|
||||
}
|
||||
if secure1psid != "" {
|
||||
c.Cookies["__Secure-1PSID"] = secure1psid
|
||||
if secure1psidts != "" {
|
||||
c.Cookies["__Secure-1PSIDTS"] = secure1psidts
|
||||
}
|
||||
}
|
||||
for _, f := range opts {
|
||||
f(c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// WithInsecureTLS sets skipping TLS verification (to mirror httpx verify=False)
|
||||
func WithInsecureTLS(insecure bool) func(*GeminiClient) {
|
||||
return func(c *GeminiClient) { c.insecure = insecure }
|
||||
}
|
||||
|
||||
// Init initializes the access token and http client.
|
||||
func (c *GeminiClient) Init(timeoutSec float64, verbose bool) error {
|
||||
// get access token
|
||||
token, validCookies, err := getAccessToken(c.Cookies, c.Proxy, verbose, c.insecure)
|
||||
if err != nil {
|
||||
c.Close(0)
|
||||
return err
|
||||
}
|
||||
c.AccessToken = token
|
||||
c.Cookies = validCookies
|
||||
|
||||
tr := &http.Transport{}
|
||||
if c.Proxy != "" {
|
||||
if pu, errParse := url.Parse(c.Proxy); errParse == nil {
|
||||
tr.Proxy = http.ProxyURL(pu)
|
||||
}
|
||||
}
|
||||
if c.insecure {
|
||||
// set via roundtripper in utils_get_access_token for token; here we reuse via default Transport
|
||||
// intentionally not adding here, as requests rely on endpoints with normal TLS
|
||||
}
|
||||
c.httpClient = &http.Client{Transport: tr, Timeout: time.Duration(timeoutSec * float64(time.Second))}
|
||||
c.Running = true
|
||||
|
||||
c.Timeout = time.Duration(timeoutSec * float64(time.Second))
|
||||
if verbose {
|
||||
fmt.Println("Gemini client initialized successfully.")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *GeminiClient) Close(delaySec float64) {
|
||||
if delaySec > 0 {
|
||||
time.Sleep(time.Duration(delaySec * float64(time.Second)))
|
||||
}
|
||||
c.Running = false
|
||||
}
|
||||
|
||||
// ensureRunning mirrors the decorator behavior and retries on APIError.
|
||||
func (c *GeminiClient) ensureRunning() error {
|
||||
if c.Running {
|
||||
return nil
|
||||
}
|
||||
return c.Init(float64(c.Timeout/time.Second), false)
|
||||
}
|
||||
|
||||
// RotateTS performs a RotateCookies request and returns the new __Secure-1PSIDTS value (if any).
|
||||
func (c *GeminiClient) RotateTS() (string, error) {
|
||||
if c == nil {
|
||||
return "", fmt.Errorf("gemini web client is nil")
|
||||
}
|
||||
return rotate1PSIDTS(c.Cookies, c.Proxy, c.insecure)
|
||||
}
|
||||
|
||||
// GenerateContent sends a prompt (with optional files) and parses the response into ModelOutput.
|
||||
func (c *GeminiClient) GenerateContent(prompt string, files []string, model Model, gem *Gem, chat *ChatSession) (ModelOutput, error) {
|
||||
var empty ModelOutput
|
||||
if prompt == "" {
|
||||
return empty, &ValueError{Msg: "Prompt cannot be empty."}
|
||||
}
|
||||
if err := c.ensureRunning(); err != nil {
|
||||
return empty, err
|
||||
}
|
||||
|
||||
// Retry wrapper similar to decorator (retry=2)
|
||||
retries := 2
|
||||
for {
|
||||
out, err := c.generateOnce(prompt, files, model, gem, chat)
|
||||
if err == nil {
|
||||
return out, nil
|
||||
}
|
||||
var apiErr *APIError
|
||||
var imgErr *ImageGenerationError
|
||||
shouldRetry := false
|
||||
if errors.As(err, &imgErr) {
|
||||
if retries > 1 {
|
||||
retries = 1
|
||||
} // only once for image generation
|
||||
shouldRetry = true
|
||||
} else if errors.As(err, &apiErr) {
|
||||
shouldRetry = true
|
||||
}
|
||||
if shouldRetry && retries > 0 {
|
||||
time.Sleep(time.Second)
|
||||
retries--
|
||||
continue
|
||||
}
|
||||
return empty, err
|
||||
}
|
||||
}
|
||||
|
||||
func ensureAnyLen(slice []any, index int) []any {
|
||||
if index < len(slice) {
|
||||
return slice
|
||||
}
|
||||
gap := index + 1 - len(slice)
|
||||
return append(slice, make([]any, gap)...)
|
||||
}
|
||||
|
||||
func (c *GeminiClient) generateOnce(prompt string, files []string, model Model, gem *Gem, chat *ChatSession) (ModelOutput, error) {
|
||||
var empty ModelOutput
|
||||
// Build f.req
|
||||
var uploaded [][]any
|
||||
for _, fp := range files {
|
||||
id, err := uploadFile(fp, c.Proxy, c.insecure)
|
||||
if err != nil {
|
||||
return empty, err
|
||||
}
|
||||
name, err := parseFileName(fp)
|
||||
if err != nil {
|
||||
return empty, err
|
||||
}
|
||||
uploaded = append(uploaded, []any{[]any{id}, name})
|
||||
}
|
||||
var item0 any
|
||||
if len(uploaded) > 0 {
|
||||
item0 = []any{prompt, 0, nil, uploaded}
|
||||
} else {
|
||||
item0 = []any{prompt}
|
||||
}
|
||||
var item2 any = nil
|
||||
if chat != nil {
|
||||
item2 = chat.Metadata()
|
||||
}
|
||||
|
||||
inner := []any{item0, nil, item2}
|
||||
requestedModel := strings.ToLower(model.Name)
|
||||
if chat != nil && chat.RequestedModel() != "" {
|
||||
requestedModel = chat.RequestedModel()
|
||||
}
|
||||
if _, ok := NanoBananaModel[requestedModel]; ok {
|
||||
inner = ensureAnyLen(inner, 49)
|
||||
inner[49] = 14
|
||||
}
|
||||
if gem != nil {
|
||||
// pad with 16 nils then gem ID
|
||||
for i := 0; i < 16; i++ {
|
||||
inner = append(inner, nil)
|
||||
}
|
||||
inner = append(inner, gem.ID)
|
||||
}
|
||||
innerJSON, _ := json.Marshal(inner)
|
||||
outer := []any{nil, string(innerJSON)}
|
||||
outerJSON, _ := json.Marshal(outer)
|
||||
|
||||
// form
|
||||
form := url.Values{}
|
||||
form.Set("at", c.AccessToken)
|
||||
form.Set("f.req", string(outerJSON))
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPost, EndpointGenerate, strings.NewReader(form.Encode()))
|
||||
applyHeaders(req, HeadersGemini)
|
||||
applyHeaders(req, model.ModelHeader)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded;charset=utf-8")
|
||||
applyCookies(req, c.Cookies)
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return empty, &TimeoutError{GeminiError{Msg: "Generate content request timed out."}}
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode == 429 {
|
||||
// Surface 429 as TemporarilyBlocked to match reference behavior
|
||||
c.Close(0)
|
||||
return empty, &TemporarilyBlocked{GeminiError{Msg: "Too many requests. IP temporarily blocked."}}
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
c.Close(0)
|
||||
return empty, &APIError{Msg: fmt.Sprintf("Failed to generate contents. Status %d", resp.StatusCode)}
|
||||
}
|
||||
|
||||
// Read body and split lines; take the 3rd line (index 2)
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
parts := strings.Split(string(b), "\n")
|
||||
if len(parts) < 3 {
|
||||
c.Close(0)
|
||||
return empty, &APIError{Msg: "Invalid response data received."}
|
||||
}
|
||||
var responseJSON []any
|
||||
if err = json.Unmarshal([]byte(parts[2]), &responseJSON); err != nil {
|
||||
c.Close(0)
|
||||
return empty, &APIError{Msg: "Invalid response data received."}
|
||||
}
|
||||
|
||||
// find body where main_part[4] exists
|
||||
var (
|
||||
body any
|
||||
bodyIndex int
|
||||
)
|
||||
for i, p := range responseJSON {
|
||||
arr, ok := p.([]any)
|
||||
if !ok || len(arr) < 3 {
|
||||
continue
|
||||
}
|
||||
s, ok := arr[2].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var mainPart []any
|
||||
if err = json.Unmarshal([]byte(s), &mainPart); err != nil {
|
||||
continue
|
||||
}
|
||||
if len(mainPart) > 4 && mainPart[4] != nil {
|
||||
body = mainPart
|
||||
bodyIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if body == nil {
|
||||
// Fallback: scan subsequent lines to locate a data frame with a non-empty body (mainPart[4]).
|
||||
var lastTop []any
|
||||
for li := 3; li < len(parts) && body == nil; li++ {
|
||||
line := strings.TrimSpace(parts[li])
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
var top []any
|
||||
if err = json.Unmarshal([]byte(line), &top); err != nil {
|
||||
continue
|
||||
}
|
||||
lastTop = top
|
||||
for i, p := range top {
|
||||
arr, ok := p.([]any)
|
||||
if !ok || len(arr) < 3 {
|
||||
continue
|
||||
}
|
||||
s, ok := arr[2].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var mainPart []any
|
||||
if err = json.Unmarshal([]byte(s), &mainPart); err != nil {
|
||||
continue
|
||||
}
|
||||
if len(mainPart) > 4 && mainPart[4] != nil {
|
||||
body = mainPart
|
||||
bodyIndex = i
|
||||
responseJSON = top
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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 {
|
||||
top = lastTop
|
||||
} else {
|
||||
_ = json.Unmarshal([]byte(parts[2]), &top)
|
||||
}
|
||||
if len(top) > 0 {
|
||||
if code, ok := extractErrorCode(top); ok {
|
||||
switch code {
|
||||
case ErrorUsageLimitExceeded:
|
||||
return empty, &UsageLimitExceeded{GeminiError{Msg: fmt.Sprintf("Failed to generate contents. Usage limit of %s has exceeded. Please try switching to another model.", model.Name)}}
|
||||
case ErrorModelInconsistent:
|
||||
return empty, &ModelInvalid{GeminiError{Msg: "Selected model is inconsistent or unavailable."}}
|
||||
case ErrorModelHeaderInvalid:
|
||||
return empty, &APIError{Msg: "Invalid model header string. Please update the selected model header."}
|
||||
case ErrorIPTemporarilyBlocked:
|
||||
return empty, &TemporarilyBlocked{GeminiError{Msg: "Too many requests. IP temporarily blocked."}}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Debug("Invalid response: control frames only; no body found")
|
||||
// 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."}
|
||||
}
|
||||
|
||||
bodyArr := body.([]any)
|
||||
// metadata
|
||||
var metadata []string
|
||||
if len(bodyArr) > 1 {
|
||||
if metaArr, ok := bodyArr[1].([]any); ok {
|
||||
for _, v := range metaArr {
|
||||
if s, isOk := v.(string); isOk {
|
||||
metadata = append(metadata, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// candidates parsing
|
||||
candContainer, ok := bodyArr[4].([]any)
|
||||
if !ok {
|
||||
return empty, &APIError{Msg: "Failed to parse response body."}
|
||||
}
|
||||
candidates := make([]Candidate, 0, len(candContainer))
|
||||
reCard := regexp.MustCompile(`^http://googleusercontent\.com/card_content/\d+`)
|
||||
reGen := regexp.MustCompile(`http://googleusercontent\.com/image_generation_content/\d+`)
|
||||
|
||||
for ci, candAny := range candContainer {
|
||||
cArr, isOk := candAny.([]any)
|
||||
if !isOk {
|
||||
continue
|
||||
}
|
||||
// text: cArr[1][0]
|
||||
var text string
|
||||
if len(cArr) > 1 {
|
||||
if sArr, isOk1 := cArr[1].([]any); isOk1 && len(sArr) > 0 {
|
||||
text, _ = sArr[0].(string)
|
||||
}
|
||||
}
|
||||
if reCard.MatchString(text) {
|
||||
// candidate[22] and candidate[22][0] or text
|
||||
if len(cArr) > 22 {
|
||||
if arr, isOk1 := cArr[22].([]any); isOk1 && len(arr) > 0 {
|
||||
if s, isOk2 := arr[0].(string); isOk2 {
|
||||
text = s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// thoughts: candidate[37][0][0]
|
||||
var thoughts *string
|
||||
if len(cArr) > 37 {
|
||||
if a, ok1 := cArr[37].([]any); ok1 && len(a) > 0 {
|
||||
if b1, ok2 := a[0].([]any); ok2 && len(b1) > 0 {
|
||||
if s, ok3 := b1[0].(string); ok3 {
|
||||
ss := decodeHTML(s)
|
||||
thoughts = &ss
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// web images: candidate[12][1]
|
||||
var webImages []WebImage
|
||||
var imgSection any
|
||||
if len(cArr) > 12 {
|
||||
imgSection = cArr[12]
|
||||
}
|
||||
if arr, ok1 := imgSection.([]any); ok1 && len(arr) > 1 {
|
||||
if imagesArr, ok2 := arr[1].([]any); ok2 {
|
||||
for _, wiAny := range imagesArr {
|
||||
wiArr, ok3 := wiAny.([]any)
|
||||
if !ok3 {
|
||||
continue
|
||||
}
|
||||
// url: wiArr[0][0][0], title: wiArr[7][0], alt: wiArr[0][4]
|
||||
var urlStr, title, alt string
|
||||
if len(wiArr) > 0 {
|
||||
if a, ok5 := wiArr[0].([]any); ok5 && len(a) > 0 {
|
||||
if b1, ok6 := a[0].([]any); ok6 && len(b1) > 0 {
|
||||
urlStr, _ = b1[0].(string)
|
||||
}
|
||||
if len(a) > 4 {
|
||||
if s, ok6 := a[4].(string); ok6 {
|
||||
alt = s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(wiArr) > 7 {
|
||||
if a, ok4 := wiArr[7].([]any); ok4 && len(a) > 0 {
|
||||
title, _ = a[0].(string)
|
||||
}
|
||||
}
|
||||
webImages = append(webImages, WebImage{Image: Image{URL: urlStr, Title: title, Alt: alt, Proxy: c.Proxy}})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// generated images
|
||||
var genImages []GeneratedImage
|
||||
hasGen := false
|
||||
if arr, ok1 := imgSection.([]any); ok1 && len(arr) > 7 {
|
||||
if a, ok2 := arr[7].([]any); ok2 && len(a) > 0 && a[0] != nil {
|
||||
hasGen = true
|
||||
}
|
||||
}
|
||||
if hasGen {
|
||||
// find img part
|
||||
var imgBody []any
|
||||
for pi := bodyIndex; pi < len(responseJSON); pi++ {
|
||||
part := responseJSON[pi]
|
||||
arr, ok1 := part.([]any)
|
||||
if !ok1 || len(arr) < 3 {
|
||||
continue
|
||||
}
|
||||
s, ok1 := arr[2].(string)
|
||||
if !ok1 {
|
||||
continue
|
||||
}
|
||||
var mp []any
|
||||
if err = json.Unmarshal([]byte(s), &mp); err != nil {
|
||||
continue
|
||||
}
|
||||
if len(mp) > 4 {
|
||||
if tt, ok2 := mp[4].([]any); ok2 && len(tt) > ci {
|
||||
if sec, ok3 := tt[ci].([]any); ok3 && len(sec) > 12 {
|
||||
if ss, ok4 := sec[12].([]any); ok4 && len(ss) > 7 {
|
||||
if first, ok5 := ss[7].([]any); ok5 && len(first) > 0 && first[0] != nil {
|
||||
imgBody = mp
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if imgBody == nil {
|
||||
return empty, &ImageGenerationError{APIError{Msg: "Failed to parse generated images."}}
|
||||
}
|
||||
imgCand := imgBody[4].([]any)[ci].([]any)
|
||||
if len(imgCand) > 1 {
|
||||
if a, ok1 := imgCand[1].([]any); ok1 && len(a) > 0 {
|
||||
if s, ok2 := a[0].(string); ok2 {
|
||||
text = strings.TrimSpace(reGen.ReplaceAllString(s, ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
// images list at imgCand[12][7][0]
|
||||
if len(imgCand) > 12 {
|
||||
if s1, ok1 := imgCand[12].([]any); ok1 && len(s1) > 7 {
|
||||
if s2, ok2 := s1[7].([]any); ok2 && len(s2) > 0 {
|
||||
if s3, ok3 := s2[0].([]any); ok3 {
|
||||
for ii, giAny := range s3 {
|
||||
ga, ok4 := giAny.([]any)
|
||||
if !ok4 || len(ga) < 4 {
|
||||
continue
|
||||
}
|
||||
// url: ga[0][3][3]
|
||||
var urlStr, title, alt string
|
||||
if a, ok5 := ga[0].([]any); ok5 && len(a) > 3 {
|
||||
if b1, ok6 := a[3].([]any); ok6 && len(b1) > 3 {
|
||||
urlStr, _ = b1[3].(string)
|
||||
}
|
||||
}
|
||||
// title from ga[3][6]
|
||||
if len(ga) > 3 {
|
||||
if a, ok5 := ga[3].([]any); ok5 {
|
||||
if len(a) > 6 {
|
||||
if v, ok6 := a[6].(float64); ok6 && v != 0 {
|
||||
title = fmt.Sprintf("[Generated Image %.0f]", v)
|
||||
} else {
|
||||
title = "[Generated Image]"
|
||||
}
|
||||
} else {
|
||||
title = "[Generated Image]"
|
||||
}
|
||||
// alt from ga[3][5][ii] fallback
|
||||
if len(a) > 5 {
|
||||
if tt, ok6 := a[5].([]any); ok6 {
|
||||
if ii < len(tt) {
|
||||
if s, ok7 := tt[ii].(string); ok7 {
|
||||
alt = s
|
||||
}
|
||||
} else if len(tt) > 0 {
|
||||
if s, ok7 := tt[0].(string); ok7 {
|
||||
alt = s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
genImages = append(genImages, GeneratedImage{Image: Image{URL: urlStr, Title: title, Alt: alt, Proxy: c.Proxy}, Cookies: c.Cookies})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cand := Candidate{
|
||||
RCID: fmt.Sprintf("%v", cArr[0]),
|
||||
Text: decodeHTML(text),
|
||||
Thoughts: thoughts,
|
||||
WebImages: webImages,
|
||||
GeneratedImages: genImages,
|
||||
}
|
||||
candidates = append(candidates, cand)
|
||||
}
|
||||
|
||||
if len(candidates) == 0 {
|
||||
return empty, &GeminiError{Msg: "Failed to generate contents. No output data found in response."}
|
||||
}
|
||||
output := ModelOutput{Metadata: metadata, Candidates: candidates, Chosen: 0}
|
||||
if chat != nil {
|
||||
chat.lastOutput = &output
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
// extractErrorCode attempts to navigate the known nested error structure and fetch the integer code.
|
||||
// Mirrors reference path: response_json[0][5][2][0][1][0]
|
||||
func extractErrorCode(top []any) (int, bool) {
|
||||
if len(top) == 0 {
|
||||
return 0, false
|
||||
}
|
||||
a, ok := top[0].([]any)
|
||||
if !ok || len(a) <= 5 {
|
||||
return 0, false
|
||||
}
|
||||
b, ok := a[5].([]any)
|
||||
if !ok || len(b) <= 2 {
|
||||
return 0, false
|
||||
}
|
||||
c, ok := b[2].([]any)
|
||||
if !ok || len(c) == 0 {
|
||||
return 0, false
|
||||
}
|
||||
d, ok := c[0].([]any)
|
||||
if !ok || len(d) <= 1 {
|
||||
return 0, false
|
||||
}
|
||||
e, ok := d[1].([]any)
|
||||
if !ok || len(e) == 0 {
|
||||
return 0, false
|
||||
}
|
||||
f, ok := e[0].(float64)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return int(f), true
|
||||
}
|
||||
|
||||
// StartChat returns a ChatSession attached to the client
|
||||
func (c *GeminiClient) StartChat(model Model, gem *Gem, metadata []string) *ChatSession {
|
||||
return &ChatSession{client: c, metadata: normalizeMeta(metadata), model: model, gem: gem, requestedModel: strings.ToLower(model.Name)}
|
||||
}
|
||||
|
||||
// ChatSession holds conversation metadata
|
||||
type ChatSession struct {
|
||||
client *GeminiClient
|
||||
metadata []string // cid, rid, rcid
|
||||
lastOutput *ModelOutput
|
||||
model Model
|
||||
gem *Gem
|
||||
requestedModel string
|
||||
}
|
||||
|
||||
func (cs *ChatSession) String() string {
|
||||
var cid, rid, rcid string
|
||||
if len(cs.metadata) > 0 {
|
||||
cid = cs.metadata[0]
|
||||
}
|
||||
if len(cs.metadata) > 1 {
|
||||
rid = cs.metadata[1]
|
||||
}
|
||||
if len(cs.metadata) > 2 {
|
||||
rcid = cs.metadata[2]
|
||||
}
|
||||
return fmt.Sprintf("ChatSession(cid='%s', rid='%s', rcid='%s')", cid, rid, rcid)
|
||||
}
|
||||
|
||||
func normalizeMeta(v []string) []string {
|
||||
out := []string{"", "", ""}
|
||||
for i := 0; i < len(v) && i < 3; i++ {
|
||||
out[i] = v[i]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (cs *ChatSession) Metadata() []string { return cs.metadata }
|
||||
func (cs *ChatSession) SetMetadata(v []string) { cs.metadata = normalizeMeta(v) }
|
||||
func (cs *ChatSession) RequestedModel() string { return cs.requestedModel }
|
||||
func (cs *ChatSession) SetRequestedModel(name string) {
|
||||
cs.requestedModel = strings.ToLower(name)
|
||||
}
|
||||
func (cs *ChatSession) CID() string {
|
||||
if len(cs.metadata) > 0 {
|
||||
return cs.metadata[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
func (cs *ChatSession) RID() string {
|
||||
if len(cs.metadata) > 1 {
|
||||
return cs.metadata[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
func (cs *ChatSession) RCID() string {
|
||||
if len(cs.metadata) > 2 {
|
||||
return cs.metadata[2]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
func (cs *ChatSession) setCID(v string) {
|
||||
if len(cs.metadata) < 1 {
|
||||
cs.metadata = normalizeMeta(cs.metadata)
|
||||
}
|
||||
cs.metadata[0] = v
|
||||
}
|
||||
func (cs *ChatSession) setRID(v string) {
|
||||
if len(cs.metadata) < 2 {
|
||||
cs.metadata = normalizeMeta(cs.metadata)
|
||||
}
|
||||
cs.metadata[1] = v
|
||||
}
|
||||
func (cs *ChatSession) setRCID(v string) {
|
||||
if len(cs.metadata) < 3 {
|
||||
cs.metadata = normalizeMeta(cs.metadata)
|
||||
}
|
||||
cs.metadata[2] = v
|
||||
}
|
||||
|
||||
// SendMessage shortcut to client's GenerateContent
|
||||
func (cs *ChatSession) SendMessage(prompt string, files []string) (ModelOutput, error) {
|
||||
out, err := cs.client.GenerateContent(prompt, files, cs.model, cs.gem, cs)
|
||||
if err == nil {
|
||||
cs.lastOutput = &out
|
||||
cs.SetMetadata(out.Metadata)
|
||||
cs.setRCID(out.RCID())
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
|
||||
// ChooseCandidate selects a candidate from last output and updates rcid
|
||||
func (cs *ChatSession) ChooseCandidate(index int) (ModelOutput, error) {
|
||||
if cs.lastOutput == nil {
|
||||
return ModelOutput{}, &ValueError{Msg: "No previous output data found in this chat session."}
|
||||
}
|
||||
if index >= len(cs.lastOutput.Candidates) {
|
||||
return ModelOutput{}, &ValueError{Msg: fmt.Sprintf("Index %d exceeds candidates", index)}
|
||||
}
|
||||
cs.lastOutput.Chosen = index
|
||||
cs.setRCID(cs.lastOutput.RCID())
|
||||
return *cs.lastOutput, nil
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
package conversation
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||
)
|
||||
|
||||
var (
|
||||
aliasOnce sync.Once
|
||||
aliasMap map[string]string
|
||||
)
|
||||
|
||||
// EnsureGeminiWebAliasMap populates the alias map once.
|
||||
func EnsureGeminiWebAliasMap() {
|
||||
aliasOnce.Do(func() {
|
||||
aliasMap = make(map[string]string)
|
||||
for _, m := range registry.GetGeminiModels() {
|
||||
if m.ID == "gemini-2.5-flash-lite" {
|
||||
continue
|
||||
}
|
||||
if m.ID == "gemini-2.5-flash" {
|
||||
aliasMap["gemini-2.5-flash-image-preview"] = "gemini-2.5-flash"
|
||||
}
|
||||
alias := AliasFromModelID(m.ID)
|
||||
aliasMap[strings.ToLower(alias)] = strings.ToLower(m.ID)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// MapAliasToUnderlying normalizes a model alias to its underlying identifier.
|
||||
func MapAliasToUnderlying(name string) string {
|
||||
EnsureGeminiWebAliasMap()
|
||||
n := strings.ToLower(strings.TrimSpace(name))
|
||||
if n == "" {
|
||||
return n
|
||||
}
|
||||
if u, ok := aliasMap[n]; ok {
|
||||
return u
|
||||
}
|
||||
const suffix = "-web"
|
||||
if strings.HasSuffix(n, suffix) {
|
||||
return strings.TrimSuffix(n, suffix)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// AliasFromModelID mirrors the original helper for deriving alias IDs.
|
||||
func AliasFromModelID(modelID string) string {
|
||||
return modelID + "-web"
|
||||
}
|
||||
|
||||
// NormalizeModel returns the canonical identifier used for hashing.
|
||||
func NormalizeModel(model string) string {
|
||||
return MapAliasToUnderlying(model)
|
||||
}
|
||||
|
||||
// GetGeminiWebAliasedModels returns alias metadata for registry exposure.
|
||||
func GetGeminiWebAliasedModels() []*registry.ModelInfo {
|
||||
EnsureGeminiWebAliasMap()
|
||||
aliased := make([]*registry.ModelInfo, 0)
|
||||
for _, m := range registry.GetGeminiModels() {
|
||||
if m.ID == "gemini-2.5-flash-lite" {
|
||||
continue
|
||||
} else if m.ID == "gemini-2.5-flash" {
|
||||
cpy := *m
|
||||
cpy.ID = "gemini-2.5-flash-image-preview"
|
||||
cpy.Name = "gemini-2.5-flash-image-preview"
|
||||
cpy.DisplayName = "Nano Banana"
|
||||
cpy.Description = "Gemini 2.5 Flash Preview Image"
|
||||
aliased = append(aliased, &cpy)
|
||||
}
|
||||
cpy := *m
|
||||
cpy.ID = AliasFromModelID(m.ID)
|
||||
cpy.Name = cpy.ID
|
||||
aliased = append(aliased, &cpy)
|
||||
}
|
||||
return aliased
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
package conversation
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Message represents a minimal role-text pair used for hashing and comparison.
|
||||
type Message struct {
|
||||
Role string `json:"role"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// StoredMessage mirrors the persisted conversation message structure.
|
||||
type StoredMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
// Sha256Hex computes SHA-256 hex digest for the specified string.
|
||||
func Sha256Hex(s string) string {
|
||||
sum := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// ToStoredMessages converts in-memory messages into the persisted representation.
|
||||
func ToStoredMessages(msgs []Message) []StoredMessage {
|
||||
out := make([]StoredMessage, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
out = append(out, StoredMessage{Role: m.Role, Content: m.Text})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// StoredToMessages converts stored messages back into the in-memory representation.
|
||||
func StoredToMessages(msgs []StoredMessage) []Message {
|
||||
out := make([]Message, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
out = append(out, Message{Role: m.Role, Text: m.Content})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// hashMessage normalizes message data and returns a stable digest.
|
||||
func hashMessage(m StoredMessage) string {
|
||||
s := fmt.Sprintf(`{"content":%q,"role":%q}`, m.Content, strings.ToLower(m.Role))
|
||||
return Sha256Hex(s)
|
||||
}
|
||||
|
||||
// HashConversationWithPrefix computes a conversation hash using the provided prefix (client identifier) and model.
|
||||
func HashConversationWithPrefix(prefix, model string, msgs []StoredMessage) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(strings.ToLower(strings.TrimSpace(prefix)))
|
||||
b.WriteString("|")
|
||||
b.WriteString(strings.ToLower(strings.TrimSpace(model)))
|
||||
for _, m := range msgs {
|
||||
b.WriteString("|")
|
||||
b.WriteString(hashMessage(m))
|
||||
}
|
||||
return Sha256Hex(b.String())
|
||||
}
|
||||
|
||||
// HashConversationForAccount keeps compatibility with the per-account hash previously used.
|
||||
func HashConversationForAccount(clientID, model string, msgs []StoredMessage) string {
|
||||
return HashConversationWithPrefix(clientID, model, msgs)
|
||||
}
|
||||
|
||||
// HashConversationGlobal produces a hash suitable for cross-account lookups.
|
||||
func HashConversationGlobal(model string, msgs []StoredMessage) string {
|
||||
return HashConversationWithPrefix("global", model, msgs)
|
||||
}
|
||||
@@ -1,280 +0,0 @@
|
||||
package conversation
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
bolt "go.etcd.io/bbolt"
|
||||
)
|
||||
|
||||
const (
|
||||
bucketMatches = "matches"
|
||||
defaultIndexFile = "gemini-web-index.bolt"
|
||||
)
|
||||
|
||||
// MatchRecord stores persisted mapping metadata for a conversation prefix.
|
||||
type MatchRecord struct {
|
||||
AccountLabel string `json:"account_label"`
|
||||
Metadata []string `json:"metadata,omitempty"`
|
||||
PrefixLen int `json:"prefix_len"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
|
||||
// MatchResult combines a persisted record with the hash that produced it.
|
||||
type MatchResult struct {
|
||||
Hash string
|
||||
Record MatchRecord
|
||||
Model string
|
||||
}
|
||||
|
||||
var (
|
||||
indexOnce sync.Once
|
||||
indexDB *bolt.DB
|
||||
indexErr error
|
||||
)
|
||||
|
||||
func openIndex() (*bolt.DB, error) {
|
||||
indexOnce.Do(func() {
|
||||
path := indexPath()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
indexErr = err
|
||||
return
|
||||
}
|
||||
db, err := bolt.Open(path, 0o600, &bolt.Options{Timeout: 2 * time.Second})
|
||||
if err != nil {
|
||||
indexErr = err
|
||||
return
|
||||
}
|
||||
indexDB = db
|
||||
})
|
||||
return indexDB, indexErr
|
||||
}
|
||||
|
||||
func indexPath() string {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil || wd == "" {
|
||||
wd = "."
|
||||
}
|
||||
return filepath.Join(wd, "conv", defaultIndexFile)
|
||||
}
|
||||
|
||||
// StoreMatch persists or updates a conversation hash mapping.
|
||||
func StoreMatch(hash string, record MatchRecord) error {
|
||||
if strings.TrimSpace(hash) == "" {
|
||||
return errors.New("gemini-web conversation: empty hash")
|
||||
}
|
||||
db, err := openIndex()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
record.UpdatedAt = time.Now().UTC().Unix()
|
||||
payload, err := json.Marshal(record)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Update(func(tx *bolt.Tx) error {
|
||||
bucket, err := tx.CreateBucketIfNotExists([]byte(bucketMatches))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Namespace by account label to avoid cross-account collisions.
|
||||
label := strings.ToLower(strings.TrimSpace(record.AccountLabel))
|
||||
if label == "" {
|
||||
return errors.New("gemini-web conversation: empty account label")
|
||||
}
|
||||
key := []byte(hash + ":" + label)
|
||||
if err := bucket.Put(key, payload); err != nil {
|
||||
return err
|
||||
}
|
||||
// Best-effort cleanup of legacy single-key format (hash -> MatchRecord).
|
||||
// We do not know its label; leave it for lookup fallback/cleanup elsewhere.
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// LookupMatch retrieves a stored mapping.
|
||||
// It prefers namespaced entries (hash:label). If multiple labels exist for the same
|
||||
// hash, it returns not found to avoid redirecting to the wrong credential.
|
||||
// Falls back to legacy single-key entries if present.
|
||||
func LookupMatch(hash string) (MatchRecord, bool, error) {
|
||||
db, err := openIndex()
|
||||
if err != nil {
|
||||
return MatchRecord{}, false, err
|
||||
}
|
||||
var foundOne bool
|
||||
var ambiguous bool
|
||||
var firstLabel string
|
||||
var single MatchRecord
|
||||
err = db.View(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(bucketMatches))
|
||||
if bucket == nil {
|
||||
return nil
|
||||
}
|
||||
// Scan namespaced keys with prefix "hash:"
|
||||
prefix := []byte(hash + ":")
|
||||
c := bucket.Cursor()
|
||||
for k, v := c.Seek(prefix); k != nil && bytes.HasPrefix(k, prefix); k, v = c.Next() {
|
||||
if len(v) == 0 {
|
||||
continue
|
||||
}
|
||||
var rec MatchRecord
|
||||
if err := json.Unmarshal(v, &rec); err != nil {
|
||||
// Ignore malformed; removal is handled elsewhere.
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(rec.AccountLabel) == "" || rec.PrefixLen <= 0 {
|
||||
continue
|
||||
}
|
||||
label := strings.ToLower(strings.TrimSpace(rec.AccountLabel))
|
||||
if !foundOne {
|
||||
firstLabel = label
|
||||
single = rec
|
||||
foundOne = true
|
||||
continue
|
||||
}
|
||||
if label != firstLabel {
|
||||
ambiguous = true
|
||||
// Early exit scan; ambiguity detected.
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if foundOne {
|
||||
return nil
|
||||
}
|
||||
// Fallback to legacy single-key format
|
||||
raw := bucket.Get([]byte(hash))
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(raw, &single)
|
||||
})
|
||||
if err != nil {
|
||||
return MatchRecord{}, false, err
|
||||
}
|
||||
if ambiguous {
|
||||
return MatchRecord{}, false, nil
|
||||
}
|
||||
if strings.TrimSpace(single.AccountLabel) == "" || single.PrefixLen <= 0 {
|
||||
return MatchRecord{}, false, nil
|
||||
}
|
||||
return single, true, nil
|
||||
}
|
||||
|
||||
// RemoveMatch deletes all mappings for the given hash (all labels and legacy key).
|
||||
func RemoveMatch(hash string) error {
|
||||
db, err := openIndex()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Update(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(bucketMatches))
|
||||
if bucket == nil {
|
||||
return nil
|
||||
}
|
||||
// Delete namespaced entries
|
||||
prefix := []byte(hash + ":")
|
||||
c := bucket.Cursor()
|
||||
for k, _ := c.Seek(prefix); k != nil && bytes.HasPrefix(k, prefix); k, _ = c.Next() {
|
||||
if err := bucket.Delete(k); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Delete legacy entry
|
||||
_ = bucket.Delete([]byte(hash))
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// RemoveMatchForLabel deletes the mapping for the given hash and label only.
|
||||
func RemoveMatchForLabel(hash, label string) error {
|
||||
label = strings.ToLower(strings.TrimSpace(label))
|
||||
if strings.TrimSpace(hash) == "" || label == "" {
|
||||
return nil
|
||||
}
|
||||
db, err := openIndex()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Update(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(bucketMatches))
|
||||
if bucket == nil {
|
||||
return nil
|
||||
}
|
||||
// Remove namespaced key
|
||||
_ = bucket.Delete([]byte(hash + ":" + label))
|
||||
// If legacy single-key exists and matches label, remove it as well.
|
||||
if raw := bucket.Get([]byte(hash)); len(raw) > 0 {
|
||||
var rec MatchRecord
|
||||
if err := json.Unmarshal(raw, &rec); err == nil {
|
||||
if strings.EqualFold(strings.TrimSpace(rec.AccountLabel), label) {
|
||||
_ = bucket.Delete([]byte(hash))
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// RemoveMatchesByLabel removes all entries associated with the specified label.
|
||||
func RemoveMatchesByLabel(label string) error {
|
||||
label = strings.TrimSpace(label)
|
||||
if label == "" {
|
||||
return nil
|
||||
}
|
||||
db, err := openIndex()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return db.Update(func(tx *bolt.Tx) error {
|
||||
bucket := tx.Bucket([]byte(bucketMatches))
|
||||
if bucket == nil {
|
||||
return nil
|
||||
}
|
||||
cursor := bucket.Cursor()
|
||||
for k, v := cursor.First(); k != nil; k, v = cursor.Next() {
|
||||
if len(v) == 0 {
|
||||
continue
|
||||
}
|
||||
var record MatchRecord
|
||||
if err := json.Unmarshal(v, &record); err != nil {
|
||||
_ = bucket.Delete(k)
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(record.AccountLabel), label) {
|
||||
if err := bucket.Delete(k); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// StoreConversation updates all hashes representing the provided conversation snapshot.
|
||||
func StoreConversation(label, model string, msgs []Message, metadata []string) error {
|
||||
label = strings.TrimSpace(label)
|
||||
if label == "" || len(msgs) == 0 {
|
||||
return nil
|
||||
}
|
||||
hashes := BuildStorageHashes(model, msgs)
|
||||
if len(hashes) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, h := range hashes {
|
||||
rec := MatchRecord{
|
||||
AccountLabel: label,
|
||||
Metadata: append([]string(nil), metadata...),
|
||||
PrefixLen: h.PrefixLen,
|
||||
}
|
||||
if err := StoreMatch(h.Hash, rec); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
package conversation
|
||||
|
||||
import "strings"
|
||||
|
||||
// PrefixHash represents a hash candidate for a specific prefix length.
|
||||
type PrefixHash struct {
|
||||
Hash string
|
||||
PrefixLen int
|
||||
}
|
||||
|
||||
// BuildLookupHashes generates hash candidates ordered from longest to shortest prefix.
|
||||
func BuildLookupHashes(model string, msgs []Message) []PrefixHash {
|
||||
if len(msgs) < 2 {
|
||||
return nil
|
||||
}
|
||||
model = NormalizeModel(model)
|
||||
sanitized := SanitizeAssistantMessages(msgs)
|
||||
result := make([]PrefixHash, 0, len(sanitized))
|
||||
for end := len(sanitized); end >= 2; end-- {
|
||||
tailRole := strings.ToLower(strings.TrimSpace(sanitized[end-1].Role))
|
||||
if tailRole != "assistant" && tailRole != "system" {
|
||||
continue
|
||||
}
|
||||
prefix := sanitized[:end]
|
||||
hash := HashConversationGlobal(model, ToStoredMessages(prefix))
|
||||
result = append(result, PrefixHash{Hash: hash, PrefixLen: end})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// BuildStorageHashes returns hashes representing the full conversation snapshot.
|
||||
func BuildStorageHashes(model string, msgs []Message) []PrefixHash {
|
||||
if len(msgs) == 0 {
|
||||
return nil
|
||||
}
|
||||
model = NormalizeModel(model)
|
||||
sanitized := SanitizeAssistantMessages(msgs)
|
||||
if len(sanitized) == 0 {
|
||||
return nil
|
||||
}
|
||||
result := make([]PrefixHash, 0, len(sanitized))
|
||||
seen := make(map[string]struct{}, len(sanitized))
|
||||
for start := 0; start < len(sanitized); start++ {
|
||||
segment := sanitized[start:]
|
||||
if len(segment) < 2 {
|
||||
continue
|
||||
}
|
||||
tailRole := strings.ToLower(strings.TrimSpace(segment[len(segment)-1].Role))
|
||||
if tailRole != "assistant" && tailRole != "system" {
|
||||
continue
|
||||
}
|
||||
hash := HashConversationGlobal(model, ToStoredMessages(segment))
|
||||
if _, exists := seen[hash]; exists {
|
||||
continue
|
||||
}
|
||||
seen[hash] = struct{}{}
|
||||
result = append(result, PrefixHash{Hash: hash, PrefixLen: len(segment)})
|
||||
}
|
||||
if len(result) == 0 {
|
||||
hash := HashConversationGlobal(model, ToStoredMessages(sanitized))
|
||||
return []PrefixHash{{Hash: hash, PrefixLen: len(sanitized)}}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
package conversation
|
||||
|
||||
const (
|
||||
MetadataMessagesKey = "gemini_web_messages"
|
||||
MetadataMatchKey = "gemini_web_match"
|
||||
)
|
||||
@@ -1,110 +0,0 @@
|
||||
package conversation
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// ExtractMessages attempts to build a message list from the inbound request payload.
|
||||
func ExtractMessages(handlerType string, raw []byte) []Message {
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
if msgs := extractOpenAIStyle(raw); len(msgs) > 0 {
|
||||
return msgs
|
||||
}
|
||||
if msgs := extractGeminiContents(raw); len(msgs) > 0 {
|
||||
return msgs
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractOpenAIStyle(raw []byte) []Message {
|
||||
root := gjson.ParseBytes(raw)
|
||||
messages := root.Get("messages")
|
||||
if !messages.Exists() {
|
||||
return nil
|
||||
}
|
||||
out := make([]Message, 0, 8)
|
||||
messages.ForEach(func(_, entry gjson.Result) bool {
|
||||
role := strings.ToLower(strings.TrimSpace(entry.Get("role").String()))
|
||||
if role == "" {
|
||||
return true
|
||||
}
|
||||
if role == "system" {
|
||||
return true
|
||||
}
|
||||
// Ignore OpenAI tool messages to keep hashing aligned with
|
||||
// persistence (which only keeps text/inlineData for Gemini contents).
|
||||
// This avoids mismatches when a tool response is present: the
|
||||
// storage path drops tool payloads while the lookup path would
|
||||
// otherwise include them, causing sticky selection to fail.
|
||||
if role == "tool" {
|
||||
return true
|
||||
}
|
||||
var contentBuilder strings.Builder
|
||||
content := entry.Get("content")
|
||||
if !content.Exists() {
|
||||
out = append(out, Message{Role: role, Text: ""})
|
||||
return true
|
||||
}
|
||||
switch content.Type {
|
||||
case gjson.String:
|
||||
contentBuilder.WriteString(content.String())
|
||||
case gjson.JSON:
|
||||
if content.IsArray() {
|
||||
content.ForEach(func(_, part gjson.Result) bool {
|
||||
if text := part.Get("text"); text.Exists() {
|
||||
if contentBuilder.Len() > 0 {
|
||||
contentBuilder.WriteString("\n")
|
||||
}
|
||||
contentBuilder.WriteString(text.String())
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
out = append(out, Message{Role: role, Text: contentBuilder.String()})
|
||||
return true
|
||||
})
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func extractGeminiContents(raw []byte) []Message {
|
||||
contents := gjson.GetBytes(raw, "contents")
|
||||
if !contents.Exists() {
|
||||
return nil
|
||||
}
|
||||
out := make([]Message, 0, 8)
|
||||
contents.ForEach(func(_, entry gjson.Result) bool {
|
||||
role := strings.TrimSpace(entry.Get("role").String())
|
||||
if role == "" {
|
||||
role = "user"
|
||||
} else {
|
||||
role = strings.ToLower(role)
|
||||
if role == "model" {
|
||||
role = "assistant"
|
||||
}
|
||||
}
|
||||
var builder strings.Builder
|
||||
entry.Get("parts").ForEach(func(_, part gjson.Result) bool {
|
||||
if text := part.Get("text"); text.Exists() {
|
||||
if builder.Len() > 0 {
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
builder.WriteString(text.String())
|
||||
}
|
||||
return true
|
||||
})
|
||||
out = append(out, Message{Role: role, Text: builder.String()})
|
||||
return true
|
||||
})
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package conversation
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var reThink = regexp.MustCompile(`(?is)<think>.*?</think>`)
|
||||
|
||||
// RemoveThinkTags strips <think>...</think> blocks and trims whitespace.
|
||||
func RemoveThinkTags(s string) string {
|
||||
return strings.TrimSpace(reThink.ReplaceAllString(s, ""))
|
||||
}
|
||||
|
||||
// SanitizeAssistantMessages removes think tags from assistant messages while leaving others untouched.
|
||||
func SanitizeAssistantMessages(msgs []Message) []Message {
|
||||
out := make([]Message, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
if strings.EqualFold(strings.TrimSpace(m.Role), "assistant") {
|
||||
out = append(out, Message{Role: m.Role, Text: RemoveThinkTags(m.Text)})
|
||||
continue
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// EqualMessages compares two message slices for equality.
|
||||
func EqualMessages(a, b []Message) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i].Role != b[i].Role || a[i].Text != b[i].Text {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -1,542 +0,0 @@
|
||||
package geminiwebapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// Image helpers ------------------------------------------------------------
|
||||
|
||||
type Image struct {
|
||||
URL string
|
||||
Title string
|
||||
Alt string
|
||||
Proxy string
|
||||
}
|
||||
|
||||
func (i Image) String() string {
|
||||
short := i.URL
|
||||
if len(short) > 20 {
|
||||
short = short[:8] + "..." + short[len(short)-12:]
|
||||
}
|
||||
return fmt.Sprintf("Image(title='%s', alt='%s', url='%s')", i.Title, i.Alt, short)
|
||||
}
|
||||
|
||||
func (i Image) Save(path string, filename string, cookies map[string]string, verbose bool, skipInvalidFilename bool, insecure bool) (string, error) {
|
||||
if filename == "" {
|
||||
// Try to parse filename from URL.
|
||||
u := i.URL
|
||||
if p := strings.Split(u, "/"); len(p) > 0 {
|
||||
filename = p[len(p)-1]
|
||||
}
|
||||
if q := strings.Split(filename, "?"); len(q) > 0 {
|
||||
filename = q[0]
|
||||
}
|
||||
}
|
||||
// Regex validation (pattern: ^(.*\.\w+)) to extract name with extension.
|
||||
if filename != "" {
|
||||
re := regexp.MustCompile(`^(.*\.\w+)`)
|
||||
if m := re.FindStringSubmatch(filename); len(m) >= 2 {
|
||||
filename = m[1]
|
||||
} else {
|
||||
if verbose {
|
||||
log.Warnf("Invalid filename: %s", filename)
|
||||
}
|
||||
if skipInvalidFilename {
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
}
|
||||
// Build client using shared helper to keep proxy/TLS behavior consistent.
|
||||
client := newHTTPClient(httpOptions{ProxyURL: i.Proxy, Insecure: insecure, FollowRedirects: true})
|
||||
client.Timeout = 120 * time.Second
|
||||
|
||||
// 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 ""
|
||||
}
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
parts := make([]string, 0, len(keys))
|
||||
for _, k := range keys {
|
||||
parts = append(parts, fmt.Sprintf("%s=%s", k, m[k]))
|
||||
}
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
rawCookie := buildCookieHeader(cookies)
|
||||
|
||||
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
// Ensure provided cookies are always sent across redirects (domain-agnostic).
|
||||
if rawCookie != "" {
|
||||
req.Header.Set("Cookie", rawCookie)
|
||||
}
|
||||
if len(via) >= 10 {
|
||||
return errors.New("stopped after 10 redirects")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, i.URL, nil)
|
||||
if rawCookie != "" {
|
||||
req.Header.Set("Cookie", rawCookie)
|
||||
}
|
||||
// Add browser-like headers to improve compatibility.
|
||||
req.Header.Set("Accept", "image/avif,image/webp,image/apng,image/*,*/*;q=0.8")
|
||||
req.Header.Set("Connection", "keep-alive")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("error downloading image: %d %s", resp.StatusCode, resp.Status)
|
||||
}
|
||||
if ct := resp.Header.Get("Content-Type"); ct != "" && !strings.Contains(strings.ToLower(ct), "image") {
|
||||
log.Warnf("Content type of %s is not image, but %s.", filename, ct)
|
||||
}
|
||||
if path == "" {
|
||||
path = "temp"
|
||||
}
|
||||
if err = os.MkdirAll(path, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
dest := filepath.Join(path, filename)
|
||||
f, err := os.Create(dest)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
_, err = io.Copy(f, resp.Body)
|
||||
_ = f.Close()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if verbose {
|
||||
fmt.Printf("Image saved as %s\n", dest)
|
||||
}
|
||||
abspath, _ := filepath.Abs(dest)
|
||||
return abspath, nil
|
||||
}
|
||||
|
||||
type WebImage struct{ Image }
|
||||
|
||||
type GeneratedImage struct {
|
||||
Image
|
||||
Cookies map[string]string
|
||||
}
|
||||
|
||||
func (g GeneratedImage) Save(path string, filename string, fullSize bool, verbose bool, skipInvalidFilename bool, insecure bool) (string, error) {
|
||||
if len(g.Cookies) == 0 {
|
||||
return "", &ValueError{Msg: "GeneratedImage requires cookies."}
|
||||
}
|
||||
strURL := g.URL
|
||||
if fullSize {
|
||||
strURL = strURL + "=s2048"
|
||||
}
|
||||
if filename == "" {
|
||||
name := time.Now().Format("20060102150405")
|
||||
if len(strURL) >= 10 {
|
||||
name = fmt.Sprintf("%s_%s.png", name, strURL[len(strURL)-10:])
|
||||
} else {
|
||||
name += ".png"
|
||||
}
|
||||
filename = name
|
||||
}
|
||||
tmp := g.Image
|
||||
tmp.URL = strURL
|
||||
return tmp.Save(path, filename, g.Cookies, verbose, skipInvalidFilename, insecure)
|
||||
}
|
||||
|
||||
// Request parsing & file helpers -------------------------------------------
|
||||
|
||||
func ParseMessagesAndFiles(rawJSON []byte) ([]RoleText, [][]byte, []string, [][]int, error) {
|
||||
var messages []RoleText
|
||||
var files [][]byte
|
||||
var mimes []string
|
||||
var perMsgFileIdx [][]int
|
||||
|
||||
contents := gjson.GetBytes(rawJSON, "contents")
|
||||
if contents.Exists() {
|
||||
contents.ForEach(func(_, content gjson.Result) bool {
|
||||
role := NormalizeRole(content.Get("role").String())
|
||||
var b strings.Builder
|
||||
startFile := len(files)
|
||||
content.Get("parts").ForEach(func(_, part gjson.Result) bool {
|
||||
if text := part.Get("text"); text.Exists() {
|
||||
if b.Len() > 0 {
|
||||
b.WriteString("\n")
|
||||
}
|
||||
b.WriteString(text.String())
|
||||
}
|
||||
if inlineData := part.Get("inlineData"); inlineData.Exists() {
|
||||
data := inlineData.Get("data").String()
|
||||
if data != "" {
|
||||
if dec, err := base64.StdEncoding.DecodeString(data); err == nil {
|
||||
files = append(files, dec)
|
||||
m := inlineData.Get("mimeType").String()
|
||||
if m == "" {
|
||||
m = inlineData.Get("mime_type").String()
|
||||
}
|
||||
mimes = append(mimes, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
messages = append(messages, RoleText{Role: role, Text: b.String()})
|
||||
endFile := len(files)
|
||||
if endFile > startFile {
|
||||
idxs := make([]int, 0, endFile-startFile)
|
||||
for i := startFile; i < endFile; i++ {
|
||||
idxs = append(idxs, i)
|
||||
}
|
||||
perMsgFileIdx = append(perMsgFileIdx, idxs)
|
||||
} else {
|
||||
perMsgFileIdx = append(perMsgFileIdx, nil)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
return messages, files, mimes, perMsgFileIdx, nil
|
||||
}
|
||||
|
||||
func MaterializeInlineFiles(files [][]byte, mimes []string) ([]string, *interfaces.ErrorMessage) {
|
||||
if len(files) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
paths := make([]string, 0, len(files))
|
||||
for i, data := range files {
|
||||
ext := MimeToExt(mimes, i)
|
||||
f, err := os.CreateTemp("", "gemini-upload-*"+ext)
|
||||
if err != nil {
|
||||
return nil, &interfaces.ErrorMessage{StatusCode: http.StatusInternalServerError, Error: fmt.Errorf("failed to create temp file: %w", err)}
|
||||
}
|
||||
if _, err = f.Write(data); err != nil {
|
||||
_ = f.Close()
|
||||
_ = os.Remove(f.Name())
|
||||
return nil, &interfaces.ErrorMessage{StatusCode: http.StatusInternalServerError, Error: fmt.Errorf("failed to write temp file: %w", err)}
|
||||
}
|
||||
if err = f.Close(); err != nil {
|
||||
_ = os.Remove(f.Name())
|
||||
return nil, &interfaces.ErrorMessage{StatusCode: http.StatusInternalServerError, Error: fmt.Errorf("failed to close temp file: %w", err)}
|
||||
}
|
||||
paths = append(paths, f.Name())
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
func CleanupFiles(paths []string) {
|
||||
for _, p := range paths {
|
||||
if p != "" {
|
||||
_ = os.Remove(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func FetchGeneratedImageData(gi GeneratedImage) (string, string, error) {
|
||||
path, err := gi.Save("", "", true, false, true, false)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer func() { _ = os.Remove(path) }()
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
mime := http.DetectContentType(b)
|
||||
if !strings.HasPrefix(mime, "image/") {
|
||||
if guessed := mimeFromExtension(filepath.Ext(path)); guessed != "" {
|
||||
mime = guessed
|
||||
} else {
|
||||
mime = "image/png"
|
||||
}
|
||||
}
|
||||
return mime, base64.StdEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
func MimeToExt(mimes []string, i int) string {
|
||||
if i < len(mimes) {
|
||||
return MimeToPreferredExt(strings.ToLower(mimes[i]))
|
||||
}
|
||||
return ".png"
|
||||
}
|
||||
|
||||
var preferredExtByMIME = map[string]string{
|
||||
"image/png": ".png",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/jpg": ".jpg",
|
||||
"image/webp": ".webp",
|
||||
"image/gif": ".gif",
|
||||
"image/bmp": ".bmp",
|
||||
"image/heic": ".heic",
|
||||
"application/pdf": ".pdf",
|
||||
}
|
||||
|
||||
func MimeToPreferredExt(mime string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(mime))
|
||||
if normalized == "" {
|
||||
return ".png"
|
||||
}
|
||||
if ext, ok := preferredExtByMIME[normalized]; ok {
|
||||
return ext
|
||||
}
|
||||
return ".png"
|
||||
}
|
||||
|
||||
func mimeFromExtension(ext string) string {
|
||||
cleaned := strings.TrimPrefix(strings.ToLower(ext), ".")
|
||||
if cleaned == "" {
|
||||
return ""
|
||||
}
|
||||
if mt, ok := misc.MimeTypes[cleaned]; ok && mt != "" {
|
||||
return mt
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// File upload helpers ------------------------------------------------------
|
||||
|
||||
func uploadFile(path string, proxy string, insecure bool) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() {
|
||||
_ = f.Close()
|
||||
}()
|
||||
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
fw, err := mw.CreateFormFile("file", filepath.Base(path))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if _, err = io.Copy(fw, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
_ = mw.Close()
|
||||
|
||||
client := newHTTPClient(httpOptions{ProxyURL: proxy, Insecure: insecure, FollowRedirects: true})
|
||||
client.Timeout = 300 * time.Second
|
||||
|
||||
req, _ := http.NewRequest(http.MethodPost, EndpointUpload, &buf)
|
||||
applyHeaders(req, HeadersUpload)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
req.Header.Set("Accept", "*/*")
|
||||
req.Header.Set("Connection", "keep-alive")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", &APIError{Msg: resp.Status}
|
||||
}
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
func parseFileName(path string) (string, error) {
|
||||
if st, err := os.Stat(path); err != nil || st.IsDir() {
|
||||
return "", &ValueError{Msg: path + " is not a valid file."}
|
||||
}
|
||||
return filepath.Base(path), nil
|
||||
}
|
||||
|
||||
// Response formatting helpers ----------------------------------------------
|
||||
|
||||
var (
|
||||
reGoogle = regexp.MustCompile("(\\()?\\[`([^`]+?)`\\]\\(https://www\\.google\\.com/search\\?q=[^)]*\\)(\\))?")
|
||||
reColonNum = regexp.MustCompile(`([^:]+:\d+)`)
|
||||
reInline = regexp.MustCompile("`(\\[[^\\]]+\\]\\([^\\)]+\\))`")
|
||||
)
|
||||
|
||||
func unescapeGeminiText(s string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, "\\<", "<")
|
||||
s = strings.ReplaceAll(s, "\\_", "_")
|
||||
s = strings.ReplaceAll(s, "\\>", ">")
|
||||
return s
|
||||
}
|
||||
|
||||
func postProcessModelText(text string) string {
|
||||
text = reGoogle.ReplaceAllStringFunc(text, func(m string) string {
|
||||
subs := reGoogle.FindStringSubmatch(m)
|
||||
if len(subs) < 4 {
|
||||
return m
|
||||
}
|
||||
outerOpen := subs[1]
|
||||
display := subs[2]
|
||||
target := display
|
||||
if loc := reColonNum.FindString(display); loc != "" {
|
||||
target = loc
|
||||
}
|
||||
newSeg := "[`" + display + "`](" + target + ")"
|
||||
if outerOpen != "" {
|
||||
return "(" + newSeg + ")"
|
||||
}
|
||||
return newSeg
|
||||
})
|
||||
text = reInline.ReplaceAllString(text, "$1")
|
||||
return text
|
||||
}
|
||||
|
||||
func estimateTokens(s string) int {
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
rc := float64(utf8.RuneCountInString(s))
|
||||
if rc <= 0 {
|
||||
return 0
|
||||
}
|
||||
est := int(math.Ceil(rc / 4.0))
|
||||
if est < 0 {
|
||||
return 0
|
||||
}
|
||||
return est
|
||||
}
|
||||
|
||||
// ConvertOutputToGemini converts simplified ModelOutput to Gemini API-like JSON.
|
||||
// promptText is used only to estimate usage tokens to populate usage fields.
|
||||
func ConvertOutputToGemini(output *ModelOutput, modelName string, promptText string) ([]byte, error) {
|
||||
if output == nil || len(output.Candidates) == 0 {
|
||||
return nil, fmt.Errorf("empty output")
|
||||
}
|
||||
|
||||
parts := make([]map[string]any, 0, 2)
|
||||
|
||||
var thoughtsText string
|
||||
if output.Candidates[0].Thoughts != nil {
|
||||
if t := strings.TrimSpace(*output.Candidates[0].Thoughts); t != "" {
|
||||
thoughtsText = unescapeGeminiText(t)
|
||||
parts = append(parts, map[string]any{
|
||||
"text": thoughtsText,
|
||||
"thought": true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
visible := unescapeGeminiText(output.Candidates[0].Text)
|
||||
finalText := postProcessModelText(visible)
|
||||
if finalText != "" {
|
||||
parts = append(parts, map[string]any{"text": finalText})
|
||||
}
|
||||
|
||||
if imgs := output.Candidates[0].GeneratedImages; len(imgs) > 0 {
|
||||
for _, gi := range imgs {
|
||||
if mime, data, err := FetchGeneratedImageData(gi); err == nil && data != "" {
|
||||
parts = append(parts, map[string]any{
|
||||
"inlineData": map[string]any{
|
||||
"mimeType": mime,
|
||||
"data": data,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
promptTokens := estimateTokens(promptText)
|
||||
completionTokens := estimateTokens(finalText)
|
||||
thoughtsTokens := 0
|
||||
if thoughtsText != "" {
|
||||
thoughtsTokens = estimateTokens(thoughtsText)
|
||||
}
|
||||
totalTokens := promptTokens + completionTokens
|
||||
|
||||
now := time.Now()
|
||||
resp := map[string]any{
|
||||
"candidates": []any{
|
||||
map[string]any{
|
||||
"content": map[string]any{
|
||||
"parts": parts,
|
||||
"role": "model",
|
||||
},
|
||||
"finishReason": "stop",
|
||||
"index": 0,
|
||||
},
|
||||
},
|
||||
"createTime": now.Format(time.RFC3339Nano),
|
||||
"responseId": fmt.Sprintf("gemini-web-%d", now.UnixNano()),
|
||||
"modelVersion": modelName,
|
||||
"usageMetadata": map[string]any{
|
||||
"promptTokenCount": promptTokens,
|
||||
"candidatesTokenCount": completionTokens,
|
||||
"thoughtsTokenCount": thoughtsTokens,
|
||||
"totalTokenCount": totalTokens,
|
||||
},
|
||||
}
|
||||
b, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal gemini response: %w", err)
|
||||
}
|
||||
return ensureColonSpacing(b), nil
|
||||
}
|
||||
|
||||
// ensureColonSpacing inserts a single space after JSON key-value colons while
|
||||
// leaving string content untouched. This matches the relaxed formatting used by
|
||||
// Gemini responses and keeps downstream text-processing tools compatible with
|
||||
// the proxy output.
|
||||
func ensureColonSpacing(b []byte) []byte {
|
||||
if len(b) == 0 {
|
||||
return b
|
||||
}
|
||||
var out bytes.Buffer
|
||||
out.Grow(len(b) + len(b)/8)
|
||||
inString := false
|
||||
escaped := false
|
||||
for i := 0; i < len(b); i++ {
|
||||
ch := b[i]
|
||||
out.WriteByte(ch)
|
||||
if escaped {
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
switch ch {
|
||||
case '\\':
|
||||
escaped = true
|
||||
case '"':
|
||||
inString = !inString
|
||||
case ':':
|
||||
if !inString && i+1 < len(b) {
|
||||
next := b[i+1]
|
||||
if next != ' ' && next != '\n' && next != '\r' && next != '\t' {
|
||||
out.WriteByte(' ')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out.Bytes()
|
||||
}
|
||||
@@ -1,253 +0,0 @@
|
||||
package geminiwebapi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
conversation "github.com/router-for-me/CLIProxyAPI/v6/internal/provider/gemini-web/conversation"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
|
||||
)
|
||||
|
||||
// Gemini web endpoints and default headers ----------------------------------
|
||||
const (
|
||||
EndpointGoogle = "https://www.google.com"
|
||||
EndpointInit = "https://gemini.google.com/app"
|
||||
EndpointGenerate = "https://gemini.google.com/_/BardChatUi/data/assistant.lamda.BardFrontendService/StreamGenerate"
|
||||
EndpointRotateCookies = "https://accounts.google.com/RotateCookies"
|
||||
EndpointUpload = "https://content-push.googleapis.com/upload"
|
||||
)
|
||||
|
||||
var (
|
||||
HeadersGemini = http.Header{
|
||||
"Content-Type": []string{"application/x-www-form-urlencoded;charset=utf-8"},
|
||||
"Host": []string{"gemini.google.com"},
|
||||
"Origin": []string{"https://gemini.google.com"},
|
||||
"Referer": []string{"https://gemini.google.com/"},
|
||||
"User-Agent": []string{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"},
|
||||
"X-Same-Domain": []string{"1"},
|
||||
}
|
||||
HeadersRotateCookies = http.Header{
|
||||
"Content-Type": []string{"application/json"},
|
||||
}
|
||||
HeadersUpload = http.Header{
|
||||
"Push-ID": []string{"feeds/mcudyrk2a4khkz"},
|
||||
}
|
||||
)
|
||||
|
||||
// Model metadata -------------------------------------------------------------
|
||||
type Model struct {
|
||||
Name string
|
||||
ModelHeader http.Header
|
||||
AdvancedOnly bool
|
||||
}
|
||||
|
||||
var (
|
||||
ModelUnspecified = Model{
|
||||
Name: "unspecified",
|
||||
ModelHeader: http.Header{},
|
||||
AdvancedOnly: false,
|
||||
}
|
||||
ModelG25Flash = Model{
|
||||
Name: "gemini-2.5-flash",
|
||||
ModelHeader: http.Header{
|
||||
"x-goog-ext-525001261-jspb": []string{"[1,null,null,null,\"71c2d248d3b102ff\",null,null,0,[4]]"},
|
||||
},
|
||||
AdvancedOnly: false,
|
||||
}
|
||||
ModelG25Pro = Model{
|
||||
Name: "gemini-2.5-pro",
|
||||
ModelHeader: http.Header{
|
||||
"x-goog-ext-525001261-jspb": []string{"[1,null,null,null,\"4af6c7f5da75d65d\",null,null,0,[4]]"},
|
||||
},
|
||||
AdvancedOnly: false,
|
||||
}
|
||||
ModelG20Flash = Model{
|
||||
Name: "gemini-2.0-flash",
|
||||
ModelHeader: http.Header{
|
||||
"x-goog-ext-525001261-jspb": []string{"[1,null,null,null,\"f299729663a2343f\"]"},
|
||||
},
|
||||
AdvancedOnly: false,
|
||||
}
|
||||
ModelG20FlashThinking = Model{
|
||||
Name: "gemini-2.0-flash-thinking",
|
||||
ModelHeader: http.Header{
|
||||
"x-goog-ext-525001261-jspb": []string{"[null,null,null,null,\"7ca48d02d802f20a\"]"},
|
||||
},
|
||||
AdvancedOnly: false,
|
||||
}
|
||||
)
|
||||
|
||||
func ModelFromName(name string) (Model, error) {
|
||||
switch name {
|
||||
case ModelUnspecified.Name:
|
||||
return ModelUnspecified, nil
|
||||
case ModelG25Flash.Name:
|
||||
return ModelG25Flash, nil
|
||||
case ModelG25Pro.Name:
|
||||
return ModelG25Pro, nil
|
||||
case ModelG20Flash.Name:
|
||||
return ModelG20Flash, nil
|
||||
case ModelG20FlashThinking.Name:
|
||||
return ModelG20FlashThinking, nil
|
||||
default:
|
||||
return Model{}, &ValueError{Msg: "Unknown model name: " + name}
|
||||
}
|
||||
}
|
||||
|
||||
// Known error codes returned from the server.
|
||||
const (
|
||||
ErrorUsageLimitExceeded = 1037
|
||||
ErrorModelInconsistent = 1050
|
||||
ErrorModelHeaderInvalid = 1052
|
||||
ErrorIPTemporarilyBlocked = 1060
|
||||
)
|
||||
|
||||
func EnsureGeminiWebAliasMap() { conversation.EnsureGeminiWebAliasMap() }
|
||||
|
||||
func GetGeminiWebAliasedModels() []*registry.ModelInfo {
|
||||
return conversation.GetGeminiWebAliasedModels()
|
||||
}
|
||||
|
||||
func MapAliasToUnderlying(name string) string { return conversation.MapAliasToUnderlying(name) }
|
||||
|
||||
func AliasFromModelID(modelID string) string { return conversation.AliasFromModelID(modelID) }
|
||||
|
||||
// Conversation domain structures -------------------------------------------
|
||||
type RoleText = conversation.Message
|
||||
|
||||
type StoredMessage = conversation.StoredMessage
|
||||
|
||||
type ConversationRecord struct {
|
||||
Model string `json:"model"`
|
||||
ClientID string `json:"client_id"`
|
||||
Metadata []string `json:"metadata,omitempty"`
|
||||
Messages []StoredMessage `json:"messages"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Candidate struct {
|
||||
RCID string
|
||||
Text string
|
||||
Thoughts *string
|
||||
WebImages []WebImage
|
||||
GeneratedImages []GeneratedImage
|
||||
}
|
||||
|
||||
func (c Candidate) String() string {
|
||||
t := c.Text
|
||||
if len(t) > 20 {
|
||||
t = t[:20] + "..."
|
||||
}
|
||||
return fmt.Sprintf("Candidate(rcid='%s', text='%s', images=%d)", c.RCID, t, len(c.WebImages)+len(c.GeneratedImages))
|
||||
}
|
||||
|
||||
func (c Candidate) Images() []Image {
|
||||
images := make([]Image, 0, len(c.WebImages)+len(c.GeneratedImages))
|
||||
for _, wi := range c.WebImages {
|
||||
images = append(images, wi.Image)
|
||||
}
|
||||
for _, gi := range c.GeneratedImages {
|
||||
images = append(images, gi.Image)
|
||||
}
|
||||
return images
|
||||
}
|
||||
|
||||
type ModelOutput struct {
|
||||
Metadata []string
|
||||
Candidates []Candidate
|
||||
Chosen int
|
||||
}
|
||||
|
||||
func (m ModelOutput) String() string { return m.Text() }
|
||||
|
||||
func (m ModelOutput) Text() string {
|
||||
if len(m.Candidates) == 0 {
|
||||
return ""
|
||||
}
|
||||
return m.Candidates[m.Chosen].Text
|
||||
}
|
||||
|
||||
func (m ModelOutput) Thoughts() *string {
|
||||
if len(m.Candidates) == 0 {
|
||||
return nil
|
||||
}
|
||||
return m.Candidates[m.Chosen].Thoughts
|
||||
}
|
||||
|
||||
func (m ModelOutput) Images() []Image {
|
||||
if len(m.Candidates) == 0 {
|
||||
return nil
|
||||
}
|
||||
return m.Candidates[m.Chosen].Images()
|
||||
}
|
||||
|
||||
func (m ModelOutput) RCID() string {
|
||||
if len(m.Candidates) == 0 {
|
||||
return ""
|
||||
}
|
||||
return m.Candidates[m.Chosen].RCID
|
||||
}
|
||||
|
||||
type Gem struct {
|
||||
ID string
|
||||
Name string
|
||||
Description *string
|
||||
Prompt *string
|
||||
Predefined bool
|
||||
}
|
||||
|
||||
func (g Gem) String() string {
|
||||
return fmt.Sprintf("Gem(id='%s', name='%s', description='%v', prompt='%v', predefined=%v)", g.ID, g.Name, g.Description, g.Prompt, g.Predefined)
|
||||
}
|
||||
|
||||
func decodeHTML(s string) string { return html.UnescapeString(s) }
|
||||
|
||||
// Error hierarchy -----------------------------------------------------------
|
||||
type AuthError struct{ Msg string }
|
||||
|
||||
func (e *AuthError) Error() string {
|
||||
if e.Msg == "" {
|
||||
return "authentication error"
|
||||
}
|
||||
return e.Msg
|
||||
}
|
||||
|
||||
type APIError struct{ Msg string }
|
||||
|
||||
func (e *APIError) Error() string {
|
||||
if e.Msg == "" {
|
||||
return "api error"
|
||||
}
|
||||
return e.Msg
|
||||
}
|
||||
|
||||
type ImageGenerationError struct{ APIError }
|
||||
|
||||
type GeminiError struct{ Msg string }
|
||||
|
||||
func (e *GeminiError) Error() string {
|
||||
if e.Msg == "" {
|
||||
return "gemini error"
|
||||
}
|
||||
return e.Msg
|
||||
}
|
||||
|
||||
type TimeoutError struct{ GeminiError }
|
||||
|
||||
type UsageLimitExceeded struct{ GeminiError }
|
||||
|
||||
type ModelInvalid struct{ GeminiError }
|
||||
|
||||
type TemporarilyBlocked struct{ GeminiError }
|
||||
|
||||
type ValueError struct{ Msg string }
|
||||
|
||||
func (e *ValueError) Error() string {
|
||||
if e.Msg == "" {
|
||||
return "value error"
|
||||
}
|
||||
return e.Msg
|
||||
}
|
||||
@@ -1,220 +0,0 @@
|
||||
package geminiwebapi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
conversation "github.com/router-for-me/CLIProxyAPI/v6/internal/provider/gemini-web/conversation"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
var (
|
||||
reXMLAnyTag = regexp.MustCompile(`(?s)<\s*[^>]+>`)
|
||||
)
|
||||
|
||||
// NormalizeRole converts a role to a standard format (lowercase, 'model' -> 'assistant').
|
||||
func NormalizeRole(role string) string {
|
||||
r := strings.ToLower(role)
|
||||
if r == "model" {
|
||||
return "assistant"
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// NeedRoleTags checks if a list of messages requires role tags.
|
||||
func NeedRoleTags(msgs []RoleText) bool {
|
||||
for _, m := range msgs {
|
||||
if strings.ToLower(m.Role) != "user" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// AddRoleTag wraps content with a role tag.
|
||||
func AddRoleTag(role, content string, unclose bool) string {
|
||||
if role == "" {
|
||||
role = "user"
|
||||
}
|
||||
if unclose {
|
||||
return "<|im_start|>" + role + "\n" + content
|
||||
}
|
||||
return "<|im_start|>" + role + "\n" + content + "\n<|im_end|>"
|
||||
}
|
||||
|
||||
// BuildPrompt constructs the final prompt from a list of messages.
|
||||
func BuildPrompt(msgs []RoleText, tagged bool, appendAssistant bool) string {
|
||||
if len(msgs) == 0 {
|
||||
if tagged && appendAssistant {
|
||||
return AddRoleTag("assistant", "", true)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if !tagged {
|
||||
var sb strings.Builder
|
||||
for i, m := range msgs {
|
||||
if i > 0 {
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
sb.WriteString(m.Text)
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
var sb strings.Builder
|
||||
for _, m := range msgs {
|
||||
sb.WriteString(AddRoleTag(m.Role, m.Text, false))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
if appendAssistant {
|
||||
sb.WriteString(AddRoleTag("assistant", "", true))
|
||||
}
|
||||
return strings.TrimSpace(sb.String())
|
||||
}
|
||||
|
||||
// RemoveThinkTags strips <think>...</think> blocks from a string.
|
||||
func RemoveThinkTags(s string) string {
|
||||
return conversation.RemoveThinkTags(s)
|
||||
}
|
||||
|
||||
// SanitizeAssistantMessages removes think tags from assistant messages.
|
||||
func SanitizeAssistantMessages(msgs []RoleText) []RoleText {
|
||||
cleaned := conversation.SanitizeAssistantMessages(msgs)
|
||||
return cleaned
|
||||
}
|
||||
|
||||
// AppendXMLWrapHintIfNeeded appends an XML wrap hint to messages containing XML-like blocks.
|
||||
func AppendXMLWrapHintIfNeeded(msgs []RoleText, disable bool) []RoleText {
|
||||
if disable {
|
||||
return msgs
|
||||
}
|
||||
const xmlWrapHint = "\nFor any xml block, e.g. tool call, always wrap it with: \n`````xml\n...\n`````\n"
|
||||
out := make([]RoleText, 0, len(msgs))
|
||||
for _, m := range msgs {
|
||||
t := m.Text
|
||||
if reXMLAnyTag.MatchString(t) {
|
||||
t = t + xmlWrapHint
|
||||
}
|
||||
out = append(out, RoleText{Role: m.Role, Text: t})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// EstimateTotalTokensFromRawJSON estimates token count by summing text parts.
|
||||
func EstimateTotalTokensFromRawJSON(rawJSON []byte) int {
|
||||
totalChars := 0
|
||||
contents := gjson.GetBytes(rawJSON, "contents")
|
||||
if contents.Exists() {
|
||||
contents.ForEach(func(_, content gjson.Result) bool {
|
||||
content.Get("parts").ForEach(func(_, part gjson.Result) bool {
|
||||
if t := part.Get("text"); t.Exists() {
|
||||
totalChars += utf8.RuneCountInString(t.String())
|
||||
}
|
||||
return true
|
||||
})
|
||||
return true
|
||||
})
|
||||
}
|
||||
if totalChars <= 0 {
|
||||
return 0
|
||||
}
|
||||
return int(math.Ceil(float64(totalChars) / 4.0))
|
||||
}
|
||||
|
||||
// Request chunking helpers ------------------------------------------------
|
||||
|
||||
const continuationHint = "\n(More messages to come, please reply with just 'ok.')"
|
||||
|
||||
func ChunkByRunes(s string, size int) []string {
|
||||
if size <= 0 {
|
||||
return []string{s}
|
||||
}
|
||||
chunks := make([]string, 0, (len(s)/size)+1)
|
||||
var buf strings.Builder
|
||||
count := 0
|
||||
for _, r := range s {
|
||||
buf.WriteRune(r)
|
||||
count++
|
||||
if count >= size {
|
||||
chunks = append(chunks, buf.String())
|
||||
buf.Reset()
|
||||
count = 0
|
||||
}
|
||||
}
|
||||
if buf.Len() > 0 {
|
||||
chunks = append(chunks, buf.String())
|
||||
}
|
||||
if len(chunks) == 0 {
|
||||
return []string{""}
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
func MaxCharsPerRequest(cfg *config.Config) int {
|
||||
// Read max characters per request from config with a conservative default.
|
||||
if cfg != nil {
|
||||
if v := cfg.GeminiWeb.MaxCharsPerRequest; v > 0 {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return 1_000_000
|
||||
}
|
||||
|
||||
func SendWithSplit(chat *ChatSession, text string, files []string, cfg *config.Config) (ModelOutput, error) {
|
||||
// Validate chat session
|
||||
if chat == nil {
|
||||
return ModelOutput{}, fmt.Errorf("nil chat session")
|
||||
}
|
||||
|
||||
// Resolve maxChars characters per request
|
||||
maxChars := MaxCharsPerRequest(cfg)
|
||||
if maxChars <= 0 {
|
||||
maxChars = 1_000_000
|
||||
}
|
||||
|
||||
// If within limit, send directly
|
||||
if utf8.RuneCountInString(text) <= maxChars {
|
||||
return chat.SendMessage(text, files)
|
||||
}
|
||||
|
||||
// Decide whether to use continuation hint (enabled by default)
|
||||
useHint := true
|
||||
if cfg != nil && cfg.GeminiWeb.DisableContinuationHint {
|
||||
useHint = false
|
||||
}
|
||||
|
||||
// Compute chunk size in runes. If the hint does not fit, disable it for this request.
|
||||
hintLen := 0
|
||||
if useHint {
|
||||
hintLen = utf8.RuneCountInString(continuationHint)
|
||||
}
|
||||
chunkSize := maxChars - hintLen
|
||||
if chunkSize <= 0 {
|
||||
// maxChars is too small to accommodate the hint; fall back to no-hint splitting
|
||||
useHint = false
|
||||
chunkSize = maxChars
|
||||
}
|
||||
|
||||
// Split into rune-safe chunks
|
||||
chunks := ChunkByRunes(text, chunkSize)
|
||||
if len(chunks) == 0 {
|
||||
chunks = []string{""}
|
||||
}
|
||||
|
||||
// Send all but the last chunk without files, optionally appending hint
|
||||
for i := 0; i < len(chunks)-1; i++ {
|
||||
part := chunks[i]
|
||||
if useHint {
|
||||
part += continuationHint
|
||||
}
|
||||
if _, err := chat.SendMessage(part, nil); err != nil {
|
||||
return ModelOutput{}, err
|
||||
}
|
||||
}
|
||||
|
||||
// Send final chunk with files and return the actual output
|
||||
return chat.SendMessage(chunks[len(chunks)-1], files)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -104,6 +104,34 @@ func GetGeminiModels() []*ModelInfo {
|
||||
OutputTokenLimit: 65536,
|
||||
SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"},
|
||||
},
|
||||
{
|
||||
ID: "gemini-2.5-flash-image-preview",
|
||||
Object: "model",
|
||||
Created: time.Now().Unix(),
|
||||
OwnedBy: "google",
|
||||
Type: "gemini",
|
||||
Name: "models/gemini-2.5-flash-image-preview",
|
||||
Version: "2.5",
|
||||
DisplayName: "Gemini 2.5 Flash Image Preview",
|
||||
Description: "State-of-the-art image generation and editing model.",
|
||||
InputTokenLimit: 1048576,
|
||||
OutputTokenLimit: 8192,
|
||||
SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"},
|
||||
},
|
||||
{
|
||||
ID: "gemini-2.5-flash-image",
|
||||
Object: "model",
|
||||
Created: time.Now().Unix(),
|
||||
OwnedBy: "google",
|
||||
Type: "gemini",
|
||||
Name: "models/gemini-2.5-flash-image",
|
||||
Version: "2.5",
|
||||
DisplayName: "Gemini 2.5 Flash Image",
|
||||
Description: "State-of-the-art image generation and editing model.",
|
||||
InputTokenLimit: 1048576,
|
||||
OutputTokenLimit: 8192,
|
||||
SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,6 +180,34 @@ func GetGeminiCLIModels() []*ModelInfo {
|
||||
OutputTokenLimit: 65536,
|
||||
SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"},
|
||||
},
|
||||
{
|
||||
ID: "gemini-2.5-flash-image-preview",
|
||||
Object: "model",
|
||||
Created: time.Now().Unix(),
|
||||
OwnedBy: "google",
|
||||
Type: "gemini",
|
||||
Name: "models/gemini-2.5-flash-image-preview",
|
||||
Version: "2.5",
|
||||
DisplayName: "Gemini 2.5 Flash Image Preview",
|
||||
Description: "State-of-the-art image generation and editing model.",
|
||||
InputTokenLimit: 1048576,
|
||||
OutputTokenLimit: 8192,
|
||||
SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"},
|
||||
},
|
||||
{
|
||||
ID: "gemini-2.5-flash-image",
|
||||
Object: "model",
|
||||
Created: time.Now().Unix(),
|
||||
OwnedBy: "google",
|
||||
Type: "gemini",
|
||||
Name: "models/gemini-2.5-flash-image",
|
||||
Version: "2.5",
|
||||
DisplayName: "Gemini 2.5 Flash Image",
|
||||
Description: "State-of-the-art image generation and editing model.",
|
||||
InputTokenLimit: 1048576,
|
||||
OutputTokenLimit: 8192,
|
||||
SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -340,6 +396,7 @@ func GetIFlowModels() []*ModelInfo {
|
||||
{ID: "qwen3-max-preview", DisplayName: "Qwen3-Max-Preview", Description: "Qwen3 Max preview build"},
|
||||
{ID: "kimi-k2-0905", DisplayName: "Kimi-K2-Instruct-0905", Description: "Moonshot Kimi K2 instruct 0905"},
|
||||
{ID: "glm-4.5", DisplayName: "GLM-4.5", Description: "Zhipu GLM 4.5 general model"},
|
||||
{ID: "glm-4.6", DisplayName: "GLM-4.6", Description: "Zhipu GLM 4.6 general model"},
|
||||
{ID: "kimi-k2", DisplayName: "Kimi-K2", Description: "Moonshot Kimi K2 general model"},
|
||||
{ID: "deepseek-v3.2", DisplayName: "DeepSeek-V3.2-Exp", Description: "DeepSeek V3.2 experimental"},
|
||||
{ID: "deepseek-v3.1", DisplayName: "DeepSeek-V3.1-Terminus", Description: "DeepSeek V3.1 Terminus"},
|
||||
|
||||
@@ -144,8 +144,8 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
|
||||
defer close(out)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
buf := make([]byte, 1024*1024)
|
||||
scanner.Buffer(buf, 1024*1024)
|
||||
buf := make([]byte, 20_971_520)
|
||||
scanner.Buffer(buf, 20_971_520)
|
||||
var param any
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
|
||||
@@ -189,8 +189,8 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
|
||||
defer close(out)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
buf := make([]byte, 1024*1024)
|
||||
scanner.Buffer(buf, 1024*1024)
|
||||
buf := make([]byte, 20_971_520)
|
||||
scanner.Buffer(buf, 20_971_520)
|
||||
var param any
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"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"
|
||||
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
||||
@@ -51,7 +52,7 @@ func (e *GeminiCLIExecutor) Identifier() string { return "gemini-cli" }
|
||||
func (e *GeminiCLIExecutor) PrepareRequest(_ *http.Request, _ *cliproxyauth.Auth) error { return nil }
|
||||
|
||||
func (e *GeminiCLIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
||||
tokenSource, baseTokenData, err := prepareGeminiCLITokenSource(ctx, auth)
|
||||
tokenSource, baseTokenData, err := prepareGeminiCLITokenSource(ctx, e.cfg, auth)
|
||||
if err != nil {
|
||||
return cliproxyexecutor.Response{}, err
|
||||
}
|
||||
@@ -60,6 +61,7 @@ func (e *GeminiCLIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth
|
||||
from := opts.SourceFormat
|
||||
to := sdktranslator.FromString("gemini-cli")
|
||||
basePayload := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
|
||||
basePayload = fixGeminiCLIImageAspectRatio(req.Model, basePayload)
|
||||
|
||||
action := "generateContent"
|
||||
if req.Metadata != nil {
|
||||
@@ -89,6 +91,7 @@ func (e *GeminiCLIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth
|
||||
payload = setJSONField(payload, "project", projectID)
|
||||
payload = setJSONField(payload, "model", attemptModel)
|
||||
}
|
||||
payload = disableGeminiThinkingConfig(payload, attemptModel)
|
||||
|
||||
tok, errTok := tokenSource.Token()
|
||||
if errTok != nil {
|
||||
@@ -138,7 +141,7 @@ func (e *GeminiCLIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth
|
||||
}
|
||||
|
||||
func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (<-chan cliproxyexecutor.StreamChunk, error) {
|
||||
tokenSource, baseTokenData, err := prepareGeminiCLITokenSource(ctx, auth)
|
||||
tokenSource, baseTokenData, err := prepareGeminiCLITokenSource(ctx, e.cfg, auth)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -147,6 +150,7 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut
|
||||
from := opts.SourceFormat
|
||||
to := sdktranslator.FromString("gemini-cli")
|
||||
basePayload := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
|
||||
basePayload = fixGeminiCLIImageAspectRatio(req.Model, basePayload)
|
||||
|
||||
projectID := strings.TrimSpace(stringValue(auth.Metadata, "project_id"))
|
||||
|
||||
@@ -165,6 +169,7 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut
|
||||
payload := append([]byte(nil), basePayload...)
|
||||
payload = setJSONField(payload, "project", projectID)
|
||||
payload = setJSONField(payload, "model", attemptModel)
|
||||
payload = disableGeminiThinkingConfig(payload, attemptModel)
|
||||
|
||||
tok, errTok := tokenSource.Token()
|
||||
if errTok != nil {
|
||||
@@ -212,8 +217,8 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if opts.Alt == "" {
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
buf := make([]byte, 1024*1024)
|
||||
scanner.Buffer(buf, 1024*1024)
|
||||
buf := make([]byte, 20_971_520)
|
||||
scanner.Buffer(buf, 20_971_520)
|
||||
var param any
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
@@ -268,7 +273,7 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut
|
||||
}
|
||||
|
||||
func (e *GeminiCLIExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
||||
tokenSource, baseTokenData, err := prepareGeminiCLITokenSource(ctx, auth)
|
||||
tokenSource, baseTokenData, err := prepareGeminiCLITokenSource(ctx, e.cfg, auth)
|
||||
if err != nil {
|
||||
return cliproxyexecutor.Response{}, err
|
||||
}
|
||||
@@ -291,6 +296,8 @@ func (e *GeminiCLIExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.
|
||||
payload := sdktranslator.TranslateRequest(from, to, attemptModel, bytes.Clone(req.Payload), false)
|
||||
payload = deleteJSONField(payload, "project")
|
||||
payload = deleteJSONField(payload, "model")
|
||||
payload = disableGeminiThinkingConfig(payload, attemptModel)
|
||||
payload = fixGeminiCLIImageAspectRatio(attemptModel, payload)
|
||||
|
||||
tok, errTok := tokenSource.Token()
|
||||
if errTok != nil {
|
||||
@@ -348,7 +355,7 @@ func (e *GeminiCLIExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth
|
||||
return auth, nil
|
||||
}
|
||||
|
||||
func prepareGeminiCLITokenSource(ctx context.Context, auth *cliproxyauth.Auth) (oauth2.TokenSource, map[string]any, error) {
|
||||
func prepareGeminiCLITokenSource(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth) (oauth2.TokenSource, map[string]any, error) {
|
||||
if auth == nil || auth.Metadata == nil {
|
||||
return nil, nil, fmt.Errorf("gemini-cli auth metadata missing")
|
||||
}
|
||||
@@ -392,8 +399,8 @@ func prepareGeminiCLITokenSource(ctx context.Context, auth *cliproxyauth.Auth) (
|
||||
}
|
||||
|
||||
ctxToken := ctx
|
||||
if rt, ok := ctx.Value("cliproxy.roundtripper").(http.RoundTripper); ok && rt != nil {
|
||||
ctxToken = context.WithValue(ctxToken, oauth2.HTTPClient, &http.Client{Transport: rt})
|
||||
if httpClient := newProxyAwareHTTPClient(ctx, cfg, auth, 0); httpClient != nil {
|
||||
ctxToken = context.WithValue(ctxToken, oauth2.HTTPClient, httpClient)
|
||||
}
|
||||
|
||||
src := conf.TokenSource(ctxToken, &token)
|
||||
@@ -500,6 +507,29 @@ func cliPreviewFallbackOrder(model string) []string {
|
||||
}
|
||||
}
|
||||
|
||||
func disableGeminiThinkingConfig(body []byte, model string) []byte {
|
||||
if !geminiModelDisallowsThinking(model) {
|
||||
return body
|
||||
}
|
||||
|
||||
updated := deleteJSONField(body, "request.generationConfig.thinkingConfig")
|
||||
updated = deleteJSONField(updated, "generationConfig.thinkingConfig")
|
||||
return updated
|
||||
}
|
||||
|
||||
func geminiModelDisallowsThinking(model string) bool {
|
||||
if model == "" {
|
||||
return false
|
||||
}
|
||||
lower := strings.ToLower(model)
|
||||
for _, marker := range []string{"gemini-2.5-flash-image-preview", "gemini-2.5-flash-image"} {
|
||||
if strings.Contains(lower, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// setJSONField sets a top-level JSON field on a byte slice payload via sjson.
|
||||
func setJSONField(body []byte, key, value string) []byte {
|
||||
if key == "" {
|
||||
@@ -523,3 +553,45 @@ func deleteJSONField(body []byte, key string) []byte {
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
func fixGeminiCLIImageAspectRatio(modelName string, rawJSON []byte) []byte {
|
||||
if modelName == "gemini-2.5-flash-image-preview" {
|
||||
aspectRatioResult := gjson.GetBytes(rawJSON, "request.generationConfig.imageConfig.aspectRatio")
|
||||
if aspectRatioResult.Exists() {
|
||||
contents := gjson.GetBytes(rawJSON, "request.contents")
|
||||
contentArray := contents.Array()
|
||||
if len(contentArray) > 0 {
|
||||
hasInlineData := false
|
||||
loopContent:
|
||||
for i := 0; i < len(contentArray); i++ {
|
||||
parts := contentArray[i].Get("parts").Array()
|
||||
for j := 0; j < len(parts); j++ {
|
||||
if parts[j].Get("inlineData").Exists() {
|
||||
hasInlineData = true
|
||||
break loopContent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !hasInlineData {
|
||||
emptyImageBase64ed, _ := util.CreateWhiteImageBase64(aspectRatioResult.String())
|
||||
emptyImagePart := `{"inlineData":{"mime_type":"image/png","data":""}}`
|
||||
emptyImagePart, _ = sjson.Set(emptyImagePart, "inlineData.data", emptyImageBase64ed)
|
||||
newPartsJson := `[]`
|
||||
newPartsJson, _ = sjson.SetRaw(newPartsJson, "-1", `{"text": "Based on the following requirements, create an image within the uploaded picture. The new content *MUST* completely cover the entire area of the original picture, maintaining its exact proportions, and *NO* blank areas should appear."}`)
|
||||
newPartsJson, _ = sjson.SetRaw(newPartsJson, "-1", emptyImagePart)
|
||||
|
||||
parts := contentArray[0].Get("parts").Array()
|
||||
for j := 0; j < len(parts); j++ {
|
||||
newPartsJson, _ = sjson.SetRaw(newPartsJson, "-1", parts[j].Raw)
|
||||
}
|
||||
|
||||
rawJSON, _ = sjson.SetRawBytes(rawJSON, "request.contents.0.parts", []byte(newPartsJson))
|
||||
rawJSON, _ = sjson.SetRawBytes(rawJSON, "request.generationConfig.responseModalities", []byte(`["Image", "Text"]`))
|
||||
}
|
||||
}
|
||||
rawJSON, _ = sjson.DeleteBytes(rawJSON, "request.generationConfig.imageConfig")
|
||||
}
|
||||
}
|
||||
return rawJSON
|
||||
}
|
||||
|
||||
@@ -77,6 +77,8 @@ func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
|
||||
from := opts.SourceFormat
|
||||
to := sdktranslator.FromString("gemini")
|
||||
body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
|
||||
body = disableGeminiThinkingConfig(body, req.Model)
|
||||
body = fixGeminiImageAspectRatio(req.Model, body)
|
||||
|
||||
action := "generateContent"
|
||||
if req.Metadata != nil {
|
||||
@@ -134,6 +136,8 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
|
||||
from := opts.SourceFormat
|
||||
to := sdktranslator.FromString("gemini")
|
||||
body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
|
||||
body = disableGeminiThinkingConfig(body, req.Model)
|
||||
body = fixGeminiImageAspectRatio(req.Model, body)
|
||||
|
||||
url := fmt.Sprintf("%s/%s/models/%s:%s", glEndpoint, glAPIVersion, req.Model, "streamGenerateContent")
|
||||
if opts.Alt == "" {
|
||||
@@ -173,8 +177,8 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
|
||||
defer close(out)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
buf := make([]byte, 1024*1024)
|
||||
scanner.Buffer(buf, 1024*1024)
|
||||
buf := make([]byte, 20_971_520)
|
||||
scanner.Buffer(buf, 20_971_520)
|
||||
var param any
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
@@ -204,6 +208,8 @@ func (e *GeminiExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut
|
||||
from := opts.SourceFormat
|
||||
to := sdktranslator.FromString("gemini")
|
||||
translatedReq := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
|
||||
translatedReq = disableGeminiThinkingConfig(translatedReq, req.Model)
|
||||
translatedReq = fixGeminiImageAspectRatio(req.Model, translatedReq)
|
||||
respCtx := context.WithValue(ctx, "alt", opts.Alt)
|
||||
translatedReq, _ = sjson.DeleteBytes(translatedReq, "tools")
|
||||
translatedReq, _ = sjson.DeleteBytes(translatedReq, "generationConfig")
|
||||
@@ -371,3 +377,45 @@ func geminiCreds(a *cliproxyauth.Auth) (apiKey, bearer string) {
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func fixGeminiImageAspectRatio(modelName string, rawJSON []byte) []byte {
|
||||
if modelName == "gemini-2.5-flash-image-preview" {
|
||||
aspectRatioResult := gjson.GetBytes(rawJSON, "generationConfig.imageConfig.aspectRatio")
|
||||
if aspectRatioResult.Exists() {
|
||||
contents := gjson.GetBytes(rawJSON, "contents")
|
||||
contentArray := contents.Array()
|
||||
if len(contentArray) > 0 {
|
||||
hasInlineData := false
|
||||
loopContent:
|
||||
for i := 0; i < len(contentArray); i++ {
|
||||
parts := contentArray[i].Get("parts").Array()
|
||||
for j := 0; j < len(parts); j++ {
|
||||
if parts[j].Get("inlineData").Exists() {
|
||||
hasInlineData = true
|
||||
break loopContent
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !hasInlineData {
|
||||
emptyImageBase64ed, _ := util.CreateWhiteImageBase64(aspectRatioResult.String())
|
||||
emptyImagePart := `{"inlineData":{"mime_type":"image/png","data":""}}`
|
||||
emptyImagePart, _ = sjson.Set(emptyImagePart, "inlineData.data", emptyImageBase64ed)
|
||||
newPartsJson := `[]`
|
||||
newPartsJson, _ = sjson.SetRaw(newPartsJson, "-1", `{"text": "Based on the following requirements, create an image within the uploaded picture. The new content *MUST* completely cover the entire area of the original picture, maintaining its exact proportions, and *NO* blank areas should appear."}`)
|
||||
newPartsJson, _ = sjson.SetRaw(newPartsJson, "-1", emptyImagePart)
|
||||
|
||||
parts := contentArray[0].Get("parts").Array()
|
||||
for j := 0; j < len(parts); j++ {
|
||||
newPartsJson, _ = sjson.SetRaw(newPartsJson, "-1", parts[j].Raw)
|
||||
}
|
||||
|
||||
rawJSON, _ = sjson.SetRawBytes(rawJSON, "contents.0.parts", []byte(newPartsJson))
|
||||
rawJSON, _ = sjson.SetRawBytes(rawJSON, "generationConfig.responseModalities", []byte(`["Image", "Text"]`))
|
||||
}
|
||||
}
|
||||
rawJSON, _ = sjson.DeleteBytes(rawJSON, "generationConfig.imageConfig")
|
||||
}
|
||||
}
|
||||
return rawJSON
|
||||
}
|
||||
|
||||
@@ -1,276 +0,0 @@
|
||||
package executor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"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/interfaces"
|
||||
geminiwebapi "github.com/router-for-me/CLIProxyAPI/v6/internal/provider/gemini-web"
|
||||
conversation "github.com/router-for-me/CLIProxyAPI/v6/internal/provider/gemini-web/conversation"
|
||||
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
type GeminiWebExecutor struct {
|
||||
cfg *config.Config
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewGeminiWebExecutor(cfg *config.Config) *GeminiWebExecutor {
|
||||
return &GeminiWebExecutor{cfg: cfg}
|
||||
}
|
||||
|
||||
func (e *GeminiWebExecutor) Identifier() string { return "gemini-web" }
|
||||
|
||||
func (e *GeminiWebExecutor) PrepareRequest(_ *http.Request, _ *cliproxyauth.Auth) error { return nil }
|
||||
|
||||
func (e *GeminiWebExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
||||
state, err := e.stateFor(auth)
|
||||
if err != nil {
|
||||
return cliproxyexecutor.Response{}, err
|
||||
}
|
||||
if err = state.EnsureClient(); err != nil {
|
||||
return cliproxyexecutor.Response{}, err
|
||||
}
|
||||
match := extractGeminiWebMatch(opts.Metadata)
|
||||
reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
|
||||
|
||||
mutex := state.GetRequestMutex()
|
||||
if mutex != nil {
|
||||
mutex.Lock()
|
||||
defer mutex.Unlock()
|
||||
if match != nil {
|
||||
state.SetPendingMatch(match)
|
||||
}
|
||||
} else if match != nil {
|
||||
state.SetPendingMatch(match)
|
||||
}
|
||||
|
||||
payload := bytes.Clone(req.Payload)
|
||||
resp, errMsg, prep := state.Send(ctx, req.Model, payload, opts)
|
||||
if errMsg != nil {
|
||||
return cliproxyexecutor.Response{}, geminiWebErrorFromMessage(errMsg)
|
||||
}
|
||||
resp = state.ConvertToTarget(ctx, req.Model, prep, resp)
|
||||
reporter.publish(ctx, parseGeminiUsage(resp))
|
||||
|
||||
from := opts.SourceFormat
|
||||
to := sdktranslator.FromString("gemini-web")
|
||||
var param any
|
||||
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), payload, bytes.Clone(resp), ¶m)
|
||||
|
||||
return cliproxyexecutor.Response{Payload: []byte(out)}, nil
|
||||
}
|
||||
|
||||
func (e *GeminiWebExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (<-chan cliproxyexecutor.StreamChunk, error) {
|
||||
state, err := e.stateFor(auth)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = state.EnsureClient(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
match := extractGeminiWebMatch(opts.Metadata)
|
||||
reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
|
||||
|
||||
mutex := state.GetRequestMutex()
|
||||
if mutex != nil {
|
||||
mutex.Lock()
|
||||
if match != nil {
|
||||
state.SetPendingMatch(match)
|
||||
}
|
||||
}
|
||||
if mutex == nil && match != nil {
|
||||
state.SetPendingMatch(match)
|
||||
}
|
||||
|
||||
gemBytes, errMsg, prep := state.Send(ctx, req.Model, bytes.Clone(req.Payload), opts)
|
||||
if errMsg != nil {
|
||||
if mutex != nil {
|
||||
mutex.Unlock()
|
||||
}
|
||||
return nil, geminiWebErrorFromMessage(errMsg)
|
||||
}
|
||||
reporter.publish(ctx, parseGeminiUsage(gemBytes))
|
||||
|
||||
from := opts.SourceFormat
|
||||
to := sdktranslator.FromString("gemini-web")
|
||||
var param any
|
||||
|
||||
lines := state.ConvertStream(ctx, req.Model, prep, gemBytes)
|
||||
done := state.DoneStream(ctx, req.Model, prep)
|
||||
out := make(chan cliproxyexecutor.StreamChunk)
|
||||
go func() {
|
||||
defer close(out)
|
||||
if mutex != nil {
|
||||
defer mutex.Unlock()
|
||||
}
|
||||
for _, line := range lines {
|
||||
lines = sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), req.Payload, bytes.Clone([]byte(line)), ¶m)
|
||||
for _, l := range lines {
|
||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(l)}
|
||||
}
|
||||
}
|
||||
for _, line := range done {
|
||||
lines = sdktranslator.TranslateStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), req.Payload, bytes.Clone([]byte(line)), ¶m)
|
||||
for _, l := range lines {
|
||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(l)}
|
||||
}
|
||||
}
|
||||
}()
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (e *GeminiWebExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
||||
return cliproxyexecutor.Response{Payload: []byte{}}, fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
func (e *GeminiWebExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
|
||||
log.Debugf("gemini web executor: refresh called")
|
||||
state, err := e.stateFor(auth)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = state.Refresh(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ts := state.TokenSnapshot()
|
||||
if auth.Metadata == nil {
|
||||
auth.Metadata = make(map[string]any)
|
||||
}
|
||||
auth.Metadata["secure_1psid"] = ts.Secure1PSID
|
||||
auth.Metadata["secure_1psidts"] = ts.Secure1PSIDTS
|
||||
auth.Metadata["type"] = "gemini-web"
|
||||
auth.Metadata["last_refresh"] = time.Now().Format(time.RFC3339)
|
||||
if v, ok := auth.Metadata["label"].(string); !ok || strings.TrimSpace(v) == "" {
|
||||
if lbl := state.Label(); strings.TrimSpace(lbl) != "" {
|
||||
auth.Metadata["label"] = strings.TrimSpace(lbl)
|
||||
}
|
||||
}
|
||||
return auth, nil
|
||||
}
|
||||
|
||||
type geminiWebRuntime struct {
|
||||
state *geminiwebapi.GeminiWebState
|
||||
}
|
||||
|
||||
func (e *GeminiWebExecutor) stateFor(auth *cliproxyauth.Auth) (*geminiwebapi.GeminiWebState, error) {
|
||||
if auth == nil {
|
||||
return nil, fmt.Errorf("gemini-web executor: auth is nil")
|
||||
}
|
||||
if runtime, ok := auth.Runtime.(*geminiWebRuntime); ok && runtime != nil && runtime.state != nil {
|
||||
return runtime.state, nil
|
||||
}
|
||||
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
if runtime, ok := auth.Runtime.(*geminiWebRuntime); ok && runtime != nil && runtime.state != nil {
|
||||
return runtime.state, nil
|
||||
}
|
||||
|
||||
ts, err := parseGeminiWebToken(auth)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg := e.cfg
|
||||
if auth.ProxyURL != "" && cfg != nil {
|
||||
copyCfg := *cfg
|
||||
copyCfg.ProxyURL = auth.ProxyURL
|
||||
cfg = ©Cfg
|
||||
}
|
||||
|
||||
storagePath := ""
|
||||
if auth.Attributes != nil {
|
||||
if p, ok := auth.Attributes["path"]; ok {
|
||||
storagePath = p
|
||||
}
|
||||
}
|
||||
state := geminiwebapi.NewGeminiWebState(cfg, ts, storagePath, auth.Label)
|
||||
runtime := &geminiWebRuntime{state: state}
|
||||
auth.Runtime = runtime
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func parseGeminiWebToken(auth *cliproxyauth.Auth) (*gemini.GeminiWebTokenStorage, error) {
|
||||
if auth == nil {
|
||||
return nil, fmt.Errorf("gemini-web executor: auth is nil")
|
||||
}
|
||||
if auth.Metadata == nil {
|
||||
return nil, fmt.Errorf("gemini-web executor: missing metadata")
|
||||
}
|
||||
psid := stringFromMetadata(auth.Metadata, "secure_1psid", "secure_1psid", "__Secure-1PSID")
|
||||
psidts := stringFromMetadata(auth.Metadata, "secure_1psidts", "secure_1psidts", "__Secure-1PSIDTS")
|
||||
if psid == "" || psidts == "" {
|
||||
return nil, fmt.Errorf("gemini-web executor: incomplete cookie metadata")
|
||||
}
|
||||
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 {
|
||||
for _, key := range keys {
|
||||
if val, ok := meta[key]; ok {
|
||||
if s, okStr := val.(string); okStr && s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func geminiWebErrorFromMessage(msg *interfaces.ErrorMessage) error {
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
return geminiWebError{message: msg}
|
||||
}
|
||||
|
||||
type geminiWebError struct {
|
||||
message *interfaces.ErrorMessage
|
||||
}
|
||||
|
||||
func (e geminiWebError) Error() string {
|
||||
if e.message == nil {
|
||||
return "gemini-web error"
|
||||
}
|
||||
if e.message.Error != nil {
|
||||
return e.message.Error.Error()
|
||||
}
|
||||
return fmt.Sprintf("gemini-web error: status %d", e.message.StatusCode)
|
||||
}
|
||||
|
||||
func (e geminiWebError) StatusCode() int {
|
||||
if e.message == nil {
|
||||
return 0
|
||||
}
|
||||
return e.message.StatusCode
|
||||
}
|
||||
|
||||
func extractGeminiWebMatch(metadata map[string]any) *conversation.MatchResult {
|
||||
if metadata == nil {
|
||||
return nil
|
||||
}
|
||||
value, ok := metadata[conversation.MetadataMatchKey]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case *conversation.MatchResult:
|
||||
return v
|
||||
case conversation.MatchResult:
|
||||
return &v
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -141,8 +141,8 @@ func (e *IFlowExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
buf := make([]byte, 1024*1024)
|
||||
scanner.Buffer(buf, 1024*1024)
|
||||
buf := make([]byte, 20_971_520)
|
||||
scanner.Buffer(buf, 20_971_520)
|
||||
var param any
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
|
||||
@@ -133,8 +133,8 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy
|
||||
defer close(out)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
buf := make([]byte, 1024*1024)
|
||||
scanner.Buffer(buf, 1024*1024)
|
||||
buf := make([]byte, 20_971_520)
|
||||
scanner.Buffer(buf, 20_971_520)
|
||||
var param any
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
|
||||
@@ -126,8 +126,8 @@ func (e *QwenExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut
|
||||
defer close(out)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
buf := make([]byte, 1024*1024)
|
||||
scanner.Buffer(buf, 1024*1024)
|
||||
buf := make([]byte, 20_971_520)
|
||||
scanner.Buffer(buf, 20_971_520)
|
||||
var param any
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
|
||||
@@ -4,10 +4,12 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage"
|
||||
"github.com/tidwall/gjson"
|
||||
@@ -18,20 +20,23 @@ type usageReporter struct {
|
||||
model string
|
||||
authID string
|
||||
apiKey string
|
||||
source string
|
||||
requestedAt time.Time
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func newUsageReporter(ctx context.Context, provider, model string, auth *cliproxyauth.Auth) *usageReporter {
|
||||
apiKey := apiKeyFromContext(ctx)
|
||||
reporter := &usageReporter{
|
||||
provider: provider,
|
||||
model: model,
|
||||
requestedAt: time.Now(),
|
||||
apiKey: apiKey,
|
||||
source: util.HideAPIKey(resolveUsageSource(auth, apiKey)),
|
||||
}
|
||||
if auth != nil {
|
||||
reporter.authID = auth.ID
|
||||
}
|
||||
reporter.apiKey = apiKeyFromContext(ctx)
|
||||
return reporter
|
||||
}
|
||||
|
||||
@@ -52,6 +57,7 @@ func (r *usageReporter) publish(ctx context.Context, detail usage.Detail) {
|
||||
usage.PublishRecord(ctx, usage.Record{
|
||||
Provider: r.provider,
|
||||
Model: r.model,
|
||||
Source: r.source,
|
||||
APIKey: r.apiKey,
|
||||
AuthID: r.authID,
|
||||
RequestedAt: r.requestedAt,
|
||||
@@ -81,6 +87,30 @@ func apiKeyFromContext(ctx context.Context) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func resolveUsageSource(auth *cliproxyauth.Auth, ctxAPIKey string) string {
|
||||
if auth != nil {
|
||||
if _, value := auth.AccountInfo(); value != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
if auth.Metadata != nil {
|
||||
if email, ok := auth.Metadata["email"].(string); ok {
|
||||
if trimmed := strings.TrimSpace(email); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
}
|
||||
if auth.Attributes != nil {
|
||||
if key := strings.TrimSpace(auth.Attributes["api_key"]); key != "" {
|
||||
return key
|
||||
}
|
||||
}
|
||||
}
|
||||
if trimmed := strings.TrimSpace(ctxAPIKey); trimmed != "" {
|
||||
return trimmed
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseCodexUsage(data []byte) (usage.Detail, bool) {
|
||||
usageNode := gjson.ParseBytes(data).Get("response.usage")
|
||||
if !usageNode.Exists() {
|
||||
|
||||
749
internal/store/gitstore.go
Normal file
749
internal/store/gitstore.go
Normal file
@@ -0,0 +1,749 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-git/go-git/v6"
|
||||
"github.com/go-git/go-git/v6/config"
|
||||
"github.com/go-git/go-git/v6/plumbing"
|
||||
"github.com/go-git/go-git/v6/plumbing/object"
|
||||
"github.com/go-git/go-git/v6/plumbing/transport"
|
||||
"github.com/go-git/go-git/v6/plumbing/transport/http"
|
||||
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||
)
|
||||
|
||||
// GitTokenStore persists token records and auth metadata using git as the backing storage.
|
||||
type GitTokenStore struct {
|
||||
mu sync.Mutex
|
||||
dirLock sync.RWMutex
|
||||
baseDir string
|
||||
repoDir string
|
||||
configDir string
|
||||
remote string
|
||||
username string
|
||||
password string
|
||||
}
|
||||
|
||||
// NewGitTokenStore creates a token store that saves credentials to disk through the
|
||||
// TokenStorage implementation embedded in the token record.
|
||||
func NewGitTokenStore(remote, username, password string) *GitTokenStore {
|
||||
return &GitTokenStore{
|
||||
remote: remote,
|
||||
username: username,
|
||||
password: password,
|
||||
}
|
||||
}
|
||||
|
||||
// SetBaseDir updates the default directory used for auth JSON persistence when no explicit path is provided.
|
||||
func (s *GitTokenStore) SetBaseDir(dir string) {
|
||||
clean := strings.TrimSpace(dir)
|
||||
if clean == "" {
|
||||
s.dirLock.Lock()
|
||||
s.baseDir = ""
|
||||
s.repoDir = ""
|
||||
s.configDir = ""
|
||||
s.dirLock.Unlock()
|
||||
return
|
||||
}
|
||||
if abs, err := filepath.Abs(clean); err == nil {
|
||||
clean = abs
|
||||
}
|
||||
repoDir := filepath.Dir(clean)
|
||||
if repoDir == "" || repoDir == "." {
|
||||
repoDir = clean
|
||||
}
|
||||
configDir := filepath.Join(repoDir, "config")
|
||||
s.dirLock.Lock()
|
||||
s.baseDir = clean
|
||||
s.repoDir = repoDir
|
||||
s.configDir = configDir
|
||||
s.dirLock.Unlock()
|
||||
}
|
||||
|
||||
// AuthDir returns the directory used for auth persistence.
|
||||
func (s *GitTokenStore) AuthDir() string {
|
||||
return s.baseDirSnapshot()
|
||||
}
|
||||
|
||||
// ConfigPath returns the managed config file path.
|
||||
func (s *GitTokenStore) ConfigPath() string {
|
||||
s.dirLock.RLock()
|
||||
defer s.dirLock.RUnlock()
|
||||
if s.configDir == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(s.configDir, "config.yaml")
|
||||
}
|
||||
|
||||
// EnsureRepository prepares the local git working tree by cloning or opening the repository.
|
||||
func (s *GitTokenStore) EnsureRepository() error {
|
||||
s.dirLock.Lock()
|
||||
if s.remote == "" {
|
||||
s.dirLock.Unlock()
|
||||
return fmt.Errorf("git token store: remote not configured")
|
||||
}
|
||||
if s.baseDir == "" {
|
||||
s.dirLock.Unlock()
|
||||
return fmt.Errorf("git token store: base directory not configured")
|
||||
}
|
||||
repoDir := s.repoDir
|
||||
if repoDir == "" {
|
||||
repoDir = filepath.Dir(s.baseDir)
|
||||
if repoDir == "" || repoDir == "." {
|
||||
repoDir = s.baseDir
|
||||
}
|
||||
s.repoDir = repoDir
|
||||
}
|
||||
if s.configDir == "" {
|
||||
s.configDir = filepath.Join(repoDir, "config")
|
||||
}
|
||||
authDir := filepath.Join(repoDir, "auths")
|
||||
configDir := filepath.Join(repoDir, "config")
|
||||
gitDir := filepath.Join(repoDir, ".git")
|
||||
authMethod := s.gitAuth()
|
||||
var initPaths []string
|
||||
if _, err := os.Stat(gitDir); errors.Is(err, fs.ErrNotExist) {
|
||||
if errMk := os.MkdirAll(repoDir, 0o700); errMk != nil {
|
||||
s.dirLock.Unlock()
|
||||
return fmt.Errorf("git token store: create repo dir: %w", errMk)
|
||||
}
|
||||
if _, errClone := git.PlainClone(repoDir, &git.CloneOptions{Auth: authMethod, URL: s.remote}); errClone != nil {
|
||||
if errors.Is(errClone, transport.ErrEmptyRemoteRepository) {
|
||||
_ = os.RemoveAll(gitDir)
|
||||
repo, errInit := git.PlainInit(repoDir, false)
|
||||
if errInit != nil {
|
||||
s.dirLock.Unlock()
|
||||
return fmt.Errorf("git token store: init empty repo: %w", errInit)
|
||||
}
|
||||
if _, errRemote := repo.Remote("origin"); errRemote != nil {
|
||||
if _, errCreate := repo.CreateRemote(&config.RemoteConfig{
|
||||
Name: "origin",
|
||||
URLs: []string{s.remote},
|
||||
}); errCreate != nil && !errors.Is(errCreate, git.ErrRemoteExists) {
|
||||
s.dirLock.Unlock()
|
||||
return fmt.Errorf("git token store: configure remote: %w", errCreate)
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(authDir, 0o700); err != nil {
|
||||
s.dirLock.Unlock()
|
||||
return fmt.Errorf("git token store: create auth dir: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(configDir, 0o700); err != nil {
|
||||
s.dirLock.Unlock()
|
||||
return fmt.Errorf("git token store: create config dir: %w", err)
|
||||
}
|
||||
if err := ensureEmptyFile(filepath.Join(authDir, ".gitkeep")); err != nil {
|
||||
s.dirLock.Unlock()
|
||||
return fmt.Errorf("git token store: create auth placeholder: %w", err)
|
||||
}
|
||||
if err := ensureEmptyFile(filepath.Join(configDir, ".gitkeep")); err != nil {
|
||||
s.dirLock.Unlock()
|
||||
return fmt.Errorf("git token store: create config placeholder: %w", err)
|
||||
}
|
||||
initPaths = []string{
|
||||
filepath.Join("auths", ".gitkeep"),
|
||||
filepath.Join("config", ".gitkeep"),
|
||||
}
|
||||
} else {
|
||||
s.dirLock.Unlock()
|
||||
return fmt.Errorf("git token store: clone remote: %w", errClone)
|
||||
}
|
||||
}
|
||||
} else if err != nil {
|
||||
s.dirLock.Unlock()
|
||||
return fmt.Errorf("git token store: stat repo: %w", err)
|
||||
} else {
|
||||
repo, errOpen := git.PlainOpen(repoDir)
|
||||
if errOpen != nil {
|
||||
s.dirLock.Unlock()
|
||||
return fmt.Errorf("git token store: open repo: %w", errOpen)
|
||||
}
|
||||
worktree, errWorktree := repo.Worktree()
|
||||
if errWorktree != nil {
|
||||
s.dirLock.Unlock()
|
||||
return fmt.Errorf("git token store: worktree: %w", errWorktree)
|
||||
}
|
||||
if errPull := worktree.Pull(&git.PullOptions{Auth: authMethod, RemoteName: "origin"}); errPull != nil {
|
||||
switch {
|
||||
case errors.Is(errPull, git.NoErrAlreadyUpToDate),
|
||||
errors.Is(errPull, git.ErrUnstagedChanges),
|
||||
errors.Is(errPull, git.ErrNonFastForwardUpdate):
|
||||
// Ignore clean syncs, local edits, and remote divergence—local changes win.
|
||||
case errors.Is(errPull, transport.ErrAuthenticationRequired),
|
||||
errors.Is(errPull, plumbing.ErrReferenceNotFound),
|
||||
errors.Is(errPull, transport.ErrEmptyRemoteRepository):
|
||||
// Ignore authentication prompts and empty remote references on initial sync.
|
||||
default:
|
||||
s.dirLock.Unlock()
|
||||
return fmt.Errorf("git token store: pull: %w", errPull)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(s.baseDir, 0o700); err != nil {
|
||||
s.dirLock.Unlock()
|
||||
return fmt.Errorf("git token store: create auth dir: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(s.configDir, 0o700); err != nil {
|
||||
s.dirLock.Unlock()
|
||||
return fmt.Errorf("git token store: create config dir: %w", err)
|
||||
}
|
||||
s.dirLock.Unlock()
|
||||
if len(initPaths) > 0 {
|
||||
s.mu.Lock()
|
||||
err := s.commitAndPushLocked("Initialize git token store", initPaths...)
|
||||
s.mu.Unlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Save persists token storage and metadata to the resolved auth file path.
|
||||
func (s *GitTokenStore) Save(_ context.Context, auth *cliproxyauth.Auth) (string, 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 auth.Disabled {
|
||||
if _, statErr := os.Stat(path); os.IsNotExist(statErr) {
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
|
||||
if err = s.EnsureRepository(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
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 !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
|
||||
}
|
||||
|
||||
relPath, errRel := s.relativeToRepo(path)
|
||||
if errRel != nil {
|
||||
return "", errRel
|
||||
}
|
||||
messageID := auth.ID
|
||||
if strings.TrimSpace(messageID) == "" {
|
||||
messageID = filepath.Base(path)
|
||||
}
|
||||
if errCommit := s.commitAndPushLocked(fmt.Sprintf("Update auth %s", strings.TrimSpace(messageID)), relPath); errCommit != nil {
|
||||
return "", errCommit
|
||||
}
|
||||
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// List enumerates all auth JSON files under the configured directory.
|
||||
func (s *GitTokenStore) List(_ context.Context) ([]*cliproxyauth.Auth, error) {
|
||||
if err := s.EnsureRepository(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dir := s.baseDirSnapshot()
|
||||
if dir == "" {
|
||||
return nil, fmt.Errorf("auth filestore: directory not configured")
|
||||
}
|
||||
entries := make([]*cliproxyauth.Auth, 0)
|
||||
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(strings.ToLower(d.Name()), ".json") {
|
||||
return nil
|
||||
}
|
||||
auth, err := s.readAuthFile(path, dir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if auth != nil {
|
||||
entries = append(entries, auth)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// Delete removes the auth file.
|
||||
func (s *GitTokenStore) Delete(_ context.Context, id string) error {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return fmt.Errorf("auth filestore: id is empty")
|
||||
}
|
||||
path, err := s.resolveDeletePath(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = s.EnsureRepository(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if err = os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("auth filestore: delete failed: %w", err)
|
||||
}
|
||||
if err == nil {
|
||||
rel, errRel := s.relativeToRepo(path)
|
||||
if errRel != nil {
|
||||
return errRel
|
||||
}
|
||||
messageID := id
|
||||
if errCommit := s.commitAndPushLocked(fmt.Sprintf("Delete auth %s", messageID), rel); errCommit != nil {
|
||||
return errCommit
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PersistAuthFiles commits and pushes the provided paths to the remote repository.
|
||||
// It no-ops when the store is not fully configured or when there are no paths.
|
||||
func (s *GitTokenStore) PersistAuthFiles(_ context.Context, message string, paths ...string) error {
|
||||
if len(paths) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := s.EnsureRepository(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
filtered := make([]string, 0, len(paths))
|
||||
for _, p := range paths {
|
||||
trimmed := strings.TrimSpace(p)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
rel, err := s.relativeToRepo(trimmed)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filtered = append(filtered, rel)
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if strings.TrimSpace(message) == "" {
|
||||
message = "Sync watcher updates"
|
||||
}
|
||||
return s.commitAndPushLocked(message, filtered...)
|
||||
}
|
||||
|
||||
func (s *GitTokenStore) resolveDeletePath(id string) (string, error) {
|
||||
if strings.ContainsRune(id, os.PathSeparator) || filepath.IsAbs(id) {
|
||||
return id, nil
|
||||
}
|
||||
dir := s.baseDirSnapshot()
|
||||
if dir == "" {
|
||||
return "", fmt.Errorf("auth filestore: directory not configured")
|
||||
}
|
||||
return filepath.Join(dir, id), nil
|
||||
}
|
||||
|
||||
func (s *GitTokenStore) readAuthFile(path, baseDir string) (*cliproxyauth.Auth, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read file: %w", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
metadata := make(map[string]any)
|
||||
if err = json.Unmarshal(data, &metadata); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal auth json: %w", err)
|
||||
}
|
||||
provider, _ := metadata["type"].(string)
|
||||
if provider == "" {
|
||||
provider = "unknown"
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stat file: %w", err)
|
||||
}
|
||||
id := s.idFor(path, baseDir)
|
||||
auth := &cliproxyauth.Auth{
|
||||
ID: id,
|
||||
Provider: provider,
|
||||
FileName: id,
|
||||
Label: s.labelFor(metadata),
|
||||
Status: cliproxyauth.StatusActive,
|
||||
Attributes: map[string]string{"path": path},
|
||||
Metadata: metadata,
|
||||
CreatedAt: info.ModTime(),
|
||||
UpdatedAt: info.ModTime(),
|
||||
LastRefreshedAt: time.Time{},
|
||||
NextRefreshAfter: time.Time{},
|
||||
}
|
||||
if email, ok := metadata["email"].(string); ok && email != "" {
|
||||
auth.Attributes["email"] = email
|
||||
}
|
||||
return auth, nil
|
||||
}
|
||||
|
||||
func (s *GitTokenStore) idFor(path, baseDir string) string {
|
||||
if baseDir == "" {
|
||||
return path
|
||||
}
|
||||
rel, err := filepath.Rel(baseDir, path)
|
||||
if err != nil {
|
||||
return path
|
||||
}
|
||||
return rel
|
||||
}
|
||||
|
||||
func (s *GitTokenStore) resolveAuthPath(auth *cliproxyauth.Auth) (string, error) {
|
||||
if auth == nil {
|
||||
return "", fmt.Errorf("auth filestore: auth is nil")
|
||||
}
|
||||
if auth.Attributes != nil {
|
||||
if p := strings.TrimSpace(auth.Attributes["path"]); p != "" {
|
||||
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")
|
||||
}
|
||||
if filepath.IsAbs(auth.ID) {
|
||||
return auth.ID, nil
|
||||
}
|
||||
dir := s.baseDirSnapshot()
|
||||
if dir == "" {
|
||||
return "", fmt.Errorf("auth filestore: directory not configured")
|
||||
}
|
||||
return filepath.Join(dir, auth.ID), nil
|
||||
}
|
||||
|
||||
func (s *GitTokenStore) labelFor(metadata map[string]any) string {
|
||||
if metadata == nil {
|
||||
return ""
|
||||
}
|
||||
if v, ok := metadata["label"].(string); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
if v, ok := metadata["email"].(string); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
if project, ok := metadata["project_id"].(string); ok && project != "" {
|
||||
return project
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *GitTokenStore) baseDirSnapshot() string {
|
||||
s.dirLock.RLock()
|
||||
defer s.dirLock.RUnlock()
|
||||
return s.baseDir
|
||||
}
|
||||
|
||||
func (s *GitTokenStore) repoDirSnapshot() string {
|
||||
s.dirLock.RLock()
|
||||
defer s.dirLock.RUnlock()
|
||||
return s.repoDir
|
||||
}
|
||||
|
||||
func (s *GitTokenStore) gitAuth() transport.AuthMethod {
|
||||
if s.username == "" && s.password == "" {
|
||||
return nil
|
||||
}
|
||||
user := s.username
|
||||
if user == "" {
|
||||
user = "git"
|
||||
}
|
||||
return &http.BasicAuth{Username: user, Password: s.password}
|
||||
}
|
||||
|
||||
func (s *GitTokenStore) relativeToRepo(path string) (string, error) {
|
||||
repoDir := s.repoDirSnapshot()
|
||||
if repoDir == "" {
|
||||
return "", fmt.Errorf("git token store: repository path not configured")
|
||||
}
|
||||
absRepo := repoDir
|
||||
if abs, err := filepath.Abs(repoDir); err == nil {
|
||||
absRepo = abs
|
||||
}
|
||||
cleanPath := path
|
||||
if abs, err := filepath.Abs(path); err == nil {
|
||||
cleanPath = abs
|
||||
}
|
||||
rel, err := filepath.Rel(absRepo, cleanPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("git token store: relative path: %w", err)
|
||||
}
|
||||
if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
||||
return "", fmt.Errorf("git token store: path outside repository")
|
||||
}
|
||||
return rel, nil
|
||||
}
|
||||
|
||||
func (s *GitTokenStore) commitAndPushLocked(message string, relPaths ...string) error {
|
||||
repoDir := s.repoDirSnapshot()
|
||||
if repoDir == "" {
|
||||
return fmt.Errorf("git token store: repository path not configured")
|
||||
}
|
||||
repo, err := git.PlainOpen(repoDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("git token store: open repo: %w", err)
|
||||
}
|
||||
worktree, err := repo.Worktree()
|
||||
if err != nil {
|
||||
return fmt.Errorf("git token store: worktree: %w", err)
|
||||
}
|
||||
added := false
|
||||
for _, rel := range relPaths {
|
||||
if strings.TrimSpace(rel) == "" {
|
||||
continue
|
||||
}
|
||||
if _, err = worktree.Add(rel); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
if _, errRemove := worktree.Remove(rel); errRemove != nil && !errors.Is(errRemove, os.ErrNotExist) {
|
||||
return fmt.Errorf("git token store: remove %s: %w", rel, errRemove)
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("git token store: add %s: %w", rel, err)
|
||||
}
|
||||
}
|
||||
added = true
|
||||
}
|
||||
if !added {
|
||||
return nil
|
||||
}
|
||||
status, err := worktree.Status()
|
||||
if err != nil {
|
||||
return fmt.Errorf("git token store: status: %w", err)
|
||||
}
|
||||
if status.IsClean() {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(message) == "" {
|
||||
message = "Update auth store"
|
||||
}
|
||||
signature := &object.Signature{
|
||||
Name: "CLIProxyAPI",
|
||||
Email: "cliproxy@local",
|
||||
When: time.Now(),
|
||||
}
|
||||
commitHash, err := worktree.Commit(message, &git.CommitOptions{
|
||||
Author: signature,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, git.ErrEmptyCommit) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("git token store: commit: %w", err)
|
||||
}
|
||||
headRef, errHead := repo.Head()
|
||||
if errHead != nil {
|
||||
if !errors.Is(errHead, plumbing.ErrReferenceNotFound) {
|
||||
return fmt.Errorf("git token store: get head: %w", errHead)
|
||||
}
|
||||
} else if errRewrite := s.rewriteHeadAsSingleCommit(repo, headRef.Name(), commitHash, message, signature); errRewrite != nil {
|
||||
return errRewrite
|
||||
}
|
||||
if err = repo.Push(&git.PushOptions{Auth: s.gitAuth(), Force: true}); err != nil {
|
||||
if errors.Is(err, git.NoErrAlreadyUpToDate) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("git token store: push: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// rewriteHeadAsSingleCommit rewrites the current branch tip to a single-parentless commit and leaves history squashed.
|
||||
func (s *GitTokenStore) rewriteHeadAsSingleCommit(repo *git.Repository, branch plumbing.ReferenceName, commitHash plumbing.Hash, message string, signature *object.Signature) error {
|
||||
commitObj, err := repo.CommitObject(commitHash)
|
||||
if err != nil {
|
||||
return fmt.Errorf("git token store: inspect head commit: %w", err)
|
||||
}
|
||||
squashed := &object.Commit{
|
||||
Author: *signature,
|
||||
Committer: *signature,
|
||||
Message: message,
|
||||
TreeHash: commitObj.TreeHash,
|
||||
ParentHashes: nil,
|
||||
Encoding: commitObj.Encoding,
|
||||
ExtraHeaders: commitObj.ExtraHeaders,
|
||||
}
|
||||
mem := &plumbing.MemoryObject{}
|
||||
mem.SetType(plumbing.CommitObject)
|
||||
if err := squashed.Encode(mem); err != nil {
|
||||
return fmt.Errorf("git token store: encode squashed commit: %w", err)
|
||||
}
|
||||
newHash, err := repo.Storer.SetEncodedObject(mem)
|
||||
if err != nil {
|
||||
return fmt.Errorf("git token store: write squashed commit: %w", err)
|
||||
}
|
||||
if err := repo.Storer.SetReference(plumbing.NewHashReference(branch, newHash)); err != nil {
|
||||
return fmt.Errorf("git token store: update branch reference: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PersistConfig commits and pushes configuration changes to git.
|
||||
func (s *GitTokenStore) PersistConfig(_ context.Context) error {
|
||||
if err := s.EnsureRepository(); err != nil {
|
||||
return err
|
||||
}
|
||||
configPath := s.ConfigPath()
|
||||
if configPath == "" {
|
||||
return fmt.Errorf("git token store: config path not configured")
|
||||
}
|
||||
if _, err := os.Stat(configPath); err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("git token store: stat config: %w", err)
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
rel, err := s.relativeToRepo(configPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.commitAndPushLocked("Update config", rel)
|
||||
}
|
||||
|
||||
func ensureEmptyFile(path string) error {
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return os.WriteFile(path, []byte{}, 0o600)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func jsonEqual(a, b []byte) bool {
|
||||
var objA any
|
||||
var objB any
|
||||
if err := json.Unmarshal(a, &objA); err != nil {
|
||||
return false
|
||||
}
|
||||
if err := json.Unmarshal(b, &objB); err != nil {
|
||||
return false
|
||||
}
|
||||
return deepEqualJSON(objA, objB)
|
||||
}
|
||||
|
||||
func deepEqualJSON(a, b any) bool {
|
||||
switch valA := a.(type) {
|
||||
case map[string]any:
|
||||
valB, ok := b.(map[string]any)
|
||||
if !ok || len(valA) != len(valB) {
|
||||
return false
|
||||
}
|
||||
for key, subA := range valA {
|
||||
subB, ok1 := valB[key]
|
||||
if !ok1 || !deepEqualJSON(subA, subB) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
case []any:
|
||||
sliceB, ok := b.([]any)
|
||||
if !ok || len(valA) != len(sliceB) {
|
||||
return false
|
||||
}
|
||||
for i := range valA {
|
||||
if !deepEqualJSON(valA[i], sliceB[i]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
case float64:
|
||||
valB, ok := b.(float64)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return valA == valB
|
||||
case string:
|
||||
valB, ok := b.(string)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return valA == valB
|
||||
case bool:
|
||||
valB, ok := b.(bool)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return valA == valB
|
||||
case nil:
|
||||
return b == nil
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
665
internal/store/postgresstore.go
Normal file
665
internal/store/postgresstore.go
Normal file
@@ -0,0 +1,665 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
|
||||
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultConfigTable = "config_store"
|
||||
defaultAuthTable = "auth_store"
|
||||
defaultConfigKey = "config"
|
||||
)
|
||||
|
||||
// PostgresStoreConfig captures configuration required to initialize a Postgres-backed store.
|
||||
type PostgresStoreConfig struct {
|
||||
DSN string
|
||||
Schema string
|
||||
ConfigTable string
|
||||
AuthTable string
|
||||
SpoolDir string
|
||||
}
|
||||
|
||||
// PostgresStore persists configuration and authentication metadata using PostgreSQL as backend
|
||||
// while mirroring data to a local workspace so existing file-based workflows continue to operate.
|
||||
type PostgresStore struct {
|
||||
db *sql.DB
|
||||
cfg PostgresStoreConfig
|
||||
spoolRoot string
|
||||
configPath string
|
||||
authDir string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewPostgresStore establishes a connection to PostgreSQL and prepares the local workspace.
|
||||
func NewPostgresStore(ctx context.Context, cfg PostgresStoreConfig) (*PostgresStore, error) {
|
||||
trimmedDSN := strings.TrimSpace(cfg.DSN)
|
||||
if trimmedDSN == "" {
|
||||
return nil, fmt.Errorf("postgres store: DSN is required")
|
||||
}
|
||||
cfg.DSN = trimmedDSN
|
||||
if cfg.ConfigTable == "" {
|
||||
cfg.ConfigTable = defaultConfigTable
|
||||
}
|
||||
if cfg.AuthTable == "" {
|
||||
cfg.AuthTable = defaultAuthTable
|
||||
}
|
||||
|
||||
spoolRoot := strings.TrimSpace(cfg.SpoolDir)
|
||||
if spoolRoot == "" {
|
||||
if cwd, err := os.Getwd(); err == nil {
|
||||
spoolRoot = filepath.Join(cwd, "pgstore")
|
||||
} else {
|
||||
spoolRoot = filepath.Join(os.TempDir(), "pgstore")
|
||||
}
|
||||
}
|
||||
absSpool, err := filepath.Abs(spoolRoot)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("postgres store: resolve spool directory: %w", err)
|
||||
}
|
||||
configDir := filepath.Join(absSpool, "config")
|
||||
authDir := filepath.Join(absSpool, "auths")
|
||||
if err = os.MkdirAll(configDir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("postgres store: create config directory: %w", err)
|
||||
}
|
||||
if err = os.MkdirAll(authDir, 0o700); err != nil {
|
||||
return nil, fmt.Errorf("postgres store: create auth directory: %w", err)
|
||||
}
|
||||
|
||||
db, err := sql.Open("pgx", cfg.DSN)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("postgres store: open database connection: %w", err)
|
||||
}
|
||||
if err = db.PingContext(ctx); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("postgres store: ping database: %w", err)
|
||||
}
|
||||
|
||||
store := &PostgresStore{
|
||||
db: db,
|
||||
cfg: cfg,
|
||||
spoolRoot: absSpool,
|
||||
configPath: filepath.Join(configDir, "config.yaml"),
|
||||
authDir: authDir,
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// Close releases the underlying database connection.
|
||||
func (s *PostgresStore) Close() error {
|
||||
if s == nil || s.db == nil {
|
||||
return nil
|
||||
}
|
||||
return s.db.Close()
|
||||
}
|
||||
|
||||
// EnsureSchema creates the required tables (and schema when provided).
|
||||
func (s *PostgresStore) EnsureSchema(ctx context.Context) error {
|
||||
if s == nil || s.db == nil {
|
||||
return fmt.Errorf("postgres store: not initialized")
|
||||
}
|
||||
if schema := strings.TrimSpace(s.cfg.Schema); schema != "" {
|
||||
query := fmt.Sprintf("CREATE SCHEMA IF NOT EXISTS %s", quoteIdentifier(schema))
|
||||
if _, err := s.db.ExecContext(ctx, query); err != nil {
|
||||
return fmt.Errorf("postgres store: create schema: %w", err)
|
||||
}
|
||||
}
|
||||
configTable := s.fullTableName(s.cfg.ConfigTable)
|
||||
if _, err := s.db.ExecContext(ctx, fmt.Sprintf(`
|
||||
CREATE TABLE IF NOT EXISTS %s (
|
||||
id TEXT PRIMARY KEY,
|
||||
content TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`, configTable)); err != nil {
|
||||
return fmt.Errorf("postgres store: create config table: %w", err)
|
||||
}
|
||||
authTable := s.fullTableName(s.cfg.AuthTable)
|
||||
if _, err := s.db.ExecContext(ctx, fmt.Sprintf(`
|
||||
CREATE TABLE IF NOT EXISTS %s (
|
||||
id TEXT PRIMARY KEY,
|
||||
content JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
`, authTable)); err != nil {
|
||||
return fmt.Errorf("postgres store: create auth table: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Bootstrap synchronizes configuration and auth records between PostgreSQL and the local workspace.
|
||||
func (s *PostgresStore) Bootstrap(ctx context.Context, exampleConfigPath string) error {
|
||||
if err := s.EnsureSchema(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.syncConfigFromDatabase(ctx, exampleConfigPath); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.syncAuthFromDatabase(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConfigPath returns the managed configuration file path inside the spool directory.
|
||||
func (s *PostgresStore) ConfigPath() string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return s.configPath
|
||||
}
|
||||
|
||||
// AuthDir returns the local directory containing mirrored auth files.
|
||||
func (s *PostgresStore) AuthDir() string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return s.authDir
|
||||
}
|
||||
|
||||
// WorkDir exposes the root spool directory used for mirroring.
|
||||
func (s *PostgresStore) WorkDir() string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return s.spoolRoot
|
||||
}
|
||||
|
||||
// SetBaseDir implements the optional interface used by authenticators; it is a no-op because
|
||||
// the Postgres-backed store controls its own workspace.
|
||||
func (s *PostgresStore) SetBaseDir(string) {}
|
||||
|
||||
// Save persists authentication metadata to disk and PostgreSQL.
|
||||
func (s *PostgresStore) Save(ctx context.Context, auth *cliproxyauth.Auth) (string, error) {
|
||||
if auth == nil {
|
||||
return "", fmt.Errorf("postgres store: auth is nil")
|
||||
}
|
||||
|
||||
path, err := s.resolveAuthPath(auth)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if path == "" {
|
||||
return "", fmt.Errorf("postgres store: missing file path attribute for %s", auth.ID)
|
||||
}
|
||||
|
||||
if auth.Disabled {
|
||||
if _, statErr := os.Stat(path); errors.Is(statErr, fs.ErrNotExist) {
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if err = os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return "", fmt.Errorf("postgres store: create auth directory: %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("postgres store: marshal metadata: %w", errMarshal)
|
||||
}
|
||||
if existing, errRead := os.ReadFile(path); errRead == nil {
|
||||
if jsonEqual(existing, raw) {
|
||||
return path, nil
|
||||
}
|
||||
} else if errRead != nil && !errors.Is(errRead, fs.ErrNotExist) {
|
||||
return "", fmt.Errorf("postgres store: read existing metadata: %w", errRead)
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if errWrite := os.WriteFile(tmp, raw, 0o600); errWrite != nil {
|
||||
return "", fmt.Errorf("postgres store: write temp auth file: %w", errWrite)
|
||||
}
|
||||
if errRename := os.Rename(tmp, path); errRename != nil {
|
||||
return "", fmt.Errorf("postgres store: rename auth file: %w", errRename)
|
||||
}
|
||||
default:
|
||||
return "", fmt.Errorf("postgres store: 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
|
||||
}
|
||||
|
||||
relID, err := s.relativeAuthID(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err = s.upsertAuthRecord(ctx, relID, path); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// List enumerates all auth records stored in PostgreSQL.
|
||||
func (s *PostgresStore) List(ctx context.Context) ([]*cliproxyauth.Auth, error) {
|
||||
query := fmt.Sprintf("SELECT id, content, created_at, updated_at FROM %s ORDER BY id", s.fullTableName(s.cfg.AuthTable))
|
||||
rows, err := s.db.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("postgres store: list auth: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
auths := make([]*cliproxyauth.Auth, 0, 32)
|
||||
for rows.Next() {
|
||||
var (
|
||||
id string
|
||||
payload string
|
||||
createdAt time.Time
|
||||
updatedAt time.Time
|
||||
)
|
||||
if err = rows.Scan(&id, &payload, &createdAt, &updatedAt); err != nil {
|
||||
return nil, fmt.Errorf("postgres store: scan auth row: %w", err)
|
||||
}
|
||||
path, errPath := s.absoluteAuthPath(id)
|
||||
if errPath != nil {
|
||||
log.WithError(errPath).Warnf("postgres store: skipping auth %s outside spool", id)
|
||||
continue
|
||||
}
|
||||
metadata := make(map[string]any)
|
||||
if err = json.Unmarshal([]byte(payload), &metadata); err != nil {
|
||||
log.WithError(err).Warnf("postgres store: skipping auth %s with invalid json", id)
|
||||
continue
|
||||
}
|
||||
provider := strings.TrimSpace(valueAsString(metadata["type"]))
|
||||
if provider == "" {
|
||||
provider = "unknown"
|
||||
}
|
||||
attr := map[string]string{"path": path}
|
||||
if email := strings.TrimSpace(valueAsString(metadata["email"])); email != "" {
|
||||
attr["email"] = email
|
||||
}
|
||||
auth := &cliproxyauth.Auth{
|
||||
ID: normalizeAuthID(id),
|
||||
Provider: provider,
|
||||
FileName: normalizeAuthID(id),
|
||||
Label: labelFor(metadata),
|
||||
Status: cliproxyauth.StatusActive,
|
||||
Attributes: attr,
|
||||
Metadata: metadata,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
LastRefreshedAt: time.Time{},
|
||||
NextRefreshAfter: time.Time{},
|
||||
}
|
||||
auths = append(auths, auth)
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("postgres store: iterate auth rows: %w", err)
|
||||
}
|
||||
return auths, nil
|
||||
}
|
||||
|
||||
// Delete removes an auth file and the corresponding database record.
|
||||
func (s *PostgresStore) Delete(ctx context.Context, id string) error {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return fmt.Errorf("postgres store: id is empty")
|
||||
}
|
||||
path, err := s.resolveDeletePath(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if err = os.Remove(path); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return fmt.Errorf("postgres store: delete auth file: %w", err)
|
||||
}
|
||||
relID, err := s.relativeAuthID(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.deleteAuthRecord(ctx, relID)
|
||||
}
|
||||
|
||||
// PersistAuthFiles stores the provided auth file changes in PostgreSQL.
|
||||
func (s *PostgresStore) PersistAuthFiles(ctx context.Context, _ string, paths ...string) error {
|
||||
if len(paths) == 0 {
|
||||
return nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
for _, p := range paths {
|
||||
trimmed := strings.TrimSpace(p)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
relID, err := s.relativeAuthID(trimmed)
|
||||
if err != nil {
|
||||
// Attempt to resolve absolute path under authDir.
|
||||
abs := trimmed
|
||||
if !filepath.IsAbs(abs) {
|
||||
abs = filepath.Join(s.authDir, trimmed)
|
||||
}
|
||||
relID, err = s.relativeAuthID(abs)
|
||||
if err != nil {
|
||||
log.WithError(err).Warnf("postgres store: ignoring auth path %s", trimmed)
|
||||
continue
|
||||
}
|
||||
trimmed = abs
|
||||
}
|
||||
if err = s.syncAuthFile(ctx, relID, trimmed); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PersistConfig mirrors the local configuration file to PostgreSQL.
|
||||
func (s *PostgresStore) PersistConfig(ctx context.Context) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
data, err := os.ReadFile(s.configPath)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return s.deleteConfigRecord(ctx)
|
||||
}
|
||||
return fmt.Errorf("postgres store: read config file: %w", err)
|
||||
}
|
||||
return s.persistConfig(ctx, data)
|
||||
}
|
||||
|
||||
// syncConfigFromDatabase writes the database-stored config to disk or seeds the database from template.
|
||||
func (s *PostgresStore) syncConfigFromDatabase(ctx context.Context, exampleConfigPath string) error {
|
||||
query := fmt.Sprintf("SELECT content FROM %s WHERE id = $1", s.fullTableName(s.cfg.ConfigTable))
|
||||
var content string
|
||||
err := s.db.QueryRowContext(ctx, query, defaultConfigKey).Scan(&content)
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
if _, errStat := os.Stat(s.configPath); errors.Is(errStat, fs.ErrNotExist) {
|
||||
if exampleConfigPath != "" {
|
||||
if errCopy := misc.CopyConfigTemplate(exampleConfigPath, s.configPath); errCopy != nil {
|
||||
return fmt.Errorf("postgres store: copy example config: %w", errCopy)
|
||||
}
|
||||
} else {
|
||||
if errCreate := os.MkdirAll(filepath.Dir(s.configPath), 0o700); errCreate != nil {
|
||||
return fmt.Errorf("postgres store: prepare config directory: %w", errCreate)
|
||||
}
|
||||
if errWrite := os.WriteFile(s.configPath, []byte{}, 0o600); errWrite != nil {
|
||||
return fmt.Errorf("postgres store: create empty config: %w", errWrite)
|
||||
}
|
||||
}
|
||||
}
|
||||
data, errRead := os.ReadFile(s.configPath)
|
||||
if errRead != nil {
|
||||
return fmt.Errorf("postgres store: read local config: %w", errRead)
|
||||
}
|
||||
if errPersist := s.persistConfig(ctx, data); errPersist != nil {
|
||||
return errPersist
|
||||
}
|
||||
case err != nil:
|
||||
return fmt.Errorf("postgres store: load config from database: %w", err)
|
||||
default:
|
||||
if err = os.MkdirAll(filepath.Dir(s.configPath), 0o700); err != nil {
|
||||
return fmt.Errorf("postgres store: prepare config directory: %w", err)
|
||||
}
|
||||
normalized := normalizeLineEndings(content)
|
||||
if err = os.WriteFile(s.configPath, []byte(normalized), 0o600); err != nil {
|
||||
return fmt.Errorf("postgres store: write config to spool: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// syncAuthFromDatabase populates the local auth directory from PostgreSQL data.
|
||||
func (s *PostgresStore) syncAuthFromDatabase(ctx context.Context) error {
|
||||
query := fmt.Sprintf("SELECT id, content FROM %s", s.fullTableName(s.cfg.AuthTable))
|
||||
rows, err := s.db.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return fmt.Errorf("postgres store: load auth from database: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
if err = os.RemoveAll(s.authDir); err != nil {
|
||||
return fmt.Errorf("postgres store: reset auth directory: %w", err)
|
||||
}
|
||||
if err = os.MkdirAll(s.authDir, 0o700); err != nil {
|
||||
return fmt.Errorf("postgres store: recreate auth directory: %w", err)
|
||||
}
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
id string
|
||||
payload string
|
||||
)
|
||||
if err = rows.Scan(&id, &payload); err != nil {
|
||||
return fmt.Errorf("postgres store: scan auth row: %w", err)
|
||||
}
|
||||
path, errPath := s.absoluteAuthPath(id)
|
||||
if errPath != nil {
|
||||
log.WithError(errPath).Warnf("postgres store: skipping auth %s outside spool", id)
|
||||
continue
|
||||
}
|
||||
if err = os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return fmt.Errorf("postgres store: create auth subdir: %w", err)
|
||||
}
|
||||
if err = os.WriteFile(path, []byte(payload), 0o600); err != nil {
|
||||
return fmt.Errorf("postgres store: write auth file: %w", err)
|
||||
}
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return fmt.Errorf("postgres store: iterate auth rows: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) syncAuthFile(ctx context.Context, relID, path string) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return s.deleteAuthRecord(ctx, relID)
|
||||
}
|
||||
return fmt.Errorf("postgres store: read auth file: %w", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return s.deleteAuthRecord(ctx, relID)
|
||||
}
|
||||
return s.persistAuth(ctx, relID, data)
|
||||
}
|
||||
|
||||
func (s *PostgresStore) upsertAuthRecord(ctx context.Context, relID, path string) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("postgres store: read auth file: %w", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
return s.deleteAuthRecord(ctx, relID)
|
||||
}
|
||||
return s.persistAuth(ctx, relID, data)
|
||||
}
|
||||
|
||||
func (s *PostgresStore) persistAuth(ctx context.Context, relID string, data []byte) error {
|
||||
jsonPayload := json.RawMessage(data)
|
||||
query := fmt.Sprintf(`
|
||||
INSERT INTO %s (id, content, created_at, updated_at)
|
||||
VALUES ($1, $2, NOW(), NOW())
|
||||
ON CONFLICT (id)
|
||||
DO UPDATE SET content = EXCLUDED.content, updated_at = NOW()
|
||||
`, s.fullTableName(s.cfg.AuthTable))
|
||||
if _, err := s.db.ExecContext(ctx, query, relID, jsonPayload); err != nil {
|
||||
return fmt.Errorf("postgres store: upsert auth record: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) deleteAuthRecord(ctx context.Context, relID string) error {
|
||||
query := fmt.Sprintf("DELETE FROM %s WHERE id = $1", s.fullTableName(s.cfg.AuthTable))
|
||||
if _, err := s.db.ExecContext(ctx, query, relID); err != nil {
|
||||
return fmt.Errorf("postgres store: delete auth record: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) persistConfig(ctx context.Context, data []byte) error {
|
||||
query := fmt.Sprintf(`
|
||||
INSERT INTO %s (id, content, created_at, updated_at)
|
||||
VALUES ($1, $2, NOW(), NOW())
|
||||
ON CONFLICT (id)
|
||||
DO UPDATE SET content = EXCLUDED.content, updated_at = NOW()
|
||||
`, s.fullTableName(s.cfg.ConfigTable))
|
||||
normalized := normalizeLineEndings(string(data))
|
||||
if _, err := s.db.ExecContext(ctx, query, defaultConfigKey, normalized); err != nil {
|
||||
return fmt.Errorf("postgres store: upsert config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) deleteConfigRecord(ctx context.Context) error {
|
||||
query := fmt.Sprintf("DELETE FROM %s WHERE id = $1", s.fullTableName(s.cfg.ConfigTable))
|
||||
if _, err := s.db.ExecContext(ctx, query, defaultConfigKey); err != nil {
|
||||
return fmt.Errorf("postgres store: delete config: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) resolveAuthPath(auth *cliproxyauth.Auth) (string, error) {
|
||||
if auth == nil {
|
||||
return "", fmt.Errorf("postgres store: auth is nil")
|
||||
}
|
||||
if auth.Attributes != nil {
|
||||
if p := strings.TrimSpace(auth.Attributes["path"]); p != "" {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
if fileName := strings.TrimSpace(auth.FileName); fileName != "" {
|
||||
if filepath.IsAbs(fileName) {
|
||||
return fileName, nil
|
||||
}
|
||||
return filepath.Join(s.authDir, fileName), nil
|
||||
}
|
||||
if auth.ID == "" {
|
||||
return "", fmt.Errorf("postgres store: missing id")
|
||||
}
|
||||
if filepath.IsAbs(auth.ID) {
|
||||
return auth.ID, nil
|
||||
}
|
||||
return filepath.Join(s.authDir, filepath.FromSlash(auth.ID)), nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) resolveDeletePath(id string) (string, error) {
|
||||
if strings.ContainsRune(id, os.PathSeparator) || filepath.IsAbs(id) {
|
||||
return id, nil
|
||||
}
|
||||
return filepath.Join(s.authDir, filepath.FromSlash(id)), nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) relativeAuthID(path string) (string, error) {
|
||||
if s == nil {
|
||||
return "", fmt.Errorf("postgres store: store not initialized")
|
||||
}
|
||||
if !filepath.IsAbs(path) {
|
||||
path = filepath.Join(s.authDir, path)
|
||||
}
|
||||
clean := filepath.Clean(path)
|
||||
rel, err := filepath.Rel(s.authDir, clean)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("postgres store: compute relative path: %w", err)
|
||||
}
|
||||
if strings.HasPrefix(rel, "..") {
|
||||
return "", fmt.Errorf("postgres store: path %s outside managed directory", path)
|
||||
}
|
||||
return filepath.ToSlash(rel), nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) absoluteAuthPath(id string) (string, error) {
|
||||
if s == nil {
|
||||
return "", fmt.Errorf("postgres store: store not initialized")
|
||||
}
|
||||
clean := filepath.Clean(filepath.FromSlash(id))
|
||||
if strings.HasPrefix(clean, "..") {
|
||||
return "", fmt.Errorf("postgres store: invalid auth identifier %s", id)
|
||||
}
|
||||
path := filepath.Join(s.authDir, clean)
|
||||
rel, err := filepath.Rel(s.authDir, path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.HasPrefix(rel, "..") {
|
||||
return "", fmt.Errorf("postgres store: resolved auth path escapes auth directory")
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func (s *PostgresStore) fullTableName(name string) string {
|
||||
if strings.TrimSpace(s.cfg.Schema) == "" {
|
||||
return quoteIdentifier(name)
|
||||
}
|
||||
return quoteIdentifier(s.cfg.Schema) + "." + quoteIdentifier(name)
|
||||
}
|
||||
|
||||
func quoteIdentifier(identifier string) string {
|
||||
replaced := strings.ReplaceAll(identifier, "\"", "\"\"")
|
||||
return "\"" + replaced + "\""
|
||||
}
|
||||
|
||||
func valueAsString(v any) string {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return t
|
||||
case fmt.Stringer:
|
||||
return t.String()
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func labelFor(metadata map[string]any) string {
|
||||
if metadata == nil {
|
||||
return ""
|
||||
}
|
||||
if v := strings.TrimSpace(valueAsString(metadata["label"])); v != "" {
|
||||
return v
|
||||
}
|
||||
if v := strings.TrimSpace(valueAsString(metadata["email"])); v != "" {
|
||||
return v
|
||||
}
|
||||
if v := strings.TrimSpace(valueAsString(metadata["project_id"])); v != "" {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func normalizeAuthID(id string) string {
|
||||
return filepath.ToSlash(filepath.Clean(id))
|
||||
}
|
||||
|
||||
func normalizeLineEndings(s string) string {
|
||||
if s == "" {
|
||||
return s
|
||||
}
|
||||
s = strings.ReplaceAll(s, "\r\n", "\n")
|
||||
s = strings.ReplaceAll(s, "\r", "\n")
|
||||
return s
|
||||
}
|
||||
@@ -331,8 +331,8 @@ func ConvertClaudeResponseToGeminiNonStream(_ context.Context, modelName string,
|
||||
streamingEvents := make([][]byte, 0)
|
||||
|
||||
scanner := bufio.NewScanner(bytes.NewReader(rawJSON))
|
||||
buffer := make([]byte, 10240*1024)
|
||||
scanner.Buffer(buffer, 10240*1024)
|
||||
buffer := make([]byte, 20_971_520)
|
||||
scanner.Buffer(buffer, 20_971_520)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
// log.Debug(string(line))
|
||||
|
||||
@@ -143,21 +143,63 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
|
||||
}
|
||||
switch typ {
|
||||
case "message":
|
||||
// Determine role from content type (input_text=user, output_text=assistant)
|
||||
// Determine role and construct Claude-compatible content parts.
|
||||
var role string
|
||||
var text strings.Builder
|
||||
var textAggregate strings.Builder
|
||||
var partsJSON []string
|
||||
hasImage := false
|
||||
if parts := item.Get("content"); parts.Exists() && parts.IsArray() {
|
||||
parts.ForEach(func(_, part gjson.Result) bool {
|
||||
ptype := part.Get("type").String()
|
||||
if ptype == "input_text" || ptype == "output_text" {
|
||||
switch ptype {
|
||||
case "input_text", "output_text":
|
||||
if t := part.Get("text"); t.Exists() {
|
||||
text.WriteString(t.String())
|
||||
txt := t.String()
|
||||
textAggregate.WriteString(txt)
|
||||
contentPart := `{"type":"text","text":""}`
|
||||
contentPart, _ = sjson.Set(contentPart, "text", txt)
|
||||
partsJSON = append(partsJSON, contentPart)
|
||||
}
|
||||
if ptype == "input_text" {
|
||||
role = "user"
|
||||
} else if ptype == "output_text" {
|
||||
} else {
|
||||
role = "assistant"
|
||||
}
|
||||
case "input_image":
|
||||
url := part.Get("image_url").String()
|
||||
if url == "" {
|
||||
url = part.Get("url").String()
|
||||
}
|
||||
if url != "" {
|
||||
var contentPart string
|
||||
if strings.HasPrefix(url, "data:") {
|
||||
trimmed := strings.TrimPrefix(url, "data:")
|
||||
mediaAndData := strings.SplitN(trimmed, ";base64,", 2)
|
||||
mediaType := "application/octet-stream"
|
||||
data := ""
|
||||
if len(mediaAndData) == 2 {
|
||||
if mediaAndData[0] != "" {
|
||||
mediaType = mediaAndData[0]
|
||||
}
|
||||
data = mediaAndData[1]
|
||||
}
|
||||
if data != "" {
|
||||
contentPart = `{"type":"image","source":{"type":"base64","media_type":"","data":""}}`
|
||||
contentPart, _ = sjson.Set(contentPart, "source.media_type", mediaType)
|
||||
contentPart, _ = sjson.Set(contentPart, "source.data", data)
|
||||
}
|
||||
} else {
|
||||
contentPart = `{"type":"image","source":{"type":"url","url":""}}`
|
||||
contentPart, _ = sjson.Set(contentPart, "source.url", url)
|
||||
}
|
||||
if contentPart != "" {
|
||||
partsJSON = append(partsJSON, contentPart)
|
||||
if role == "" {
|
||||
role = "user"
|
||||
}
|
||||
hasImage = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
@@ -174,14 +216,24 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
|
||||
}
|
||||
}
|
||||
|
||||
if text.Len() > 0 || role == "system" {
|
||||
if len(partsJSON) > 0 {
|
||||
msg := `{"role":"","content":[]}`
|
||||
msg, _ = sjson.Set(msg, "role", role)
|
||||
if len(partsJSON) == 1 && !hasImage {
|
||||
// Preserve legacy behavior for single text content
|
||||
msg, _ = sjson.Delete(msg, "content")
|
||||
textPart := gjson.Parse(partsJSON[0])
|
||||
msg, _ = sjson.Set(msg, "content", textPart.Get("text").String())
|
||||
} else {
|
||||
for _, partJSON := range partsJSON {
|
||||
msg, _ = sjson.SetRaw(msg, "content.-1", partJSON)
|
||||
}
|
||||
}
|
||||
out, _ = sjson.SetRaw(out, "messages.-1", msg)
|
||||
} else if textAggregate.Len() > 0 || role == "system" {
|
||||
msg := `{"role":"","content":""}`
|
||||
msg, _ = sjson.Set(msg, "role", role)
|
||||
if text.Len() > 0 {
|
||||
msg, _ = sjson.Set(msg, "content", text.String())
|
||||
} else {
|
||||
msg, _ = sjson.Set(msg, "content", "")
|
||||
}
|
||||
msg, _ = sjson.Set(msg, "content", textAggregate.String())
|
||||
out, _ = sjson.SetRaw(out, "messages.-1", msg)
|
||||
}
|
||||
|
||||
|
||||
@@ -445,8 +445,8 @@ func ConvertClaudeResponseToOpenAIResponsesNonStream(_ context.Context, _ string
|
||||
// Use a simple scanner to iterate through raw bytes
|
||||
// Note: extremely large responses may require increasing the buffer
|
||||
scanner := bufio.NewScanner(bytes.NewReader(rawJSON))
|
||||
buf := make([]byte, 10240*1024)
|
||||
scanner.Buffer(buf, 10240*1024)
|
||||
buf := make([]byte, 20_971_520)
|
||||
scanner.Buffer(buf, 20_971_520)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
if !bytes.HasPrefix(line, dataTag) {
|
||||
|
||||
@@ -68,36 +68,79 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
|
||||
|
||||
for i := 0; i < len(messageResults); i++ {
|
||||
messageResult := messageResults[i]
|
||||
messageRole := messageResult.Get("role").String()
|
||||
|
||||
newMessage := func() string {
|
||||
msg := `{"type": "message","role":"","content":[]}`
|
||||
msg, _ = sjson.Set(msg, "role", messageRole)
|
||||
return msg
|
||||
}
|
||||
|
||||
message := newMessage()
|
||||
contentIndex := 0
|
||||
hasContent := false
|
||||
|
||||
flushMessage := func() {
|
||||
if hasContent {
|
||||
template, _ = sjson.SetRaw(template, "input.-1", message)
|
||||
message = newMessage()
|
||||
contentIndex = 0
|
||||
hasContent = false
|
||||
}
|
||||
}
|
||||
|
||||
appendTextContent := func(text string) {
|
||||
partType := "input_text"
|
||||
if messageRole == "assistant" {
|
||||
partType = "output_text"
|
||||
}
|
||||
message, _ = sjson.Set(message, fmt.Sprintf("content.%d.type", contentIndex), partType)
|
||||
message, _ = sjson.Set(message, fmt.Sprintf("content.%d.text", contentIndex), text)
|
||||
contentIndex++
|
||||
hasContent = true
|
||||
}
|
||||
|
||||
appendImageContent := func(dataURL string) {
|
||||
message, _ = sjson.Set(message, fmt.Sprintf("content.%d.type", contentIndex), "input_image")
|
||||
message, _ = sjson.Set(message, fmt.Sprintf("content.%d.image_url", contentIndex), dataURL)
|
||||
contentIndex++
|
||||
hasContent = true
|
||||
}
|
||||
|
||||
messageContentsResult := messageResult.Get("content")
|
||||
if messageContentsResult.IsArray() {
|
||||
messageContentResults := messageContentsResult.Array()
|
||||
for j := 0; j < len(messageContentResults); j++ {
|
||||
messageContentResult := messageContentResults[j]
|
||||
messageContentTypeResult := messageContentResult.Get("type")
|
||||
contentType := messageContentTypeResult.String()
|
||||
contentType := messageContentResult.Get("type").String()
|
||||
|
||||
if contentType == "text" {
|
||||
// Handle text content by creating appropriate message structure.
|
||||
message := `{"type": "message","role":"","content":[]}`
|
||||
messageRole := messageResult.Get("role").String()
|
||||
message, _ = sjson.Set(message, "role", messageRole)
|
||||
|
||||
partType := "input_text"
|
||||
if messageRole == "assistant" {
|
||||
partType = "output_text"
|
||||
switch contentType {
|
||||
case "text":
|
||||
appendTextContent(messageContentResult.Get("text").String())
|
||||
case "image":
|
||||
sourceResult := messageContentResult.Get("source")
|
||||
if sourceResult.Exists() {
|
||||
data := sourceResult.Get("data").String()
|
||||
if data == "" {
|
||||
data = sourceResult.Get("base64").String()
|
||||
}
|
||||
if data != "" {
|
||||
mediaType := sourceResult.Get("media_type").String()
|
||||
if mediaType == "" {
|
||||
mediaType = sourceResult.Get("mime_type").String()
|
||||
}
|
||||
if mediaType == "" {
|
||||
mediaType = "application/octet-stream"
|
||||
}
|
||||
dataURL := fmt.Sprintf("data:%s;base64,%s", mediaType, data)
|
||||
appendImageContent(dataURL)
|
||||
}
|
||||
}
|
||||
|
||||
currentIndex := len(gjson.Get(message, "content").Array())
|
||||
message, _ = sjson.Set(message, fmt.Sprintf("content.%d.type", currentIndex), partType)
|
||||
message, _ = sjson.Set(message, fmt.Sprintf("content.%d.text", currentIndex), messageContentResult.Get("text").String())
|
||||
template, _ = sjson.SetRaw(template, "input.-1", message)
|
||||
} else if contentType == "tool_use" {
|
||||
// Handle tool use content by creating function call message.
|
||||
case "tool_use":
|
||||
flushMessage()
|
||||
functionCallMessage := `{"type":"function_call"}`
|
||||
functionCallMessage, _ = sjson.Set(functionCallMessage, "call_id", messageContentResult.Get("id").String())
|
||||
{
|
||||
// Shorten tool name if needed based on declared tools
|
||||
name := messageContentResult.Get("name").String()
|
||||
toolMap := buildReverseMapFromClaudeOriginalToShort(rawJSON)
|
||||
if short, ok := toolMap[name]; ok {
|
||||
@@ -109,28 +152,18 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
|
||||
}
|
||||
functionCallMessage, _ = sjson.Set(functionCallMessage, "arguments", messageContentResult.Get("input").Raw)
|
||||
template, _ = sjson.SetRaw(template, "input.-1", functionCallMessage)
|
||||
} else if contentType == "tool_result" {
|
||||
// Handle tool result content by creating function call output message.
|
||||
case "tool_result":
|
||||
flushMessage()
|
||||
functionCallOutputMessage := `{"type":"function_call_output"}`
|
||||
functionCallOutputMessage, _ = sjson.Set(functionCallOutputMessage, "call_id", messageContentResult.Get("tool_use_id").String())
|
||||
functionCallOutputMessage, _ = sjson.Set(functionCallOutputMessage, "output", messageContentResult.Get("content").String())
|
||||
template, _ = sjson.SetRaw(template, "input.-1", functionCallOutputMessage)
|
||||
}
|
||||
}
|
||||
flushMessage()
|
||||
} else if messageContentsResult.Type == gjson.String {
|
||||
// Handle string content by creating appropriate message structure.
|
||||
message := `{"type": "message","role":"","content":[]}`
|
||||
messageRole := messageResult.Get("role").String()
|
||||
message, _ = sjson.Set(message, "role", messageRole)
|
||||
|
||||
partType := "input_text"
|
||||
if messageRole == "assistant" {
|
||||
partType = "output_text"
|
||||
}
|
||||
|
||||
message, _ = sjson.Set(message, "content.0.type", partType)
|
||||
message, _ = sjson.Set(message, "content.0.text", messageContentsResult.String())
|
||||
template, _ = sjson.SetRaw(template, "input.-1", message)
|
||||
appendTextContent(messageContentsResult.String())
|
||||
flushMessage()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +186,12 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
|
||||
shortMap := buildShortNameMap(names)
|
||||
for i := 0; i < len(toolResults); i++ {
|
||||
toolResult := toolResults[i]
|
||||
// Special handling: map Claude web search tool to Codex web_search
|
||||
if toolResult.Get("type").String() == "web_search_20250305" {
|
||||
// Replace the tool content entirely with {"type":"web_search"}
|
||||
template, _ = sjson.SetRaw(template, "tools.-1", `{"type":"web_search"}`)
|
||||
continue
|
||||
}
|
||||
tool := toolResult.Raw
|
||||
tool, _ = sjson.Set(tool, "type", "function")
|
||||
// Apply shortened name if needed
|
||||
|
||||
@@ -181,8 +181,8 @@ func ConvertCodexResponseToClaude(_ context.Context, _ string, originalRequestRa
|
||||
// - string: A Claude Code-compatible JSON response containing all message content and metadata
|
||||
func ConvertCodexResponseToClaudeNonStream(_ context.Context, _ string, originalRequestRawJSON, _ []byte, rawJSON []byte, _ *any) string {
|
||||
scanner := bufio.NewScanner(bytes.NewReader(rawJSON))
|
||||
buffer := make([]byte, 10240*1024)
|
||||
scanner.Buffer(buffer, 10240*1024)
|
||||
buffer := make([]byte, 20_971_520)
|
||||
scanner.Buffer(buffer, 20_971_520)
|
||||
revNames := buildReverseMapFromClaudeOriginalShortToOriginal(originalRequestRawJSON)
|
||||
|
||||
for scanner.Scan() {
|
||||
|
||||
@@ -153,8 +153,8 @@ func ConvertCodexResponseToGemini(_ context.Context, modelName string, originalR
|
||||
// - string: A Gemini-compatible JSON response containing all message content and metadata
|
||||
func ConvertCodexResponseToGeminiNonStream(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) string {
|
||||
scanner := bufio.NewScanner(bytes.NewReader(rawJSON))
|
||||
buffer := make([]byte, 10240*1024)
|
||||
scanner.Buffer(buffer, 10240*1024)
|
||||
buffer := make([]byte, 20_971_520)
|
||||
scanner.Buffer(buffer, 20_971_520)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
// log.Debug(string(line))
|
||||
|
||||
@@ -34,9 +34,17 @@ func ConvertOpenAIResponsesRequestToCodex(modelName string, inputRawJSON []byte,
|
||||
}
|
||||
|
||||
inputResult := gjson.GetBytes(rawJSON, "input")
|
||||
inputResults := []gjson.Result{}
|
||||
if inputResult.Exists() && inputResult.IsArray() {
|
||||
inputResults = inputResult.Array()
|
||||
var inputResults []gjson.Result
|
||||
if inputResult.Exists() {
|
||||
if inputResult.IsArray() {
|
||||
inputResults = inputResult.Array()
|
||||
} else if inputResult.Type == gjson.String {
|
||||
newInput := `[{"type":"message","role":"user","content":[{"type":"input_text","text":""}]}]`
|
||||
newInput, _ = sjson.Set(newInput, "0.content.0.text", inputResult.String())
|
||||
inputResults = gjson.Parse(newInput).Array()
|
||||
}
|
||||
} else {
|
||||
inputResults = []gjson.Result{}
|
||||
}
|
||||
|
||||
extractedSystemInstructions := false
|
||||
|
||||
@@ -30,8 +30,8 @@ func ConvertCodexResponseToOpenAIResponses(ctx context.Context, modelName string
|
||||
// from a non-streaming OpenAI Chat Completions response.
|
||||
func ConvertCodexResponseToOpenAIResponsesNonStream(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) string {
|
||||
scanner := bufio.NewScanner(bytes.NewReader(rawJSON))
|
||||
buffer := make([]byte, 10240*1024)
|
||||
scanner.Buffer(buffer, 10240*1024)
|
||||
buffer := make([]byte, 20_971_520)
|
||||
scanner.Buffer(buffer, 20_971_520)
|
||||
dataTag := []byte("data:")
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
|
||||
@@ -26,6 +26,21 @@ import (
|
||||
// - []byte: The transformed request data in Gemini CLI API format
|
||||
func ConvertOpenAIRequestToGeminiCLI(modelName string, inputRawJSON []byte, _ bool) []byte {
|
||||
rawJSON := bytes.Clone(inputRawJSON)
|
||||
var pathsToDelete []string
|
||||
root := gjson.ParseBytes(rawJSON)
|
||||
util.Walk(root, "", "additionalProperties", &pathsToDelete)
|
||||
util.Walk(root, "", "$schema", &pathsToDelete)
|
||||
util.Walk(root, "", "ref", &pathsToDelete)
|
||||
util.Walk(root, "", "strict", &pathsToDelete)
|
||||
|
||||
var err error
|
||||
for _, p := range pathsToDelete {
|
||||
rawJSON, err = sjson.DeleteBytes(rawJSON, p)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Base envelope
|
||||
out := []byte(`{"project":"","request":{"contents":[],"generationConfig":{"thinkingConfig":{"include_thoughts":true}}},"model":"gemini-2.5-pro"}`)
|
||||
|
||||
@@ -65,6 +80,31 @@ func ConvertOpenAIRequestToGeminiCLI(modelName string, inputRawJSON []byte, _ bo
|
||||
out, _ = sjson.SetBytes(out, "request.generationConfig.topK", tkr.Num)
|
||||
}
|
||||
|
||||
// Map OpenAI modalities -> Gemini CLI request.generationConfig.responseModalities
|
||||
// e.g. "modalities": ["image", "text"] -> ["Image", "Text"]
|
||||
if mods := gjson.GetBytes(rawJSON, "modalities"); mods.Exists() && mods.IsArray() {
|
||||
var responseMods []string
|
||||
for _, m := range mods.Array() {
|
||||
switch strings.ToLower(m.String()) {
|
||||
case "text":
|
||||
responseMods = append(responseMods, "Text")
|
||||
case "image":
|
||||
responseMods = append(responseMods, "Image")
|
||||
}
|
||||
}
|
||||
if len(responseMods) > 0 {
|
||||
out, _ = sjson.SetBytes(out, "request.generationConfig.responseModalities", responseMods)
|
||||
}
|
||||
}
|
||||
|
||||
// OpenRouter-style image_config support
|
||||
// If the input uses top-level image_config.aspect_ratio, map it into request.generationConfig.imageConfig.aspectRatio.
|
||||
if imgCfg := gjson.GetBytes(rawJSON, "image_config"); imgCfg.Exists() && imgCfg.IsObject() {
|
||||
if ar := imgCfg.Get("aspect_ratio"); ar.Exists() && ar.Type == gjson.String {
|
||||
out, _ = sjson.SetBytes(out, "request.generationConfig.imageConfig.aspectRatio", ar.Str)
|
||||
}
|
||||
}
|
||||
|
||||
// messages -> systemInstruction + contents
|
||||
messages := gjson.GetBytes(rawJSON, "messages")
|
||||
if messages.IsArray() {
|
||||
@@ -232,7 +272,7 @@ func ConvertOpenAIRequestToGeminiCLI(modelName string, inputRawJSON []byte, _ bo
|
||||
}
|
||||
|
||||
var pathsToType []string
|
||||
root := gjson.ParseBytes(out)
|
||||
root = gjson.ParseBytes(out)
|
||||
util.Walk(root, "", "type", &pathsToType)
|
||||
for _, p := range pathsToType {
|
||||
typeResult := gjson.GetBytes(out, p)
|
||||
|
||||
@@ -8,6 +8,7 @@ package chat_completions
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
@@ -19,6 +20,7 @@ import (
|
||||
// convertCliResponseToOpenAIChatParams holds parameters for response conversion.
|
||||
type convertCliResponseToOpenAIChatParams struct {
|
||||
UnixTimestamp int64
|
||||
FunctionIndex int
|
||||
}
|
||||
|
||||
// ConvertCliResponseToOpenAI translates a single chunk of a streaming response from the
|
||||
@@ -39,6 +41,7 @@ func ConvertCliResponseToOpenAI(_ context.Context, _ string, originalRequestRawJ
|
||||
if *param == nil {
|
||||
*param = &convertCliResponseToOpenAIChatParams{
|
||||
UnixTimestamp: 0,
|
||||
FunctionIndex: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,6 +103,10 @@ func ConvertCliResponseToOpenAI(_ context.Context, _ string, originalRequestRawJ
|
||||
partResult := partResults[i]
|
||||
partTextResult := partResult.Get("text")
|
||||
functionCallResult := partResult.Get("functionCall")
|
||||
inlineDataResult := partResult.Get("inlineData")
|
||||
if !inlineDataResult.Exists() {
|
||||
inlineDataResult = partResult.Get("inline_data")
|
||||
}
|
||||
|
||||
if partTextResult.Exists() {
|
||||
// Handle text content, distinguishing between regular content and reasoning/thoughts.
|
||||
@@ -112,19 +119,52 @@ func ConvertCliResponseToOpenAI(_ context.Context, _ string, originalRequestRawJ
|
||||
} else if functionCallResult.Exists() {
|
||||
// Handle function call content.
|
||||
toolCallsResult := gjson.Get(template, "choices.0.delta.tool_calls")
|
||||
if !toolCallsResult.Exists() || !toolCallsResult.IsArray() {
|
||||
functionCallIndex := (*param).(*convertCliResponseToOpenAIChatParams).FunctionIndex
|
||||
(*param).(*convertCliResponseToOpenAIChatParams).FunctionIndex++
|
||||
if toolCallsResult.Exists() && toolCallsResult.IsArray() {
|
||||
functionCallIndex = len(toolCallsResult.Array())
|
||||
} else {
|
||||
template, _ = sjson.SetRaw(template, "choices.0.delta.tool_calls", `[]`)
|
||||
}
|
||||
|
||||
functionCallTemplate := `{"id": "","type": "function","function": {"name": "","arguments": ""}}`
|
||||
functionCallTemplate := `{"id": "","index": 0,"type": "function","function": {"name": "","arguments": ""}}`
|
||||
fcName := functionCallResult.Get("name").String()
|
||||
functionCallTemplate, _ = sjson.Set(functionCallTemplate, "id", fmt.Sprintf("%s-%d", fcName, time.Now().UnixNano()))
|
||||
functionCallTemplate, _ = sjson.Set(functionCallTemplate, "index", functionCallIndex)
|
||||
functionCallTemplate, _ = sjson.Set(functionCallTemplate, "function.name", fcName)
|
||||
if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() {
|
||||
functionCallTemplate, _ = sjson.Set(functionCallTemplate, "function.arguments", fcArgsResult.Raw)
|
||||
}
|
||||
template, _ = sjson.Set(template, "choices.0.delta.role", "assistant")
|
||||
template, _ = sjson.SetRaw(template, "choices.0.delta.tool_calls.-1", functionCallTemplate)
|
||||
} else if inlineDataResult.Exists() {
|
||||
data := inlineDataResult.Get("data").String()
|
||||
if data == "" {
|
||||
continue
|
||||
}
|
||||
mimeType := inlineDataResult.Get("mimeType").String()
|
||||
if mimeType == "" {
|
||||
mimeType = inlineDataResult.Get("mime_type").String()
|
||||
}
|
||||
if mimeType == "" {
|
||||
mimeType = "image/png"
|
||||
}
|
||||
imageURL := fmt.Sprintf("data:%s;base64,%s", mimeType, data)
|
||||
imagePayload, err := json.Marshal(map[string]any{
|
||||
"type": "image_url",
|
||||
"image_url": map[string]string{
|
||||
"url": imageURL,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
imagesResult := gjson.Get(template, "choices.0.delta.images")
|
||||
if !imagesResult.Exists() || !imagesResult.IsArray() {
|
||||
template, _ = sjson.SetRaw(template, "choices.0.delta.images", `[]`)
|
||||
}
|
||||
template, _ = sjson.Set(template, "choices.0.delta.role", "assistant")
|
||||
template, _ = sjson.SetRaw(template, "choices.0.delta.images.-1", string(imagePayload))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
package chat_completions
|
||||
|
||||
import (
|
||||
. "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
||||
geminiChat "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/openai/chat-completions"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator"
|
||||
)
|
||||
|
||||
func init() {
|
||||
translator.Register(
|
||||
OpenAI,
|
||||
GeminiWeb,
|
||||
geminiChat.ConvertOpenAIRequestToGemini,
|
||||
interfaces.TranslateResponse{
|
||||
Stream: geminiChat.ConvertGeminiResponseToOpenAI,
|
||||
NonStream: geminiChat.ConvertGeminiResponseToOpenAINonStream,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package responses
|
||||
|
||||
import (
|
||||
. "github.com/router-for-me/CLIProxyAPI/v6/internal/constant"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
||||
geminiResponses "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/openai/responses"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/translator/translator"
|
||||
)
|
||||
|
||||
func init() {
|
||||
translator.Register(
|
||||
OpenaiResponse,
|
||||
GeminiWeb,
|
||||
geminiResponses.ConvertOpenAIResponsesRequestToGemini,
|
||||
interfaces.TranslateResponse{
|
||||
Stream: geminiResponses.ConvertGeminiResponseToOpenAIResponses,
|
||||
NonStream: geminiResponses.ConvertGeminiResponseToOpenAIResponsesNonStream,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -26,6 +26,21 @@ import (
|
||||
// - []byte: The transformed request data in Gemini API format
|
||||
func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool) []byte {
|
||||
rawJSON := bytes.Clone(inputRawJSON)
|
||||
var pathsToDelete []string
|
||||
root := gjson.ParseBytes(rawJSON)
|
||||
util.Walk(root, "", "additionalProperties", &pathsToDelete)
|
||||
util.Walk(root, "", "$schema", &pathsToDelete)
|
||||
util.Walk(root, "", "ref", &pathsToDelete)
|
||||
util.Walk(root, "", "strict", &pathsToDelete)
|
||||
|
||||
var err error
|
||||
for _, p := range pathsToDelete {
|
||||
rawJSON, err = sjson.DeleteBytes(rawJSON, p)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Base envelope
|
||||
out := []byte(`{"contents":[],"generationConfig":{"thinkingConfig":{"include_thoughts":true}}}`)
|
||||
|
||||
@@ -65,6 +80,31 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
|
||||
out, _ = sjson.SetBytes(out, "generationConfig.topK", tkr.Num)
|
||||
}
|
||||
|
||||
// Map OpenAI modalities -> Gemini generationConfig.responseModalities
|
||||
// e.g. "modalities": ["image", "text"] -> ["Image", "Text"]
|
||||
if mods := gjson.GetBytes(rawJSON, "modalities"); mods.Exists() && mods.IsArray() {
|
||||
var responseMods []string
|
||||
for _, m := range mods.Array() {
|
||||
switch strings.ToLower(m.String()) {
|
||||
case "text":
|
||||
responseMods = append(responseMods, "Text")
|
||||
case "image":
|
||||
responseMods = append(responseMods, "Image")
|
||||
}
|
||||
}
|
||||
if len(responseMods) > 0 {
|
||||
out, _ = sjson.SetBytes(out, "generationConfig.responseModalities", responseMods)
|
||||
}
|
||||
}
|
||||
|
||||
// OpenRouter-style image_config support
|
||||
// If the input uses top-level image_config.aspect_ratio, map it into generationConfig.imageConfig.aspectRatio.
|
||||
if imgCfg := gjson.GetBytes(rawJSON, "image_config"); imgCfg.Exists() && imgCfg.IsObject() {
|
||||
if ar := imgCfg.Get("aspect_ratio"); ar.Exists() && ar.Type == gjson.String {
|
||||
out, _ = sjson.SetBytes(out, "generationConfig.imageConfig.aspectRatio", ar.Str)
|
||||
}
|
||||
}
|
||||
|
||||
// messages -> systemInstruction + contents
|
||||
messages := gjson.GetBytes(rawJSON, "messages")
|
||||
if messages.IsArray() {
|
||||
@@ -257,7 +297,7 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
|
||||
}
|
||||
|
||||
var pathsToType []string
|
||||
root := gjson.ParseBytes(out)
|
||||
root = gjson.ParseBytes(out)
|
||||
util.Walk(root, "", "type", &pathsToType)
|
||||
for _, p := range pathsToType {
|
||||
typeResult := gjson.GetBytes(out, p)
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
// convertGeminiResponseToOpenAIChatParams holds parameters for response conversion.
|
||||
type convertGeminiResponseToOpenAIChatParams struct {
|
||||
UnixTimestamp int64
|
||||
FunctionIndex int
|
||||
}
|
||||
|
||||
// ConvertGeminiResponseToOpenAI translates a single chunk of a streaming response from the
|
||||
@@ -39,6 +40,7 @@ func ConvertGeminiResponseToOpenAI(_ context.Context, _ string, originalRequestR
|
||||
if *param == nil {
|
||||
*param = &convertGeminiResponseToOpenAIChatParams{
|
||||
UnixTimestamp: 0,
|
||||
FunctionIndex: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,13 +122,18 @@ func ConvertGeminiResponseToOpenAI(_ context.Context, _ string, originalRequestR
|
||||
} else if functionCallResult.Exists() {
|
||||
// Handle function call content.
|
||||
toolCallsResult := gjson.Get(template, "choices.0.delta.tool_calls")
|
||||
if !toolCallsResult.Exists() || !toolCallsResult.IsArray() {
|
||||
functionCallIndex := (*param).(*convertGeminiResponseToOpenAIChatParams).FunctionIndex
|
||||
(*param).(*convertGeminiResponseToOpenAIChatParams).FunctionIndex++
|
||||
if toolCallsResult.Exists() && toolCallsResult.IsArray() {
|
||||
functionCallIndex = len(toolCallsResult.Array())
|
||||
} else {
|
||||
template, _ = sjson.SetRaw(template, "choices.0.delta.tool_calls", `[]`)
|
||||
}
|
||||
|
||||
functionCallTemplate := `{"id": "","type": "function","function": {"name": "","arguments": ""}}`
|
||||
functionCallTemplate := `{"id": "","index": 0,"type": "function","function": {"name": "","arguments": ""}}`
|
||||
fcName := functionCallResult.Get("name").String()
|
||||
functionCallTemplate, _ = sjson.Set(functionCallTemplate, "id", fmt.Sprintf("%s-%d", fcName, time.Now().UnixNano()))
|
||||
functionCallTemplate, _ = sjson.Set(functionCallTemplate, "index", functionCallIndex)
|
||||
functionCallTemplate, _ = sjson.Set(functionCallTemplate, "function.name", fcName)
|
||||
if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() {
|
||||
functionCallTemplate, _ = sjson.Set(functionCallTemplate, "function.arguments", fcArgsResult.Raw)
|
||||
|
||||
@@ -23,9 +23,6 @@ import (
|
||||
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/openai/chat-completions"
|
||||
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini/openai/responses"
|
||||
|
||||
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini-web/openai/chat-completions"
|
||||
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/gemini-web/openai/responses"
|
||||
|
||||
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/openai/claude"
|
||||
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/openai/gemini"
|
||||
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/openai/gemini-cli"
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
|
||||
@@ -100,14 +101,40 @@ func ConvertGeminiRequestToOpenAI(modelName string, inputRawJSON []byte, stream
|
||||
"content": "",
|
||||
}
|
||||
|
||||
var contentParts []string
|
||||
var textBuilder strings.Builder
|
||||
var aggregatedParts []interface{}
|
||||
onlyTextContent := true
|
||||
var toolCalls []interface{}
|
||||
|
||||
if parts.Exists() && parts.IsArray() {
|
||||
parts.ForEach(func(_, part gjson.Result) bool {
|
||||
// Handle text parts
|
||||
if text := part.Get("text"); text.Exists() {
|
||||
contentParts = append(contentParts, text.String())
|
||||
formattedText := text.String()
|
||||
textBuilder.WriteString(formattedText)
|
||||
aggregatedParts = append(aggregatedParts, map[string]interface{}{
|
||||
"type": "text",
|
||||
"text": formattedText,
|
||||
})
|
||||
}
|
||||
|
||||
// Handle inline data (e.g., images)
|
||||
if inlineData := part.Get("inlineData"); inlineData.Exists() {
|
||||
onlyTextContent = false
|
||||
|
||||
mimeType := inlineData.Get("mimeType").String()
|
||||
if mimeType == "" {
|
||||
mimeType = "application/octet-stream"
|
||||
}
|
||||
data := inlineData.Get("data").String()
|
||||
imageURL := fmt.Sprintf("data:%s;base64,%s", mimeType, data)
|
||||
|
||||
aggregatedParts = append(aggregatedParts, map[string]interface{}{
|
||||
"type": "image_url",
|
||||
"image_url": map[string]interface{}{
|
||||
"url": imageURL,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Handle function calls (Gemini) -> tool calls (OpenAI)
|
||||
@@ -175,8 +202,12 @@ func ConvertGeminiRequestToOpenAI(modelName string, inputRawJSON []byte, stream
|
||||
}
|
||||
|
||||
// Set content
|
||||
if len(contentParts) > 0 {
|
||||
msg["content"] = strings.Join(contentParts, "")
|
||||
if len(aggregatedParts) > 0 {
|
||||
if onlyTextContent {
|
||||
msg["content"] = textBuilder.String()
|
||||
} else {
|
||||
msg["content"] = aggregatedParts
|
||||
}
|
||||
}
|
||||
|
||||
// Set tool calls if any
|
||||
|
||||
@@ -89,6 +89,7 @@ type modelStats struct {
|
||||
// RequestDetail stores the timestamp and token usage for a single request.
|
||||
type RequestDetail struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Source string `json:"source"`
|
||||
Tokens TokenStats `json:"tokens"`
|
||||
}
|
||||
|
||||
@@ -188,7 +189,11 @@ func (s *RequestStatistics) Record(ctx context.Context, record coreusage.Record)
|
||||
stats = &apiStats{Models: make(map[string]*modelStats)}
|
||||
s.apis[statsKey] = stats
|
||||
}
|
||||
s.updateAPIStats(stats, modelName, RequestDetail{Timestamp: timestamp, Tokens: detail})
|
||||
s.updateAPIStats(stats, modelName, RequestDetail{
|
||||
Timestamp: timestamp,
|
||||
Source: record.Source,
|
||||
Tokens: detail,
|
||||
})
|
||||
|
||||
s.requestsByDay[dayKey]++
|
||||
s.requestsByHour[hourKey]++
|
||||
|
||||
59
internal/util/image.go
Normal file
59
internal/util/image.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"image"
|
||||
"image/draw"
|
||||
"image/png"
|
||||
)
|
||||
|
||||
func CreateWhiteImageBase64(aspectRatio string) (string, error) {
|
||||
width := 1024
|
||||
height := 1024
|
||||
|
||||
switch aspectRatio {
|
||||
case "1:1":
|
||||
width = 1024
|
||||
height = 1024
|
||||
case "2:3":
|
||||
width = 832
|
||||
height = 1248
|
||||
case "3:2":
|
||||
width = 1248
|
||||
height = 832
|
||||
case "3:4":
|
||||
width = 864
|
||||
height = 1184
|
||||
case "4:3":
|
||||
width = 1184
|
||||
height = 864
|
||||
case "4:5":
|
||||
width = 896
|
||||
height = 1152
|
||||
case "5:4":
|
||||
width = 1152
|
||||
height = 896
|
||||
case "9:16":
|
||||
width = 768
|
||||
height = 1344
|
||||
case "16:9":
|
||||
width = 1344
|
||||
height = 768
|
||||
case "21:9":
|
||||
width = 1536
|
||||
height = 672
|
||||
}
|
||||
|
||||
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
draw.Draw(img, img.Bounds(), image.White, image.Point{}, draw.Src)
|
||||
|
||||
var buf bytes.Buffer
|
||||
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
base64String := base64.StdEncoding.EncodeToString(buf.Bytes())
|
||||
return base64String, nil
|
||||
}
|
||||
@@ -20,37 +20,45 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
// "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/claude"
|
||||
// "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex"
|
||||
// "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini"
|
||||
// "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/qwen"
|
||||
// "github.com/router-for-me/CLIProxyAPI/v6/internal/client"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
// "github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
|
||||
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
||||
log "github.com/sirupsen/logrus"
|
||||
// "github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// storePersister captures persistence-capable token store methods used by the watcher.
|
||||
type storePersister interface {
|
||||
PersistConfig(ctx context.Context) error
|
||||
PersistAuthFiles(ctx context.Context, message string, paths ...string) error
|
||||
}
|
||||
|
||||
type authDirProvider interface {
|
||||
AuthDir() string
|
||||
}
|
||||
|
||||
// Watcher manages file watching for configuration and authentication files
|
||||
type Watcher struct {
|
||||
configPath string
|
||||
authDir string
|
||||
config *config.Config
|
||||
clientsMutex sync.RWMutex
|
||||
reloadCallback func(*config.Config)
|
||||
watcher *fsnotify.Watcher
|
||||
lastAuthHashes map[string]string
|
||||
lastConfigHash string
|
||||
authQueue chan<- AuthUpdate
|
||||
currentAuths map[string]*coreauth.Auth
|
||||
dispatchMu sync.Mutex
|
||||
dispatchCond *sync.Cond
|
||||
pendingUpdates map[string]AuthUpdate
|
||||
pendingOrder []string
|
||||
dispatchCancel context.CancelFunc
|
||||
configPath string
|
||||
authDir string
|
||||
config *config.Config
|
||||
clientsMutex sync.RWMutex
|
||||
reloadCallback func(*config.Config)
|
||||
watcher *fsnotify.Watcher
|
||||
lastAuthHashes map[string]string
|
||||
lastConfigHash string
|
||||
authQueue chan<- AuthUpdate
|
||||
currentAuths map[string]*coreauth.Auth
|
||||
dispatchMu sync.Mutex
|
||||
dispatchCond *sync.Cond
|
||||
pendingUpdates map[string]AuthUpdate
|
||||
pendingOrder []string
|
||||
dispatchCancel context.CancelFunc
|
||||
storePersister storePersister
|
||||
mirroredAuthDir string
|
||||
oldConfigYaml []byte
|
||||
}
|
||||
|
||||
type stableIDGenerator struct {
|
||||
@@ -114,7 +122,6 @@ func NewWatcher(configPath, authDir string, reloadCallback func(*config.Config))
|
||||
if errNewWatcher != nil {
|
||||
return nil, errNewWatcher
|
||||
}
|
||||
|
||||
w := &Watcher{
|
||||
configPath: configPath,
|
||||
authDir: authDir,
|
||||
@@ -123,6 +130,18 @@ func NewWatcher(configPath, authDir string, reloadCallback func(*config.Config))
|
||||
lastAuthHashes: make(map[string]string),
|
||||
}
|
||||
w.dispatchCond = sync.NewCond(&w.dispatchMu)
|
||||
if store := sdkAuth.GetTokenStore(); store != nil {
|
||||
if persister, ok := store.(storePersister); ok {
|
||||
w.storePersister = persister
|
||||
log.Debug("persistence-capable token store detected; watcher will propagate persisted changes")
|
||||
}
|
||||
if provider, ok := store.(authDirProvider); ok {
|
||||
if fixed := strings.TrimSpace(provider.AuthDir()); fixed != "" {
|
||||
w.mirroredAuthDir = fixed
|
||||
log.Debugf("mirrored auth directory locked to %s", fixed)
|
||||
}
|
||||
}
|
||||
}
|
||||
return w, nil
|
||||
}
|
||||
|
||||
@@ -161,6 +180,7 @@ func (w *Watcher) SetConfig(cfg *config.Config) {
|
||||
w.clientsMutex.Lock()
|
||||
defer w.clientsMutex.Unlock()
|
||||
w.config = cfg
|
||||
w.oldConfigYaml, _ = yaml.Marshal(cfg)
|
||||
}
|
||||
|
||||
// SetAuthUpdateQueue sets the queue used to emit auth updates.
|
||||
@@ -336,6 +356,41 @@ func (w *Watcher) stopDispatch() {
|
||||
w.clientsMutex.Unlock()
|
||||
}
|
||||
|
||||
func (w *Watcher) persistConfigAsync() {
|
||||
if w == nil || w.storePersister == nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := w.storePersister.PersistConfig(ctx); err != nil {
|
||||
log.Errorf("failed to persist config change: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (w *Watcher) persistAuthAsync(message string, paths ...string) {
|
||||
if w == nil || w.storePersister == nil {
|
||||
return
|
||||
}
|
||||
filtered := make([]string, 0, len(paths))
|
||||
for _, p := range paths {
|
||||
if trimmed := strings.TrimSpace(p); trimmed != "" {
|
||||
filtered = append(filtered, trimmed)
|
||||
}
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if err := w.storePersister.PersistAuthFiles(ctx, message, filtered...); err != nil {
|
||||
log.Errorf("failed to persist auth changes: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func authEqual(a, b *coreauth.Auth) bool {
|
||||
return reflect.DeepEqual(normalizeAuth(a), normalizeAuth(b))
|
||||
}
|
||||
@@ -440,6 +495,7 @@ func (w *Watcher) handleEvent(event fsnotify.Event) {
|
||||
w.clientsMutex.Lock()
|
||||
w.lastConfigHash = finalHash
|
||||
w.clientsMutex.Unlock()
|
||||
w.persistConfigAsync()
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -472,14 +528,20 @@ 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)
|
||||
if w.mirroredAuthDir != "" {
|
||||
newConfig.AuthDir = w.mirroredAuthDir
|
||||
} else {
|
||||
newConfig.AuthDir = resolvedAuthDir
|
||||
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
|
||||
var oldConfig *config.Config
|
||||
_ = yaml.Unmarshal(w.oldConfigYaml, &oldConfig)
|
||||
w.oldConfigYaml, _ = yaml.Marshal(newConfig)
|
||||
w.config = newConfig
|
||||
w.clientsMutex.Unlock()
|
||||
|
||||
@@ -491,83 +553,16 @@ func (w *Watcher) reloadConfig() bool {
|
||||
log.Debugf("log level updated - debug mode changed from %t to %t", oldConfig.Debug, newConfig.Debug)
|
||||
}
|
||||
|
||||
// Log configuration changes in debug mode
|
||||
// Log configuration changes in debug mode, only when there are material diffs
|
||||
if oldConfig != nil {
|
||||
log.Debugf("config changes detected:")
|
||||
if oldConfig.Port != newConfig.Port {
|
||||
log.Debugf(" port: %d -> %d", oldConfig.Port, newConfig.Port)
|
||||
}
|
||||
if oldConfig.AuthDir != newConfig.AuthDir {
|
||||
log.Debugf(" auth-dir: %s -> %s", oldConfig.AuthDir, newConfig.AuthDir)
|
||||
}
|
||||
if oldConfig.Debug != newConfig.Debug {
|
||||
log.Debugf(" debug: %t -> %t", oldConfig.Debug, newConfig.Debug)
|
||||
}
|
||||
if oldConfig.ProxyURL != newConfig.ProxyURL {
|
||||
log.Debugf(" proxy-url: %s -> %s", oldConfig.ProxyURL, newConfig.ProxyURL)
|
||||
}
|
||||
if oldConfig.RequestLog != newConfig.RequestLog {
|
||||
log.Debugf(" request-log: %t -> %t", oldConfig.RequestLog, newConfig.RequestLog)
|
||||
}
|
||||
if oldConfig.RequestRetry != newConfig.RequestRetry {
|
||||
log.Debugf(" request-retry: %d -> %d", oldConfig.RequestRetry, newConfig.RequestRetry)
|
||||
}
|
||||
if oldConfig.GeminiWeb.Context != newConfig.GeminiWeb.Context {
|
||||
log.Debugf(" gemini-web.context: %t -> %t", oldConfig.GeminiWeb.Context, newConfig.GeminiWeb.Context)
|
||||
}
|
||||
if oldConfig.GeminiWeb.MaxCharsPerRequest != newConfig.GeminiWeb.MaxCharsPerRequest {
|
||||
log.Debugf(" gemini-web.max-chars-per-request: %d -> %d", oldConfig.GeminiWeb.MaxCharsPerRequest, newConfig.GeminiWeb.MaxCharsPerRequest)
|
||||
}
|
||||
if oldConfig.GeminiWeb.DisableContinuationHint != newConfig.GeminiWeb.DisableContinuationHint {
|
||||
log.Debugf(" gemini-web.disable-continuation-hint: %t -> %t", oldConfig.GeminiWeb.DisableContinuationHint, newConfig.GeminiWeb.DisableContinuationHint)
|
||||
}
|
||||
if oldConfig.GeminiWeb.CodeMode != newConfig.GeminiWeb.CodeMode {
|
||||
log.Debugf(" gemini-web.code-mode: %t -> %t", oldConfig.GeminiWeb.CodeMode, newConfig.GeminiWeb.CodeMode)
|
||||
}
|
||||
if len(oldConfig.APIKeys) != len(newConfig.APIKeys) {
|
||||
log.Debugf(" api-keys count: %d -> %d", len(oldConfig.APIKeys), len(newConfig.APIKeys))
|
||||
}
|
||||
if len(oldConfig.GlAPIKey) != len(newConfig.GlAPIKey) {
|
||||
log.Debugf(" generative-language-api-key count: %d -> %d", len(oldConfig.GlAPIKey), len(newConfig.GlAPIKey))
|
||||
}
|
||||
if len(oldConfig.ClaudeKey) != len(newConfig.ClaudeKey) {
|
||||
log.Debugf(" claude-api-key count: %d -> %d", len(oldConfig.ClaudeKey), len(newConfig.ClaudeKey))
|
||||
}
|
||||
if len(oldConfig.CodexKey) != len(newConfig.CodexKey) {
|
||||
log.Debugf(" codex-api-key count: %d -> %d", len(oldConfig.CodexKey), len(newConfig.CodexKey))
|
||||
}
|
||||
if oldConfig.RemoteManagement.AllowRemote != newConfig.RemoteManagement.AllowRemote {
|
||||
log.Debugf(" remote-management.allow-remote: %t -> %t", oldConfig.RemoteManagement.AllowRemote, newConfig.RemoteManagement.AllowRemote)
|
||||
}
|
||||
if oldConfig.RemoteManagement.SecretKey != newConfig.RemoteManagement.SecretKey {
|
||||
switch {
|
||||
case oldConfig.RemoteManagement.SecretKey == "" && newConfig.RemoteManagement.SecretKey != "":
|
||||
log.Debug(" remote-management.secret-key: created")
|
||||
case oldConfig.RemoteManagement.SecretKey != "" && newConfig.RemoteManagement.SecretKey == "":
|
||||
log.Debug(" remote-management.secret-key: deleted")
|
||||
default:
|
||||
log.Debug(" remote-management.secret-key: updated")
|
||||
}
|
||||
if newConfig.RemoteManagement.SecretKey == "" {
|
||||
log.Info("management routes will be disabled after secret key removal")
|
||||
} else {
|
||||
log.Info("management routes will be enabled after secret key update")
|
||||
}
|
||||
}
|
||||
if oldConfig.RemoteManagement.DisableControlPanel != newConfig.RemoteManagement.DisableControlPanel {
|
||||
log.Debugf(" remote-management.disable-control-panel: %t -> %t", oldConfig.RemoteManagement.DisableControlPanel, newConfig.RemoteManagement.DisableControlPanel)
|
||||
}
|
||||
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)
|
||||
details := buildConfigChangeDetails(oldConfig, newConfig)
|
||||
if len(details) > 0 {
|
||||
log.Debugf("config changes detected:")
|
||||
for _, d := range details {
|
||||
log.Debugf(" %s", d)
|
||||
}
|
||||
} else {
|
||||
log.Debugf("no material config field changes detected")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -703,6 +698,7 @@ func (w *Watcher) addOrUpdateClient(path string) {
|
||||
log.Debugf("triggering server update callback after add/update")
|
||||
w.reloadCallback(cfg)
|
||||
}
|
||||
w.persistAuthAsync(fmt.Sprintf("Sync auth %s", filepath.Base(path)), path)
|
||||
}
|
||||
|
||||
// removeClient handles the removal of a single client.
|
||||
@@ -720,6 +716,7 @@ func (w *Watcher) removeClient(path string) {
|
||||
log.Debugf("triggering server update callback after removal")
|
||||
w.reloadCallback(cfg)
|
||||
}
|
||||
w.persistAuthAsync(fmt.Sprintf("Remove auth %s", filepath.Base(path)), path)
|
||||
}
|
||||
|
||||
// SnapshotCombinedClients returns a snapshot of current combined clients.
|
||||
@@ -948,6 +945,11 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
|
||||
id = rel
|
||||
}
|
||||
|
||||
proxyURL := ""
|
||||
if p, ok := metadata["proxy_url"].(string); ok {
|
||||
proxyURL = p
|
||||
}
|
||||
|
||||
a := &coreauth.Auth{
|
||||
ID: id,
|
||||
Provider: provider,
|
||||
@@ -957,6 +959,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
|
||||
"source": full,
|
||||
"path": full,
|
||||
},
|
||||
ProxyURL: proxyURL,
|
||||
Metadata: metadata,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
@@ -1164,3 +1167,138 @@ func openAICompatKey(entry config.OpenAICompatibility, index int) (string, strin
|
||||
}
|
||||
return fmt.Sprintf("index:%d", index), fmt.Sprintf("entry-%d", index+1)
|
||||
}
|
||||
|
||||
// buildConfigChangeDetails computes a redacted, human-readable list of config changes.
|
||||
// It avoids printing secrets (like API keys) and focuses on structural or non-sensitive fields.
|
||||
func buildConfigChangeDetails(oldCfg, newCfg *config.Config) []string {
|
||||
changes := make([]string, 0, 16)
|
||||
if oldCfg == nil || newCfg == nil {
|
||||
return changes
|
||||
}
|
||||
|
||||
// Simple scalars
|
||||
if oldCfg.Port != newCfg.Port {
|
||||
changes = append(changes, fmt.Sprintf("port: %d -> %d", oldCfg.Port, newCfg.Port))
|
||||
}
|
||||
if oldCfg.AuthDir != newCfg.AuthDir {
|
||||
changes = append(changes, fmt.Sprintf("auth-dir: %s -> %s", oldCfg.AuthDir, newCfg.AuthDir))
|
||||
}
|
||||
if oldCfg.Debug != newCfg.Debug {
|
||||
changes = append(changes, fmt.Sprintf("debug: %t -> %t", oldCfg.Debug, newCfg.Debug))
|
||||
}
|
||||
if oldCfg.LoggingToFile != newCfg.LoggingToFile {
|
||||
changes = append(changes, fmt.Sprintf("logging-to-file: %t -> %t", oldCfg.LoggingToFile, newCfg.LoggingToFile))
|
||||
}
|
||||
if oldCfg.UsageStatisticsEnabled != newCfg.UsageStatisticsEnabled {
|
||||
changes = append(changes, fmt.Sprintf("usage-statistics-enabled: %t -> %t", oldCfg.UsageStatisticsEnabled, newCfg.UsageStatisticsEnabled))
|
||||
}
|
||||
if oldCfg.RequestLog != newCfg.RequestLog {
|
||||
changes = append(changes, fmt.Sprintf("request-log: %t -> %t", oldCfg.RequestLog, newCfg.RequestLog))
|
||||
}
|
||||
if oldCfg.RequestRetry != newCfg.RequestRetry {
|
||||
changes = append(changes, fmt.Sprintf("request-retry: %d -> %d", oldCfg.RequestRetry, newCfg.RequestRetry))
|
||||
}
|
||||
if oldCfg.ProxyURL != newCfg.ProxyURL {
|
||||
changes = append(changes, fmt.Sprintf("proxy-url: %s -> %s", oldCfg.ProxyURL, newCfg.ProxyURL))
|
||||
}
|
||||
|
||||
// Quota-exceeded behavior
|
||||
if oldCfg.QuotaExceeded.SwitchProject != newCfg.QuotaExceeded.SwitchProject {
|
||||
changes = append(changes, fmt.Sprintf("quota-exceeded.switch-project: %t -> %t", oldCfg.QuotaExceeded.SwitchProject, newCfg.QuotaExceeded.SwitchProject))
|
||||
}
|
||||
if oldCfg.QuotaExceeded.SwitchPreviewModel != newCfg.QuotaExceeded.SwitchPreviewModel {
|
||||
changes = append(changes, fmt.Sprintf("quota-exceeded.switch-preview-model: %t -> %t", oldCfg.QuotaExceeded.SwitchPreviewModel, newCfg.QuotaExceeded.SwitchPreviewModel))
|
||||
}
|
||||
|
||||
// API keys (redacted) and counts
|
||||
if len(oldCfg.APIKeys) != len(newCfg.APIKeys) {
|
||||
changes = append(changes, fmt.Sprintf("api-keys count: %d -> %d", len(oldCfg.APIKeys), len(newCfg.APIKeys)))
|
||||
} else if !reflect.DeepEqual(trimStrings(oldCfg.APIKeys), trimStrings(newCfg.APIKeys)) {
|
||||
changes = append(changes, "api-keys: values updated (count unchanged, redacted)")
|
||||
}
|
||||
if len(oldCfg.GlAPIKey) != len(newCfg.GlAPIKey) {
|
||||
changes = append(changes, fmt.Sprintf("generative-language-api-key count: %d -> %d", len(oldCfg.GlAPIKey), len(newCfg.GlAPIKey)))
|
||||
} else if !reflect.DeepEqual(trimStrings(oldCfg.GlAPIKey), trimStrings(newCfg.GlAPIKey)) {
|
||||
changes = append(changes, "generative-language-api-key: values updated (count unchanged, redacted)")
|
||||
}
|
||||
|
||||
// Claude keys (do not print key material)
|
||||
if len(oldCfg.ClaudeKey) != len(newCfg.ClaudeKey) {
|
||||
changes = append(changes, fmt.Sprintf("claude-api-key count: %d -> %d", len(oldCfg.ClaudeKey), len(newCfg.ClaudeKey)))
|
||||
} else {
|
||||
for i := range oldCfg.ClaudeKey {
|
||||
if i >= len(newCfg.ClaudeKey) {
|
||||
break
|
||||
}
|
||||
o := oldCfg.ClaudeKey[i]
|
||||
n := newCfg.ClaudeKey[i]
|
||||
if strings.TrimSpace(o.BaseURL) != strings.TrimSpace(n.BaseURL) {
|
||||
changes = append(changes, fmt.Sprintf("claude[%d].base-url: %s -> %s", i, strings.TrimSpace(o.BaseURL), strings.TrimSpace(n.BaseURL)))
|
||||
}
|
||||
if strings.TrimSpace(o.ProxyURL) != strings.TrimSpace(n.ProxyURL) {
|
||||
changes = append(changes, fmt.Sprintf("claude[%d].proxy-url: %s -> %s", i, strings.TrimSpace(o.ProxyURL), strings.TrimSpace(n.ProxyURL)))
|
||||
}
|
||||
if strings.TrimSpace(o.APIKey) != strings.TrimSpace(n.APIKey) {
|
||||
changes = append(changes, fmt.Sprintf("claude[%d].api-key: updated", i))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Codex keys (do not print key material)
|
||||
if len(oldCfg.CodexKey) != len(newCfg.CodexKey) {
|
||||
changes = append(changes, fmt.Sprintf("codex-api-key count: %d -> %d", len(oldCfg.CodexKey), len(newCfg.CodexKey)))
|
||||
} else {
|
||||
for i := range oldCfg.CodexKey {
|
||||
if i >= len(newCfg.CodexKey) {
|
||||
break
|
||||
}
|
||||
o := oldCfg.CodexKey[i]
|
||||
n := newCfg.CodexKey[i]
|
||||
if strings.TrimSpace(o.BaseURL) != strings.TrimSpace(n.BaseURL) {
|
||||
changes = append(changes, fmt.Sprintf("codex[%d].base-url: %s -> %s", i, strings.TrimSpace(o.BaseURL), strings.TrimSpace(n.BaseURL)))
|
||||
}
|
||||
if strings.TrimSpace(o.ProxyURL) != strings.TrimSpace(n.ProxyURL) {
|
||||
changes = append(changes, fmt.Sprintf("codex[%d].proxy-url: %s -> %s", i, strings.TrimSpace(o.ProxyURL), strings.TrimSpace(n.ProxyURL)))
|
||||
}
|
||||
if strings.TrimSpace(o.APIKey) != strings.TrimSpace(n.APIKey) {
|
||||
changes = append(changes, fmt.Sprintf("codex[%d].api-key: updated", i))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remote management (never print the key)
|
||||
if oldCfg.RemoteManagement.AllowRemote != newCfg.RemoteManagement.AllowRemote {
|
||||
changes = append(changes, fmt.Sprintf("remote-management.allow-remote: %t -> %t", oldCfg.RemoteManagement.AllowRemote, newCfg.RemoteManagement.AllowRemote))
|
||||
}
|
||||
if oldCfg.RemoteManagement.DisableControlPanel != newCfg.RemoteManagement.DisableControlPanel {
|
||||
changes = append(changes, fmt.Sprintf("remote-management.disable-control-panel: %t -> %t", oldCfg.RemoteManagement.DisableControlPanel, newCfg.RemoteManagement.DisableControlPanel))
|
||||
}
|
||||
if oldCfg.RemoteManagement.SecretKey != newCfg.RemoteManagement.SecretKey {
|
||||
switch {
|
||||
case oldCfg.RemoteManagement.SecretKey == "" && newCfg.RemoteManagement.SecretKey != "":
|
||||
changes = append(changes, "remote-management.secret-key: created")
|
||||
case oldCfg.RemoteManagement.SecretKey != "" && newCfg.RemoteManagement.SecretKey == "":
|
||||
changes = append(changes, "remote-management.secret-key: deleted")
|
||||
default:
|
||||
changes = append(changes, "remote-management.secret-key: updated")
|
||||
}
|
||||
}
|
||||
|
||||
// OpenAI compatibility providers (summarized)
|
||||
if compat := diffOpenAICompatibility(oldCfg.OpenAICompatibility, newCfg.OpenAICompatibility); len(compat) > 0 {
|
||||
changes = append(changes, "openai-compatibility:")
|
||||
for _, c := range compat {
|
||||
changes = append(changes, " "+c)
|
||||
}
|
||||
}
|
||||
|
||||
return changes
|
||||
}
|
||||
|
||||
func trimStrings(in []string) []string {
|
||||
out := make([]string, len(in))
|
||||
for i := range in {
|
||||
out[i] = strings.TrimSpace(in[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
|
||||
conversation "github.com/router-for-me/CLIProxyAPI/v6/internal/provider/gemini-web/conversation"
|
||||
"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"
|
||||
@@ -49,8 +48,6 @@ type BaseAPIHandler struct {
|
||||
Cfg *config.SDKConfig
|
||||
}
|
||||
|
||||
const geminiWebProvider = "gemini-web"
|
||||
|
||||
// NewBaseAPIHandlers creates a new API handlers instance.
|
||||
// It takes a slice of clients and configuration as input.
|
||||
//
|
||||
@@ -140,7 +137,6 @@ func (h *BaseAPIHandler) ExecuteWithAuthManager(ctx context.Context, handlerType
|
||||
if len(providers) == 0 {
|
||||
return nil, &interfaces.ErrorMessage{StatusCode: http.StatusBadRequest, Error: fmt.Errorf("unknown provider for model %s", modelName)}
|
||||
}
|
||||
metadata := h.buildGeminiWebMetadata(handlerType, providers, rawJSON)
|
||||
req := coreexecutor.Request{
|
||||
Model: modelName,
|
||||
Payload: cloneBytes(rawJSON),
|
||||
@@ -150,7 +146,6 @@ func (h *BaseAPIHandler) ExecuteWithAuthManager(ctx context.Context, handlerType
|
||||
Alt: alt,
|
||||
OriginalRequest: cloneBytes(rawJSON),
|
||||
SourceFormat: sdktranslator.FromString(handlerType),
|
||||
Metadata: metadata,
|
||||
}
|
||||
resp, err := h.AuthManager.Execute(ctx, providers, req, opts)
|
||||
if err != nil {
|
||||
@@ -166,7 +161,6 @@ func (h *BaseAPIHandler) ExecuteCountWithAuthManager(ctx context.Context, handle
|
||||
if len(providers) == 0 {
|
||||
return nil, &interfaces.ErrorMessage{StatusCode: http.StatusBadRequest, Error: fmt.Errorf("unknown provider for model %s", modelName)}
|
||||
}
|
||||
metadata := h.buildGeminiWebMetadata(handlerType, providers, rawJSON)
|
||||
req := coreexecutor.Request{
|
||||
Model: modelName,
|
||||
Payload: cloneBytes(rawJSON),
|
||||
@@ -176,7 +170,6 @@ func (h *BaseAPIHandler) ExecuteCountWithAuthManager(ctx context.Context, handle
|
||||
Alt: alt,
|
||||
OriginalRequest: cloneBytes(rawJSON),
|
||||
SourceFormat: sdktranslator.FromString(handlerType),
|
||||
Metadata: metadata,
|
||||
}
|
||||
resp, err := h.AuthManager.ExecuteCount(ctx, providers, req, opts)
|
||||
if err != nil {
|
||||
@@ -195,7 +188,6 @@ func (h *BaseAPIHandler) ExecuteStreamWithAuthManager(ctx context.Context, handl
|
||||
close(errChan)
|
||||
return nil, errChan
|
||||
}
|
||||
metadata := h.buildGeminiWebMetadata(handlerType, providers, rawJSON)
|
||||
req := coreexecutor.Request{
|
||||
Model: modelName,
|
||||
Payload: cloneBytes(rawJSON),
|
||||
@@ -205,7 +197,6 @@ func (h *BaseAPIHandler) ExecuteStreamWithAuthManager(ctx context.Context, handl
|
||||
Alt: alt,
|
||||
OriginalRequest: cloneBytes(rawJSON),
|
||||
SourceFormat: sdktranslator.FromString(handlerType),
|
||||
Metadata: metadata,
|
||||
}
|
||||
chunks, err := h.AuthManager.ExecuteStream(ctx, providers, req, opts)
|
||||
if err != nil {
|
||||
@@ -241,18 +232,6 @@ func cloneBytes(src []byte) []byte {
|
||||
return dst
|
||||
}
|
||||
|
||||
func (h *BaseAPIHandler) buildGeminiWebMetadata(handlerType string, providers []string, rawJSON []byte) map[string]any {
|
||||
if !util.InArray(providers, geminiWebProvider) {
|
||||
return nil
|
||||
}
|
||||
meta := make(map[string]any)
|
||||
msgs := conversation.ExtractMessages(handlerType, rawJSON)
|
||||
if len(msgs) > 0 {
|
||||
meta[conversation.MetadataMessagesKey] = msgs
|
||||
}
|
||||
return meta
|
||||
}
|
||||
|
||||
// WriteErrorResponse writes an error message to the response writer using the HTTP status embedded in the message.
|
||||
func (h *BaseAPIHandler) WriteErrorResponse(c *gin.Context, msg *interfaces.ErrorMessage) {
|
||||
status := http.StatusInternalServerError
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"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
|
||||
// Gemini Web credentials via the shared Authenticator contract.
|
||||
type GeminiWebAuthenticator struct{}
|
||||
|
||||
func NewGeminiWebAuthenticator() *GeminiWebAuthenticator { return &GeminiWebAuthenticator{} }
|
||||
|
||||
func (a *GeminiWebAuthenticator) Provider() string { return "gemini-web" }
|
||||
|
||||
func (a *GeminiWebAuthenticator) Login(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) {
|
||||
_ = ctx
|
||||
_ = cfg
|
||||
_ = opts
|
||||
return nil, fmt.Errorf("gemini-web authenticator does not support scripted login; use CLI --gemini-web-auth")
|
||||
}
|
||||
|
||||
func (a *GeminiWebAuthenticator) RefreshLead() *time.Duration {
|
||||
d := time.Hour
|
||||
return &d
|
||||
}
|
||||
@@ -13,7 +13,6 @@ func init() {
|
||||
registerRefreshLead("iflow", func() Authenticator { return NewIFlowAuthenticator() })
|
||||
registerRefreshLead("gemini", func() Authenticator { return NewGeminiAuthenticator() })
|
||||
registerRefreshLead("gemini-cli", func() Authenticator { return NewGeminiAuthenticator() })
|
||||
registerRefreshLead("gemini-web", func() Authenticator { return NewGeminiWebAuthenticator() })
|
||||
}
|
||||
|
||||
func registerRefreshLead(provider string, factory func() Authenticator) {
|
||||
|
||||
@@ -285,9 +285,6 @@ func (m *Manager) executeWithProvider(ctx context.Context, provider string, req
|
||||
log.Debugf("Use API key %s for model %s", util.HideAPIKey(accountInfo), req.Model)
|
||||
} else if accountType == "oauth" {
|
||||
log.Debugf("Use OAuth %s for model %s", accountInfo, req.Model)
|
||||
} else if accountType == "cookie" {
|
||||
// Only Gemini Web uses cookie; print stable account label as-is.
|
||||
log.Debugf("Use Cookie %s for model %s", accountInfo, req.Model)
|
||||
}
|
||||
|
||||
tried[auth.ID] = struct{}{}
|
||||
@@ -333,8 +330,6 @@ func (m *Manager) executeCountWithProvider(ctx context.Context, provider string,
|
||||
log.Debugf("Use API key %s for model %s", util.HideAPIKey(accountInfo), req.Model)
|
||||
} else if accountType == "oauth" {
|
||||
log.Debugf("Use OAuth %s for model %s", accountInfo, req.Model)
|
||||
} else if accountType == "cookie" {
|
||||
log.Debugf("Use Cookie %s for model %s", accountInfo, req.Model)
|
||||
}
|
||||
|
||||
tried[auth.ID] = struct{}{}
|
||||
@@ -380,8 +375,6 @@ func (m *Manager) executeStreamWithProvider(ctx context.Context, provider string
|
||||
log.Debugf("Use API key %s for model %s", util.HideAPIKey(accountInfo), req.Model)
|
||||
} else if accountType == "oauth" {
|
||||
log.Debugf("Use OAuth %s for model %s", accountInfo, req.Model)
|
||||
} else if accountType == "cookie" {
|
||||
log.Debugf("Use Cookie %s for model %s", accountInfo, req.Model)
|
||||
}
|
||||
|
||||
tried[auth.ID] = struct{}{}
|
||||
@@ -787,27 +780,31 @@ func (m *Manager) pickNext(ctx context.Context, provider, model string, opts cli
|
||||
return nil, nil, &Error{Code: "executor_not_found", Message: "executor not registered"}
|
||||
}
|
||||
candidates := make([]*Auth, 0, len(m.auths))
|
||||
for _, auth := range m.auths {
|
||||
if auth.Provider != provider || auth.Disabled {
|
||||
for _, candidate := range m.auths {
|
||||
if candidate.Provider != provider || candidate.Disabled {
|
||||
continue
|
||||
}
|
||||
if _, used := tried[auth.ID]; used {
|
||||
if _, used := tried[candidate.ID]; used {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, auth.Clone())
|
||||
candidates = append(candidates, candidate)
|
||||
}
|
||||
m.mu.RUnlock()
|
||||
if len(candidates) == 0 {
|
||||
m.mu.RUnlock()
|
||||
return nil, nil, &Error{Code: "auth_not_found", Message: "no auth available"}
|
||||
}
|
||||
auth, errPick := m.selector.Pick(ctx, provider, model, opts, candidates)
|
||||
selected, errPick := m.selector.Pick(ctx, provider, model, opts, candidates)
|
||||
if errPick != nil {
|
||||
m.mu.RUnlock()
|
||||
return nil, nil, errPick
|
||||
}
|
||||
if auth == nil {
|
||||
if selected == nil {
|
||||
m.mu.RUnlock()
|
||||
return nil, nil, &Error{Code: "auth_not_found", Message: "selector returned no auth"}
|
||||
}
|
||||
return auth, executor, nil
|
||||
authCopy := selected.Clone()
|
||||
m.mu.RUnlock()
|
||||
return authCopy, executor, nil
|
||||
}
|
||||
|
||||
func (m *Manager) persist(ctx context.Context, auth *Auth) error {
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
conversation "github.com/router-for-me/CLIProxyAPI/v6/internal/provider/gemini-web/conversation"
|
||||
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
geminiWebProviderKey = "gemini-web"
|
||||
)
|
||||
|
||||
type geminiWebStickySelector struct {
|
||||
base Selector
|
||||
}
|
||||
|
||||
func NewGeminiWebStickySelector(base Selector) Selector {
|
||||
if selector, ok := base.(*geminiWebStickySelector); ok {
|
||||
return selector
|
||||
}
|
||||
if base == nil {
|
||||
base = &RoundRobinSelector{}
|
||||
}
|
||||
return &geminiWebStickySelector{base: base}
|
||||
}
|
||||
|
||||
func (m *Manager) EnableGeminiWebStickySelector() {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, ok := m.selector.(*geminiWebStickySelector); ok {
|
||||
return
|
||||
}
|
||||
m.selector = NewGeminiWebStickySelector(m.selector)
|
||||
}
|
||||
|
||||
func (s *geminiWebStickySelector) Pick(ctx context.Context, provider, model string, opts cliproxyexecutor.Options, auths []*Auth) (*Auth, error) {
|
||||
if !strings.EqualFold(provider, geminiWebProviderKey) {
|
||||
if opts.Metadata != nil {
|
||||
delete(opts.Metadata, conversation.MetadataMatchKey)
|
||||
}
|
||||
return s.base.Pick(ctx, provider, model, opts, auths)
|
||||
}
|
||||
|
||||
messages := extractGeminiWebMessages(opts.Metadata)
|
||||
if len(messages) >= 2 {
|
||||
normalizedModel := conversation.NormalizeModel(model)
|
||||
candidates := conversation.BuildLookupHashes(normalizedModel, messages)
|
||||
for _, candidate := range candidates {
|
||||
record, ok, err := conversation.LookupMatch(candidate.Hash)
|
||||
if err != nil {
|
||||
log.Warnf("gemini-web selector: lookup failed for hash %s: %v", candidate.Hash, err)
|
||||
continue
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
label := strings.TrimSpace(record.AccountLabel)
|
||||
if label == "" {
|
||||
continue
|
||||
}
|
||||
auth := findAuthByLabel(auths, label)
|
||||
if auth != nil {
|
||||
if opts.Metadata != nil {
|
||||
opts.Metadata[conversation.MetadataMatchKey] = &conversation.MatchResult{
|
||||
Hash: candidate.Hash,
|
||||
Record: record,
|
||||
Model: normalizedModel,
|
||||
}
|
||||
}
|
||||
return auth, nil
|
||||
}
|
||||
_ = conversation.RemoveMatchForLabel(candidate.Hash, label)
|
||||
}
|
||||
}
|
||||
|
||||
return s.base.Pick(ctx, provider, model, opts, auths)
|
||||
}
|
||||
|
||||
func extractGeminiWebMessages(metadata map[string]any) []conversation.Message {
|
||||
if metadata == nil {
|
||||
return nil
|
||||
}
|
||||
raw, ok := metadata[conversation.MetadataMessagesKey]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
switch v := raw.(type) {
|
||||
case []conversation.Message:
|
||||
return v
|
||||
case *[]conversation.Message:
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
return *v
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func findAuthByLabel(auths []*Auth, label string) *Auth {
|
||||
if len(auths) == 0 {
|
||||
return nil
|
||||
}
|
||||
normalized := strings.ToLower(strings.TrimSpace(label))
|
||||
for _, auth := range auths {
|
||||
if auth == nil {
|
||||
continue
|
||||
}
|
||||
if strings.ToLower(strings.TrimSpace(auth.Label)) == normalized {
|
||||
return auth
|
||||
}
|
||||
if auth.Metadata != nil {
|
||||
if v, ok := auth.Metadata["label"].(string); ok && strings.ToLower(strings.TrimSpace(v)) == normalized {
|
||||
return auth
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -134,24 +134,6 @@ 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 {
|
||||
if v, ok := a.Metadata["label"].(string); ok && strings.TrimSpace(v) != "" {
|
||||
return "cookie", strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
// Minimal fallback to cookie value for backward compatibility
|
||||
if a.Metadata != nil {
|
||||
if v, ok := a.Metadata["secure_1psid"].(string); ok && v != "" {
|
||||
return "cookie", v
|
||||
}
|
||||
if v, ok := a.Metadata["__Secure-1PSID"].(string); ok && v != "" {
|
||||
return "cookie", v
|
||||
}
|
||||
}
|
||||
}
|
||||
// For Gemini CLI, include project ID in the OAuth account info if present.
|
||||
if strings.ToLower(a.Provider) == "gemini-cli" {
|
||||
if a.Metadata != nil {
|
||||
|
||||
@@ -14,8 +14,6 @@ import (
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/api"
|
||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||
geminiwebclient "github.com/router-for-me/CLIProxyAPI/v6/internal/provider/gemini-web"
|
||||
conversation "github.com/router-for-me/CLIProxyAPI/v6/internal/provider/gemini-web/conversation"
|
||||
"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"
|
||||
@@ -207,23 +205,6 @@ func (s *Service) applyCoreAuthRemoval(ctx context.Context, id string) {
|
||||
}
|
||||
GlobalModelRegistry().UnregisterClient(id)
|
||||
if existing, ok := s.coreManager.GetByID(id); ok && existing != nil {
|
||||
if strings.EqualFold(existing.Provider, "gemini-web") {
|
||||
// Prefer the stable cookie label stored in metadata when available.
|
||||
var label string
|
||||
if existing.Metadata != nil {
|
||||
if v, ok := existing.Metadata["label"].(string); ok {
|
||||
label = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
if label == "" {
|
||||
label = strings.TrimSpace(existing.Label)
|
||||
}
|
||||
if label != "" {
|
||||
if err := conversation.RemoveMatchesByLabel(label); err != nil {
|
||||
log.Debugf("failed to remove gemini web sticky entries for %s: %v", label, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
existing.Disabled = true
|
||||
existing.Status = coreauth.StatusDisabled
|
||||
if _, err := s.coreManager.Update(ctx, existing); err != nil {
|
||||
@@ -271,9 +252,6 @@ func (s *Service) ensureExecutorsForAuth(a *coreauth.Auth) {
|
||||
s.coreManager.RegisterExecutor(executor.NewGeminiExecutor(s.cfg))
|
||||
case "gemini-cli":
|
||||
s.coreManager.RegisterExecutor(executor.NewGeminiCLIExecutor(s.cfg))
|
||||
case "gemini-web":
|
||||
s.coreManager.RegisterExecutor(executor.NewGeminiWebExecutor(s.cfg))
|
||||
s.coreManager.EnableGeminiWebStickySelector()
|
||||
case "claude":
|
||||
s.coreManager.RegisterExecutor(executor.NewClaudeExecutor(s.cfg))
|
||||
case "codex":
|
||||
@@ -291,6 +269,17 @@ func (s *Service) ensureExecutorsForAuth(a *coreauth.Auth) {
|
||||
}
|
||||
}
|
||||
|
||||
// rebindExecutors refreshes provider executors so they observe the latest configuration.
|
||||
func (s *Service) rebindExecutors() {
|
||||
if s == nil || s.coreManager == nil {
|
||||
return
|
||||
}
|
||||
auths := s.coreManager.List()
|
||||
for _, auth := range auths {
|
||||
s.ensureExecutorsForAuth(auth)
|
||||
}
|
||||
}
|
||||
|
||||
// Run starts the service and blocks until the context is cancelled or the server stops.
|
||||
// It initializes all components including authentication, file watching, HTTP server,
|
||||
// and starts processing requests. The method blocks until the context is cancelled.
|
||||
@@ -389,6 +378,7 @@ func (s *Service) Run(ctx context.Context) error {
|
||||
s.cfgMu.Lock()
|
||||
s.cfg = newCfg
|
||||
s.cfgMu.Unlock()
|
||||
s.rebindExecutors()
|
||||
|
||||
}
|
||||
|
||||
@@ -524,8 +514,6 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) {
|
||||
models = registry.GetGeminiModels()
|
||||
case "gemini-cli":
|
||||
models = registry.GetGeminiCLIModels()
|
||||
case "gemini-web":
|
||||
models = geminiwebclient.GetGeminiWebAliasedModels()
|
||||
case "claude":
|
||||
models = registry.GetClaudeModels()
|
||||
case "codex":
|
||||
|
||||
@@ -14,6 +14,7 @@ type Record struct {
|
||||
Model string
|
||||
APIKey string
|
||||
AuthID string
|
||||
Source string
|
||||
RequestedAt time.Time
|
||||
Detail Detail
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user