Compare commits

...

70 Commits

Author SHA1 Message Date
Luis Pater
eb2549a782 fix(gemini): update response template to omit finishReason until known 2025-10-16 06:41:04 +08:00
Luis Pater
c419264a70 fix(responses): handle empty and invalid rawJSON in ConvertOpenAIChatCompletionsResponseToOpenAIResponses 2025-10-16 06:34:00 +08:00
Luis Pater
6b23e2da74 feat(claude): add Claude 4.5 Haiku model definition 2025-10-16 04:53:07 +08:00
Luis Pater
5ab0854b5b fix(claude): track message_start event in streaming response
Add a `MessageStarted` flag to `ConvertOpenAIResponseToAnthropicParams` to ensure the `message_start` event is emitted only once during streaming.
Refactor response handling to detect streaming mode via the `stream` field instead of the `object` type, simplifying the branching logic.
Update the streaming conversion to set `MessageStarted` after sending the `message_start` event, preventing duplicate starts.
These changes improve correctness of streaming response handling for Claude integration.
2025-10-16 03:54:48 +08:00
Adamcf
15981aa412 fix: add Claude→Claude passthrough to prevent SSE event fragmentation
When from==to (Claude→Claude scenario), directly forward SSE stream
line-by-line without invoking TranslateStream. This preserves the
multi-line SSE event structure (event:/data:/blank) and prevents
JSON parsing errors caused by event fragmentation.

Resolves: JSON parsing error when using Claude Code streaming responses

fix: correct SSE event formatting in Handler layer

Remove duplicate newline additions (\n\n) that were breaking SSE event format.
The Executor layer already provides properly formatted SSE chunks with correct
line endings, so the Handler should forward them as-is without modification.

Changes:
- Remove redundant \n\n addition after each chunk
- Add len(chunk) > 0 check before writing
- Format error messages as proper SSE events (event: error\ndata: {...}\n\n)
- Add chunkIdx counter for future debugging needs

This fixes JSON parsing errors caused by malformed SSE event streams.

fix: update comments for clarity in SSE event forwarding
2025-10-15 22:13:44 +08:00
Luis Pater
ac4f52c532 Merge pull request #127 from router-for-me/usage
fix(server): snapshot config with YAML to handle in-place mutations
2025-10-15 21:39:44 +08:00
hkfires
84fa497169 fix(server): snapshot config with YAML to handle in-place mutations
- Add oldConfigYaml to store previous config snapshot
- Rebuild oldCfg from YAML in UpdateClients for reliable change detection
- Initialize and refresh snapshot on startup and after updates
- Prevents change detection bugs when Management API mutates cfg in place
- Import gopkg.in/yaml.v3
2025-10-15 18:26:23 +08:00
Luis Pater
b641d90287 Fixed #91
refactor(translator): streamline Codex response handling and remove redundant code

- Updated `ConvertCodexResponseToOpenAIResponses` logic for clarity and consistency.
- Simplified `ConvertCodexResponseToOpenAIResponsesNonStream` by removing unnecessary buffer setup and scanner logic.
- Switched to using `sjson.SetRaw` for improved processing of raw input strings.
2025-10-15 12:58:18 +08:00
Luis Pater
32d01a6a7c Merge pull request #125 from router-for-me/object
add S3-compatible object store
2025-10-15 11:52:54 +08:00
hkfires
9ef76dcc61 Add Object Storage 2025-10-15 11:47:35 +08:00
Luis Pater
4576f9915b Fixed: #121
feat(translator): map Claude web search tool type to Codex web_search

- Added special handling to replace `web_search_20250305` tool type with `{"type":"web_search"}` in Claude request processing.
2025-10-15 09:32:12 +08:00
Luis Pater
c945e35983 feat(translator): improve Claude request handling with enhanced content processing
- Introduced helper functions (`appendTextContent`, `appendImageContent`, etc.) for structured content construction.
- Refactored message generation logic for better clarity, supporting mixed content scenarios (text, images, and function calls).
- Added `flushMessage` to ensure proper grouping of message contents.
2025-10-14 23:58:37 +08:00
hkfires
1cd275f4c1 Merge branch 'dev' 2025-10-14 15:47:39 +08:00
hkfires
4bc1ed6031 feat(config): use block style for YAML maps/lists; keep [] for empty 2025-10-14 15:43:58 +08:00
hkfires
78989d6c0d feat(store)!: Lock AuthDir when use gitstore/pgstore 2025-10-14 15:43:58 +08:00
hkfires
d6aa1e5ba0 fix(postgresstore): normalize config line endings for DB/disk writes 2025-10-14 15:43:58 +08:00
hkfires
50c1c50dbd docs: document PostgreSQL-backed config/token store 2025-10-14 15:43:58 +08:00
hkfires
5123cfd47e feat(store): add PostgreSQL-backed config store with env selection 2025-10-14 15:43:58 +08:00
Chén Mù
9072accc43 Merge pull request #118 from router-for-me/config
feat(config): use block style for YAML maps/lists
2025-10-14 13:44:00 +08:00
hkfires
0d8134aabe feat(config): use block style for YAML maps/lists; keep [] for empty 2025-10-14 13:17:04 +08:00
Chén Mù
4fdbdf7925 Merge pull request #117 from router-for-me/pg
feat(store): add PostgreSQL-backed config store with env selection
2025-10-14 11:28:19 +08:00
hkfires
50c84485c3 feat(store)!: Lock AuthDir when use gitstore/pgstore 2025-10-14 10:46:45 +08:00
hkfires
f335aeeedb fix(postgresstore): normalize config line endings for DB/disk writes 2025-10-14 08:38:15 +08:00
Luis Pater
32a8102d71 feat(usage): add support for tracking request source in usage records
- Introduced `Source` field to usage-related structs for better origin tracking.
- Updated `newUsageReporter` to resolve and populate the `Source` attribute.
- Implemented `resolveUsageSource` to determine source from auth metadata or API key.
2025-10-14 02:11:43 +08:00
hkfires
61f6a612e3 docs: document PostgreSQL-backed config/token store 2025-10-13 22:31:01 +08:00
hkfires
42087d5387 feat(store): add PostgreSQL-backed config store with env selection 2025-10-13 21:05:43 +08:00
Luis Pater
f2710c03ab Merge pull request #116 from router-for-me/log
fix(management,config,watcher): treat empty base-url as removal; improve config change logs
2025-10-13 20:48:33 +08:00
hkfires
39abde2413 refactor(watcher): remove redundant quota-exceeded change logs 2025-10-13 14:02:55 +08:00
hkfires
0aa8706ef7 feat(config): Treat empty BaseURL for Codex keys as deletion 2025-10-13 13:48:27 +08:00
hkfires
5fd4a8b974 feat(config): Remove OpenAI providers with empty BaseURL 2025-10-13 13:48:27 +08:00
hkfires
06e6f0a5f2 refactor(watcher): Extract config change logging to new function 2025-10-13 13:48:27 +08:00
Luis Pater
80f6d6fe7c chore(watcher): add YAML serialization for config change tracking and improve quota-exceeded debug logs 2025-10-13 13:32:43 +08:00
Luis Pater
3be6175aec chore(auth): add debug log for iflow token response body 2025-10-13 09:12:45 +08:00
Luis Pater
599986495b feat(translator): enhance OpenAI Gemini request handling for mixed content
- Replaced `contentParts` with `aggregatedParts` to support mixed content (text and inline data).
- Introduced `textBuilder` for efficient text concatenation.
- Added support for inline data processing, including base64-encoded image URLs.
- Updated `msg["content"]` logic to handle both plain text and mixed content scenarios.
2025-10-13 02:15:55 +08:00
Luis Pater
cb83985cc7 chore(server): remove debug println statement from server.go 2025-10-12 23:58:50 +08:00
Luis Pater
6ec028808f docs(readme): add MANAGEMENT_PASSWORD environment variable documentation
- Updated environment variable table in both English (README.md) and Chinese (README_CN.md) to include `MANAGEMENT_PASSWORD`.
2025-10-12 23:06:20 +08:00
Luis Pater
71faa19bb4 Merge pull request #114 from router-for-me/management
feat(managementasset): Authenticate GitHub API requests
2025-10-12 21:40:42 +08:00
hkfires
b5ad978d44 feat(managementasset): Authenticate GitHub API requests 2025-10-12 21:21:51 +08:00
Luis Pater
0508c9fbce Merge pull request #113 from sususu98/main
chore: update .gitignore include .env
2025-10-12 18:21:59 +08:00
Luis Pater
92bb642e98 docs(readme): document Git-backed configuration and token store setup
- Added instructions for configuring a Git repository as a backend for `config.yaml` and token storage.
- Included example environment variable configurations for Docker and Docker Compose.
- Updated both English (README.md) and Chinese (README_CN.md) documentation.
2025-10-12 13:23:11 +08:00
sususu
af82855bed chore: update .gitignore include .env 2025-10-12 07:16:11 +02:00
Luis Pater
a83978f769 feat(store): introduce GitTokenStore for token persistence via Git backend
- Added `GitTokenStore` to handle token storage and metadata using Git as a backing storage.
- Implemented methods for initialization, save, retrieval, listing, and deletion of auth files.
- Updated `go.mod` and `go.sum` to include new dependencies for Git integration.
- Integrated support for Git-backed configuration via `GitTokenStore`.
- Updated server logic to clone, initialize, and manage configurations from Git repositories.
- Added helper functions for verifying and synchronizing configuration files.
- Improved error handling and contextual logging for Git operations.
- Modified Dockerfile to include `config.example.yaml` for initial setup.
- Added `gitCommitter` interface to handle Git-based commit and push operations.
- Configured `Watcher` to detect and leverage Git-backed token stores.
- Implemented `commitConfigAsync` and `commitAuthAsync` methods for asynchronous change synchronization.
- Enhanced `GitTokenStore` with `CommitPaths` method to support selective file commits.
2025-10-12 13:13:31 +08:00
Luis Pater
2513d908be Merge pull request #111 from router-for-me/cloud
fix(server): Handle empty/invalid config in cloud deploy mode
2025-10-11 22:40:51 +08:00
hkfires
4c033b3af7 feat(config): disable logging and usage stats by default 2025-10-11 22:11:08 +08:00
hkfires
843a81f68d fix(server): Handle empty/invalid config in cloud deploy mode 2025-10-11 22:07:08 +08:00
Luis Pater
f6e713ab6b Merge pull request #110 from router-for-me/cloud
feat(config): Gracefully handle empty or invalid optional config
2025-10-11 21:22:10 +08:00
Luis Pater
1834c65116 Merge pull request #107 from router-for-me/gemini-web
Remove Gemini Web
2025-10-11 21:14:15 +08:00
hkfires
fc6aa8ef77 feat(config): Gracefully handle empty or invalid optional config 2025-10-11 20:49:15 +08:00
hkfires
c3f88126e6 refactor(provider): remove Gemini Web cookie-based support 2025-10-11 12:56:07 +08:00
hkfires
b895018ff5 refactor(provider): remove Gemini Web cookie-based provider 2025-10-11 12:53:03 +08:00
Luis Pater
9c6832cc22 Update LICENSE to reflect extended copyright ownership 2025-10-11 08:46:04 +08:00
Luis Pater
1ada33ab1d Merge pull request #104 from router-for-me/cloud
Add Cloud Deploy Mode
2025-10-10 20:23:11 +08:00
hkfires
78738ca3f0 fix(config): treat directory as absent for optional config in cloud deploy mode 2025-10-10 19:40:02 +08:00
hkfires
ac01c74c02 feat(server): Add cloud deploy mode 2025-10-10 18:52:43 +08:00
Luis Pater
02e28bbbe9 feat(watcher): add support for proxy_url in auth metadata
- Extracted and assigned `proxy_url` from metadata to `Auth.ProxyURL`.
2025-10-10 10:20:33 +08:00
Luis Pater
b9c7b9eea5 docs: add Homebrew installation instructions to README and README_CN
- Updated both English and Chinese documentation with steps to install and start `cliproxyapi` via Homebrew.
2025-10-10 04:38:01 +08:00
Luis Pater
57195fa0f5 feat(managementasset): enforce 3-hour rate limit on management asset update checks
- Introduced synchronization with `sync.Mutex` to ensure thread safety.
- Added logic to skip update checks if the last check was performed within the 3-hour interval.
2025-10-10 04:23:58 +08:00
Luis Pater
11f090c223 Fixed #102
feat(translator): add support for removing `strict` in Gemini request transformation

- Updated API and CLI translators to remove the `strict` path during request transformation, in addition to existing predefined JSON paths.
2025-10-10 02:59:21 +08:00
Luis Pater
829dd06b42 feat(cliproxy/auth): restructure auth candidate selection and ensure synchronization
- Refactored candidate selection logic in `auth/manager.go`.
- Ensured proper synchronization around `mu.RUnlock` to prevent racing conditions.
2025-10-10 02:35:15 +08:00
Luis Pater
20787cd107 feat(registry, executor, util): add support for gemini-2.5-flash-image-preview and improve aspect ratio handling
- Introduced `gemini-2.5-flash-image-preview` model to the registry with updated definitions.
- Enhanced Gemini CLI and API executors to handle image aspect ratio adjustments for the new model.
- Added utility function to create base64 white image placeholders based on aspect ratio configurations.
2025-10-10 01:49:58 +08:00
Luis Pater
1aa568ce45 docs: document api-keys usage in README and README_CN
- Added explanation and examples for `api-keys` configuration.
- Updated both English and Chinese documentation.
2025-10-09 23:36:11 +08:00
Luis Pater
b2cdbbdd47 feat(registry, executor): add support for glm-4.6 model and enhance Gemini CLI token handling
- Added `glm-4.6` model to registry and documentation.
- Updated Gemini CLI executor to pass configuration to `prepareGeminiCLITokenSource` for improved token management.
2025-10-09 20:57:18 +08:00
Luis Pater
8056af42a3 Merge pull request #99 from router-for-me/banana
feat(translator): Add support for openrouter image_config
2025-10-09 20:16:09 +08:00
hkfires
01be94a0de feat(translator): Map OpenAI modalities to Gemini responseModalities 2025-10-09 19:38:07 +08:00
hkfires
d1933075c3 Revert "feat(translator): Pass through imageConfig" 2025-10-09 16:35:08 +08:00
hkfires
a602ae859b feat(translator): Add support for openrouter image_config 2025-10-09 15:47:06 +08:00
hkfires
c5d7137d66 feat(translator): Pass through imageConfig 2025-10-09 13:50:43 +08:00
Luis Pater
d45ebff66b feat(registry, executor): add support for gemini-2.5-flash-image model
- Introduced `gemini-2.5-flash-image` model with updated definitions in registry.
- Enhanced model marker detection in Gemini CLI executor to include support for the new model.
2025-10-09 10:06:10 +08:00
Luis Pater
d6f671250e Fixed: #97
feat(translator): enhance request and response parsing for Gemini API and CLI

- Added support for removing predefined JSON paths (`additionalProperties`, `$schema`, `ref`) during request transformation for Gemini.
- Introduced `FunctionIndex` parameter to manage function call indexing in streaming responses for both API and CLI translators.
- Improved handling of tool call content and function call templates in response parsing logic.
2025-10-08 23:49:21 +08:00
Luis Pater
6d822cf309 fix(access): rebuild providers for specific AccessProviderTypeConfigAPIKey changes
- Added logic to force rebuild when provider type matches `AccessProviderTypeConfigAPIKey`.
2025-10-08 19:43:42 +08:00
75 changed files with 3987 additions and 4949 deletions

View File

@@ -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/*

34
.env.example Normal file
View File

@@ -0,0 +1,34 @@
# Example environment configuration for CLIProxyAPI.
# Copy this file to `.env` and uncomment the variables you need.
#
# NOTE: Environment variables are only required when using remote storage options.
# For local file-based storage (default), no environment variables need to be set.
# ------------------------------------------------------------------------------
# Management Web UI
# ------------------------------------------------------------------------------
# MANAGEMENT_PASSWORD=change-me-to-a-strong-password
# ------------------------------------------------------------------------------
# Postgres Token Store (optional)
# ------------------------------------------------------------------------------
# PGSTORE_DSN=postgresql://user:pass@localhost:5432/cliproxy
# PGSTORE_SCHEMA=public
# PGSTORE_LOCAL_PATH=/var/lib/cliproxy
# ------------------------------------------------------------------------------
# Git-Backed Config Store (optional)
# ------------------------------------------------------------------------------
# GITSTORE_GIT_URL=https://github.com/your-org/cli-proxy-config.git
# GITSTORE_GIT_USERNAME=git-user
# GITSTORE_GIT_TOKEN=ghp_your_personal_access_token
# GITSTORE_LOCAL_PATH=/data/cliproxy/gitstore
# ------------------------------------------------------------------------------
# Object Store Token Store (optional)
# ------------------------------------------------------------------------------
# OBJECTSTORE_ENDPOINT=https://s3.your-cloud.example.com
# OBJECTSTORE_BUCKET=cli-proxy-config
# OBJECTSTORE_ACCESS_KEY=your_access_key
# OBJECTSTORE_SECRET_KEY=your_secret_key
# OBJECTSTORE_LOCAL_PATH=/data/cliproxy/objectstore

31
.gitignore vendored
View File

@@ -1,17 +1,32 @@
# Binaries
cli-proxy-api
*.exe
# Configuration
config.yaml
.env
# Generated content
bin/*
docs/*
logs/*
conv/*
temp/*
pgstore/*
gitstore/*
objectstore/*
static/*
# Authentication data
auths/*
!auths/.gitkeep
.vscode/*
.claude/*
.serena/*
# Documentation
docs/*
AGENTS.md
CLAUDE.md
GEMINI.md
*.exe
temp/*
cli-proxy-api
static/*
# Tooling metadata
.vscode/*
.claude/*
.serena/*

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

139
README.md
View File

@@ -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,7 @@ 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
@@ -277,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
@@ -326,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.gem-mode` | string | "" | Selects a predefined Gem to attach for Gemini Web requests; allowed values: `coding-partner`, `writing-editor`. When empty, no Gem is attached. |
| `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
@@ -355,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
@@ -375,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
gem-mode: "" # Select Gem: "coding-partner" or "writing-editor"; empty means no Gem
max-chars-per-request: 1000000 # Max characters per request
# API keys for official Generative Language API
generative-language-api-key:
- "AIzaSy...01"
@@ -419,6 +413,70 @@ 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.
### Object Storage-backed Configuration and Token Store
An S3-compatible object storage service can host configuration and authentication records.
**Environment Variables**
| Variable | Required | Default | Description |
|--------------------------|----------|--------------------------------|--------------------------------------------------------------------------------------------------------------------------|
| `OBJECTSTORE_ENDPOINT` | Yes | | Object storage endpoint. Include `http://` or `https://` to force the protocol (omitted scheme → HTTPS). |
| `OBJECTSTORE_BUCKET` | Yes | | Bucket that stores `config/config.yaml` and `auths/*.json`. |
| `OBJECTSTORE_ACCESS_KEY` | Yes | | Access key ID for the object storage account. |
| `OBJECTSTORE_SECRET_KEY` | Yes | | Secret key for the object storage account. |
| `OBJECTSTORE_LOCAL_PATH` | No | Current working directory | Root directory for the local mirror; the server writes to `<value>/objectstore`. If unset, defaults to current CWD. |
**How it Works**
1. **Startup:** The endpoint is parsed (respecting any scheme prefix), a MinIO-compatible client is created in path-style mode, and the bucket is created when missing.
2. **Local Mirror:** A writable cache at `<OBJECTSTORE_LOCAL_PATH or CWD>/objectstore` mirrors `config/config.yaml` and `auths/`.
3. **Bootstrapping:** When `config/config.yaml` is absent in the bucket, the server copies `config.example.yaml`, uploads it, and uses it as the initial configuration.
4. **Sync:** Changes to configuration or auth files are uploaded to the bucket, and remote updates are mirrored back to disk, keeping watchers and management APIs in sync.
### OpenAI Compatibility Providers
Configure upstream OpenAI-compatible providers (e.g., OpenRouter) via `openai-compatibility`.
@@ -591,12 +649,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
@@ -627,6 +679,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:
@@ -642,6 +706,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.
@@ -667,10 +752,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

View File

@@ -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。
- OpenAICodex/GPTOAuth
```bash
./cli-proxy-api --codex-login
@@ -270,6 +269,7 @@ 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
@@ -289,6 +289,7 @@ console.log(await claudeResponse.json());
- deepseek-v3
- kimi-k2
- glm-4.5
- glm-4.6
- tstars2.0
- 以及其他 iFlow 支持的模型
- Gemini 模型在需要时自动切换到对应的 preview 版本
@@ -338,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.gem-mode` | string | "" | 选择要附加的预设 Gem`coding-partner` 或 `writing-editor`);为空表示不附加。 |
| `gemini-web.max-chars-per-request` | integer | 1,000,000 | 单次请求发送给 Gemini Web 的最大字符数。 |
| `gemini-web.disable-continuation-hint` | boolean | false | 当提示被拆分时,是否禁用连续提示的暗示。 |
### 配置文件示例
@@ -366,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
@@ -387,12 +388,6 @@ quota-exceeded:
switch-project: true # 当配额超限时是否自动切换到另一个项目
switch-preview-model: true # 当配额超限时是否自动切换到预览模型
# Gemini Web 客户端配置
gemini-web:
context: true # 启用会话上下文重用
gem-mode: "" # 选择 Gem"coding-partner" 或 "writing-editor";为空表示不附加
max-chars-per-request: 1000000 # 单次请求最大字符数
# AIStduio Gemini API 的 API 密钥
generative-language-api-key:
- "AIzaSy...01"
@@ -431,6 +426,70 @@ 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同时数据库中的内容也会反向同步至本地镜像便于文件监听与管理接口继续工作。
### 对象存储驱动的配置与令牌存储
可以选择使用 S3 兼容的对象存储来托管配置与鉴权数据。
**环境变量**
| 变量 | 是否必填 | 默认值 | 说明 |
|--------------------------|----------|--------------------|--------------------------------------------------------------------------------------------------------------------------|
| `OBJECTSTORE_ENDPOINT` | 是 | | 对象存储访问端点。可带 `http://` 或 `https://` 前缀指定协议(省略则默认 HTTPS。 |
| `OBJECTSTORE_BUCKET` | 是 | | 用于存放 `config/config.yaml` 与 `auths/*.json` 的 Bucket 名称。 |
| `OBJECTSTORE_ACCESS_KEY` | 是 | | 对象存储账号的访问密钥 ID。 |
| `OBJECTSTORE_SECRET_KEY` | 是 | | 对象存储账号的访问密钥 Secret。 |
| `OBJECTSTORE_LOCAL_PATH` | 否 | 当前工作目录 (CWD) | 本地镜像根目录;服务会写入到 `<值>/objectstore`。 |
**工作流程**
1. **启动阶段:** 解析端点地址(识别协议前缀),创建 MinIO 兼容客户端并使用 Path-Style 模式,如 Bucket 不存在会自动创建。
2. **本地镜像:** 在 `<OBJECTSTORE_LOCAL_PATH 或当前工作目录>/objectstore` 维护可写缓存,同步 `config/config.yaml` 与 `auths/`。
3. **初始化:** 若 Bucket 中缺少配置文件,将以 `config.example.yaml` 为模板生成 `config/config.yaml` 并上传。
4. **双向同步:** 本地变更会上传到对象存储,同时远端对象也会拉回到本地,保证文件监听、管理 API 与 CLI 命令行为一致。
### OpenAI 兼容上游提供商
通过 `openai-compatibility` 配置上游 OpenAI 兼容提供商(例如 OpenRouter
@@ -599,12 +658,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
@@ -636,6 +689,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. 克隆仓库并进入目录:
@@ -651,6 +716,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 上的预构建镜像启动服务。服务将在后台运行。
@@ -676,10 +762,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

View File

@@ -4,15 +4,24 @@
package main
import (
"context"
"errors"
"flag"
"fmt"
"io/fs"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/joho/godotenv"
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"
@@ -44,7 +53,6 @@ func main() {
var claudeLogin bool
var qwenLogin bool
var iflowLogin bool
var geminiWebAuth bool
var noBrowser bool
var projectID string
var configPath string
@@ -56,7 +64,6 @@ 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", DefaultConfigPath, "Configure File Path")
@@ -95,26 +102,264 @@ 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
useGitStore bool
gitStoreRemoteURL string
gitStoreUser string
gitStorePassword string
gitStoreLocalPath string
gitStoreInst *store.GitTokenStore
gitStoreRoot string
useObjectStore bool
objectStoreEndpoint string
objectStoreAccess string
objectStoreSecret string
objectStoreBucket string
objectStoreLocalPath string
objectStoreInst *store.ObjectTokenStore
)
wd, err := os.Getwd()
if err != nil {
log.Fatalf("failed to get working directory: %v", err)
}
// Load environment variables from .env if present.
if errLoad := godotenv.Load(filepath.Join(wd, ".env")); errLoad != nil {
if !errors.Is(errLoad, os.ErrNotExist) {
log.WithError(errLoad).Warn("failed to load .env file")
}
}
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
}
if value, ok := lookupEnv("OBJECTSTORE_ENDPOINT", "objectstore_endpoint"); ok {
useObjectStore = true
objectStoreEndpoint = value
}
if value, ok := lookupEnv("OBJECTSTORE_ACCESS_KEY", "objectstore_access_key"); ok {
objectStoreAccess = value
}
if value, ok := lookupEnv("OBJECTSTORE_SECRET_KEY", "objectstore_secret_key"); ok {
objectStoreSecret = value
}
if value, ok := lookupEnv("OBJECTSTORE_BUCKET", "objectstore_bucket"); ok {
objectStoreBucket = value
}
if value, ok := lookupEnv("OBJECTSTORE_LOCAL_PATH", "objectstore_local_path"); ok {
objectStoreLocalPath = 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 useObjectStore {
objectStoreRoot := objectStoreLocalPath
if objectStoreRoot == "" {
objectStoreRoot = wd
}
objectStoreRoot = filepath.Join(objectStoreRoot, "objectstore")
resolvedEndpoint := strings.TrimSpace(objectStoreEndpoint)
useSSL := true
if strings.Contains(resolvedEndpoint, "://") {
parsed, errParse := url.Parse(resolvedEndpoint)
if errParse != nil {
log.Fatalf("failed to parse object store endpoint %q: %v", objectStoreEndpoint, errParse)
}
switch strings.ToLower(parsed.Scheme) {
case "http":
useSSL = false
case "https":
useSSL = true
default:
log.Fatalf("unsupported object store scheme %q (only http and https are allowed)", parsed.Scheme)
}
if parsed.Host == "" {
log.Fatalf("object store endpoint %q is missing host information", objectStoreEndpoint)
}
resolvedEndpoint = parsed.Host
if parsed.Path != "" && parsed.Path != "/" {
resolvedEndpoint = strings.TrimSuffix(parsed.Host+parsed.Path, "/")
}
}
resolvedEndpoint = strings.TrimRight(resolvedEndpoint, "/")
objCfg := store.ObjectStoreConfig{
Endpoint: resolvedEndpoint,
Bucket: objectStoreBucket,
AccessKey: objectStoreAccess,
SecretKey: objectStoreSecret,
LocalRoot: objectStoreRoot,
UseSSL: useSSL,
PathStyle: true,
}
objectStoreInst, err = store.NewObjectTokenStore(objCfg)
if err != nil {
log.Fatalf("failed to initialize object token store: %v", err)
}
examplePath := filepath.Join(wd, "config.example.yaml")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
if errBootstrap := objectStoreInst.Bootstrap(ctx, examplePath); errBootstrap != nil {
cancel()
log.Fatalf("failed to bootstrap object-backed config: %v", errBootstrap)
}
cancel()
configFilePath = objectStoreInst.ConfigPath()
cfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy)
if err == nil {
if cfg == nil {
cfg = &config.Config{}
}
cfg.AuthDir = objectStoreInst.AuthDir()
log.Infof("object-backed token store enabled, bucket: %s", objectStoreBucket)
}
} 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 {
@@ -138,7 +383,15 @@ 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 useObjectStore {
sdkAuth.RegisterTokenStore(objectStoreInst)
} else if useGitStore {
sdkAuth.RegisterTokenStore(gitStoreInst)
} else {
sdkAuth.RegisterTokenStore(sdkAuth.NewFileTokenStore())
}
// Register built-in access providers before constructing services.
configaccess.Register()
@@ -158,9 +411,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)
}

View File

@@ -79,19 +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
# # Gem selection (Gem Mode):
# # - "coding-partner": attach the predefined Coding partner Gem
# # - "writing-editor": attach the predefined Writing editor Gem
# # - empty: do not attach any Gem
# gem-mode: ""

View File

@@ -10,6 +10,10 @@ services:
COMMIT: ${COMMIT:-none}
BUILD_DATE: ${BUILD_DATE:-unknown}
container_name: cli-proxy-api
# env_file:
# - .env
environment:
DEPLOY: ${DEPLOY:-}
ports:
- "8317:8317"
- "8085:8085"
@@ -20,5 +24,4 @@ services:
- ./config.yaml:/CLIProxyAPI/config.yaml
- ./auths:/root/.cli-proxy-api
- ./logs:/CLIProxyAPI/logs
- ./conv:/CLIProxyAPI/conv
restart: unless-stopped

41
go.mod
View File

@@ -1,49 +1,72 @@
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/joho/godotenv v1.5.1
github.com/jackc/pgx/v5 v5.7.6
github.com/klauspost/compress v1.17.4
github.com/minio/minio-go/v7 v7.0.66
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/dustin/go-humanize v1.0.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/minio/md5-simd v1.1.2 // indirect
github.com/minio/sha256-simd v1.0.1 // 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/rs/xid v1.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
gopkg.in/ini.v1 v1.67.0 // indirect
)

104
go.sum
View File

@@ -1,16 +1,34 @@
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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
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 +37,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,23 +57,52 @@ 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/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
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/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
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=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.0.66 h1:bnTOXOHjOqv/gcMuiVbN9o2ngRItvqE774dG9nq0Dzw=
github.com/minio/minio-go/v7 v7.0.66/go.mod h1:DHAgmyQEGdW3Cif0UooKOyrT3Vxs82zNdV6tkKhRtbs=
github.com/minio/sha256-simd v1.0.1 h1:6kaan5IFmwTNynnKKpDHe6FWHohJOHhCPchzK49dzMM=
github.com/minio/sha256-simd v1.0.1/go.mod h1:Pz6AKMiUdngCLpeTL/RJY1M9rUuPMYujV5xJjtbRSN8=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -53,8 +110,16 @@ 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/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
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 +129,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 +151,39 @@ 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/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
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=

View File

@@ -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{}{}

View File

@@ -3,8 +3,6 @@ package management
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -25,7 +23,6 @@ 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"
@@ -903,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()

View File

@@ -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)

View File

@@ -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()
}

View File

@@ -32,6 +32,7 @@ import (
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers/openai"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
log "github.com/sirupsen/logrus"
"gopkg.in/yaml.v3"
)
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>`
@@ -116,6 +117,10 @@ type Server struct {
// cfg holds the current server configuration.
cfg *config.Config
// oldConfigYaml stores a YAML snapshot of the previous configuration for change detection.
// This prevents issues when the config object is modified in place by Management API.
oldConfigYaml []byte
// accessManager handles request authentication providers.
accessManager *sdkaccess.Manager
@@ -126,6 +131,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
@@ -134,6 +142,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
@@ -193,17 +204,29 @@ 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,
}
// Save initial YAML snapshot
s.oldConfigYaml, _ = yaml.Marshal(cfg)
s.applyAccessConfig(nil, cfg)
// Initialize management handler
s.mgmt = managementHandlers.NewHandler(cfg, configFilePath, authManager)
@@ -218,9 +241,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()
}
@@ -272,7 +296,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",
@@ -419,7 +442,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)
@@ -442,8 +464,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
@@ -451,7 +472,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
}
@@ -640,7 +661,11 @@ func (s *Server) applyAccessConfig(oldCfg, newCfg *config.Config) {
// - clients: The new slice of AI service clients
// - cfg: The new application configuration
func (s *Server) UpdateClients(cfg *config.Config) {
oldCfg := s.cfg
// Reconstruct old config from YAML snapshot to avoid reference sharing issues
var oldCfg *config.Config
if len(s.oldConfigYaml) > 0 {
_ = yaml.Unmarshal(s.oldConfigYaml, &oldCfg)
}
// Update request logger enabled state if it has changed
previousRequestLog := false
@@ -692,30 +717,41 @@ 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)
s.cfg = cfg
// Save YAML snapshot for next comparison
s.oldConfigYaml, _ = yaml.Marshal(cfg)
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 {

View File

@@ -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
}

View File

@@ -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")
}

View File

@@ -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)
}

View File

@@ -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")
}

View File

@@ -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,42 +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"`
// GemMode selects a predefined Gem to attach for Gemini Web requests.
// Allowed values:
// - "coding-partner"
// - "writing-editor"
// When empty, no Gem is attached by configuration.
// This is independent from CodeMode below, which is kept for backwards compatibility.
GemMode string `yaml:"gem-mode" json:"gem-mode"`
// CodeMode enables legacy coding-mode behaviors for Gemini Web.
// Backwards compatibility: when true, the service behaves as before by
// attaching the predefined "Coding partner" Gem and enabling extra
// conveniences (e.g., XML wrapping hints). Prefer GemMode for selecting
// a Gem going forward.
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'.
@@ -187,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)
}
@@ -220,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
@@ -293,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)
@@ -533,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
}
}

View File

@@ -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"

View File

@@ -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 {

View 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()
}

View File

@@ -1,884 +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-web": {},
}
// 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}
// Attach Gem first to keep index alignment with reference implementation
// so the Gemini Web UI can recognize the selected Gem.
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)
}
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
}
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
}

View File

@@ -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-web"] = "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-web"
cpy.Name = "gemini-2.5-flash-image-web"
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
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -1,6 +0,0 @@
package conversation
const (
MetadataMessagesKey = "gemini_web_messages"
MetadataMatchKey = "gemini_web_match"
)

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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, "&lt;", "<")
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()
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -8,6 +8,15 @@ import "time"
// GetClaudeModels returns the standard Claude model definitions
func GetClaudeModels() []*ModelInfo {
return []*ModelInfo{
{
ID: "claude-haiku-4-5-20251001",
Object: "model",
Created: 1759276800, // 2025-10-01
OwnedBy: "anthropic",
Type: "claude",
DisplayName: "Claude 4.5 Haiku",
},
{
ID: "claude-sonnet-4-5-20250929",
Object: "model",
@@ -104,6 +113,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"},
},
}
}
@@ -166,6 +203,20 @@ func GetGeminiCLIModels() []*ModelInfo {
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"},
},
}
}
@@ -354,6 +405,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"},

View File

@@ -143,6 +143,31 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
go func() {
defer close(out)
defer func() { _ = resp.Body.Close() }()
// If from == to (Claude → Claude), directly forward the SSE stream without translation
if from == to {
scanner := bufio.NewScanner(resp.Body)
buf := make([]byte, 20_971_520)
scanner.Buffer(buf, 20_971_520)
for scanner.Scan() {
line := scanner.Bytes()
appendAPIResponseChunk(ctx, e.cfg, line)
if detail, ok := parseClaudeStreamUsage(line); ok {
reporter.publish(ctx, detail)
}
// Forward the line as-is to preserve SSE format
cloned := make([]byte, len(line)+1)
copy(cloned, line)
cloned[len(line)] = '\n'
out <- cliproxyexecutor.StreamChunk{Payload: cloned}
}
if err = scanner.Err(); err != nil {
out <- cliproxyexecutor.StreamChunk{Err: err}
}
return
}
// For other formats, use translation
scanner := bufio.NewScanner(resp.Body)
buf := make([]byte, 20_971_520)
scanner.Buffer(buf, 20_971_520)

View File

@@ -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 {
@@ -139,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
}
@@ -148,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"))
@@ -270,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
}
@@ -294,6 +297,7 @@ func (e *GeminiCLIExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.
payload = deleteJSONField(payload, "project")
payload = deleteJSONField(payload, "model")
payload = disableGeminiThinkingConfig(payload, attemptModel)
payload = fixGeminiCLIImageAspectRatio(attemptModel, payload)
tok, errTok := tokenSource.Token()
if errTok != nil {
@@ -351,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")
}
@@ -395,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)
@@ -518,7 +522,7 @@ func geminiModelDisallowsThinking(model string) bool {
return false
}
lower := strings.ToLower(model)
for _, marker := range []string{"gemini-2.5-flash-image-preview"} {
for _, marker := range []string{"gemini-2.5-flash-image-preview", "gemini-2.5-flash-image"} {
if strings.Contains(lower, marker) {
return true
}
@@ -549,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
}

View File

@@ -78,6 +78,7 @@ func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
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 {
@@ -136,6 +137,7 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
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 == "" {
@@ -207,6 +209,7 @@ func (e *GeminiExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut
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")
@@ -374,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
}

View File

@@ -1,280 +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), &param)
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)), &param)
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)), &param)
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 {
// Hot-reload: ensure cached state sees the latest config
runtime.state.SetConfig(e.cfg)
return runtime.state, nil
}
e.mu.Lock()
defer e.mu.Unlock()
if runtime, ok := auth.Runtime.(*geminiWebRuntime); ok && runtime != nil && runtime.state != nil {
// Hot-reload: ensure cached state sees the latest config
runtime.state.SetConfig(e.cfg)
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 = &copyCfg
}
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
}
}

View File

@@ -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
View 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
}
}

View File

@@ -0,0 +1,618 @@
package store
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/credentials"
"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 (
objectStoreConfigKey = "config/config.yaml"
objectStoreAuthPrefix = "auths"
)
// ObjectStoreConfig captures configuration for the object storage-backed token store.
type ObjectStoreConfig struct {
Endpoint string
Bucket string
AccessKey string
SecretKey string
Region string
Prefix string
LocalRoot string
UseSSL bool
PathStyle bool
}
// ObjectTokenStore persists configuration and authentication metadata using an S3-compatible object storage backend.
// Files are mirrored to a local workspace so existing file-based flows continue to operate.
type ObjectTokenStore struct {
client *minio.Client
cfg ObjectStoreConfig
spoolRoot string
configPath string
authDir string
mu sync.Mutex
}
// NewObjectTokenStore initializes an object storage backed token store.
func NewObjectTokenStore(cfg ObjectStoreConfig) (*ObjectTokenStore, error) {
cfg.Endpoint = strings.TrimSpace(cfg.Endpoint)
cfg.Bucket = strings.TrimSpace(cfg.Bucket)
cfg.AccessKey = strings.TrimSpace(cfg.AccessKey)
cfg.SecretKey = strings.TrimSpace(cfg.SecretKey)
cfg.Prefix = strings.Trim(cfg.Prefix, "/")
if cfg.Endpoint == "" {
return nil, fmt.Errorf("object store: endpoint is required")
}
if cfg.Bucket == "" {
return nil, fmt.Errorf("object store: bucket is required")
}
if cfg.AccessKey == "" {
return nil, fmt.Errorf("object store: access key is required")
}
if cfg.SecretKey == "" {
return nil, fmt.Errorf("object store: secret key is required")
}
root := strings.TrimSpace(cfg.LocalRoot)
if root == "" {
if cwd, err := os.Getwd(); err == nil {
root = filepath.Join(cwd, "objectstore")
} else {
root = filepath.Join(os.TempDir(), "objectstore")
}
}
absRoot, err := filepath.Abs(root)
if err != nil {
return nil, fmt.Errorf("object store: resolve spool directory: %w", err)
}
configDir := filepath.Join(absRoot, "config")
authDir := filepath.Join(absRoot, "auths")
if err = os.MkdirAll(configDir, 0o700); err != nil {
return nil, fmt.Errorf("object store: create config directory: %w", err)
}
if err = os.MkdirAll(authDir, 0o700); err != nil {
return nil, fmt.Errorf("object store: create auth directory: %w", err)
}
options := &minio.Options{
Creds: credentials.NewStaticV4(cfg.AccessKey, cfg.SecretKey, ""),
Secure: cfg.UseSSL,
Region: cfg.Region,
}
if cfg.PathStyle {
options.BucketLookup = minio.BucketLookupPath
}
client, err := minio.New(cfg.Endpoint, options)
if err != nil {
return nil, fmt.Errorf("object store: create client: %w", err)
}
return &ObjectTokenStore{
client: client,
cfg: cfg,
spoolRoot: absRoot,
configPath: filepath.Join(configDir, "config.yaml"),
authDir: authDir,
}, nil
}
// SetBaseDir implements the optional interface used by authenticators; it is a no-op because
// the object store controls its own workspace.
func (s *ObjectTokenStore) SetBaseDir(string) {}
// ConfigPath returns the managed configuration file path inside the spool directory.
func (s *ObjectTokenStore) ConfigPath() string {
if s == nil {
return ""
}
return s.configPath
}
// AuthDir returns the local directory containing mirrored auth files.
func (s *ObjectTokenStore) AuthDir() string {
if s == nil {
return ""
}
return s.authDir
}
// Bootstrap ensures the target bucket exists and synchronizes data from the object storage backend.
func (s *ObjectTokenStore) Bootstrap(ctx context.Context, exampleConfigPath string) error {
if s == nil {
return fmt.Errorf("object store: not initialized")
}
if err := s.ensureBucket(ctx); err != nil {
return err
}
if err := s.syncConfigFromBucket(ctx, exampleConfigPath); err != nil {
return err
}
if err := s.syncAuthFromBucket(ctx); err != nil {
return err
}
return nil
}
// Save persists authentication metadata to disk and uploads it to the object storage backend.
func (s *ObjectTokenStore) Save(ctx context.Context, auth *cliproxyauth.Auth) (string, error) {
if auth == nil {
return "", fmt.Errorf("object store: auth is nil")
}
path, err := s.resolveAuthPath(auth)
if err != nil {
return "", err
}
if path == "" {
return "", fmt.Errorf("object 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("object 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("object 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("object store: read existing metadata: %w", errRead)
}
tmp := path + ".tmp"
if errWrite := os.WriteFile(tmp, raw, 0o600); errWrite != nil {
return "", fmt.Errorf("object store: write temp auth file: %w", errWrite)
}
if errRename := os.Rename(tmp, path); errRename != nil {
return "", fmt.Errorf("object store: rename auth file: %w", errRename)
}
default:
return "", fmt.Errorf("object 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
}
if err = s.uploadAuth(ctx, path); err != nil {
return "", err
}
return path, nil
}
// List enumerates auth JSON files from the mirrored workspace.
func (s *ObjectTokenStore) List(_ context.Context) ([]*cliproxyauth.Auth, error) {
dir := strings.TrimSpace(s.AuthDir())
if dir == "" {
return nil, fmt.Errorf("object store: auth directory not configured")
}
entries := make([]*cliproxyauth.Auth, 0, 32)
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 {
log.WithError(err).Warnf("object store: skip auth %s", path)
return nil
}
if auth != nil {
entries = append(entries, auth)
}
return nil
})
if err != nil {
return nil, fmt.Errorf("object store: walk auth directory: %w", err)
}
return entries, nil
}
// Delete removes an auth file locally and remotely.
func (s *ObjectTokenStore) Delete(ctx context.Context, id string) error {
id = strings.TrimSpace(id)
if id == "" {
return fmt.Errorf("object 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("object store: delete auth file: %w", err)
}
if err = s.deleteAuthObject(ctx, path); err != nil {
return err
}
return nil
}
// PersistAuthFiles uploads the provided auth files to the object storage backend.
func (s *ObjectTokenStore) 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
}
abs := trimmed
if !filepath.IsAbs(abs) {
abs = filepath.Join(s.authDir, trimmed)
}
if err := s.uploadAuth(ctx, abs); err != nil {
return err
}
}
return nil
}
// PersistConfig uploads the local configuration file to the object storage backend.
func (s *ObjectTokenStore) 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.deleteObject(ctx, objectStoreConfigKey)
}
return fmt.Errorf("object store: read config file: %w", err)
}
if len(data) == 0 {
return s.deleteObject(ctx, objectStoreConfigKey)
}
return s.putObject(ctx, objectStoreConfigKey, data, "application/x-yaml")
}
func (s *ObjectTokenStore) ensureBucket(ctx context.Context) error {
exists, err := s.client.BucketExists(ctx, s.cfg.Bucket)
if err != nil {
return fmt.Errorf("object store: check bucket: %w", err)
}
if exists {
return nil
}
if err = s.client.MakeBucket(ctx, s.cfg.Bucket, minio.MakeBucketOptions{Region: s.cfg.Region}); err != nil {
return fmt.Errorf("object store: create bucket: %w", err)
}
return nil
}
func (s *ObjectTokenStore) syncConfigFromBucket(ctx context.Context, example string) error {
key := s.prefixedKey(objectStoreConfigKey)
_, err := s.client.StatObject(ctx, s.cfg.Bucket, key, minio.StatObjectOptions{})
switch {
case err == nil:
object, errGet := s.client.GetObject(ctx, s.cfg.Bucket, key, minio.GetObjectOptions{})
if errGet != nil {
return fmt.Errorf("object store: fetch config: %w", errGet)
}
defer object.Close()
data, errRead := io.ReadAll(object)
if errRead != nil {
return fmt.Errorf("object store: read config: %w", errRead)
}
if errWrite := os.WriteFile(s.configPath, normalizeLineEndingsBytes(data), 0o600); errWrite != nil {
return fmt.Errorf("object store: write config: %w", errWrite)
}
case isObjectNotFound(err):
if _, statErr := os.Stat(s.configPath); errors.Is(statErr, fs.ErrNotExist) {
if example != "" {
if errCopy := misc.CopyConfigTemplate(example, s.configPath); errCopy != nil {
return fmt.Errorf("object store: copy example config: %w", errCopy)
}
} else {
if errCreate := os.MkdirAll(filepath.Dir(s.configPath), 0o700); errCreate != nil {
return fmt.Errorf("object store: prepare config directory: %w", errCreate)
}
if errWrite := os.WriteFile(s.configPath, []byte{}, 0o600); errWrite != nil {
return fmt.Errorf("object store: create empty config: %w", errWrite)
}
}
}
data, errRead := os.ReadFile(s.configPath)
if errRead != nil {
return fmt.Errorf("object store: read local config: %w", errRead)
}
if len(data) > 0 {
if errPut := s.putObject(ctx, objectStoreConfigKey, data, "application/x-yaml"); errPut != nil {
return errPut
}
}
default:
return fmt.Errorf("object store: stat config: %w", err)
}
return nil
}
func (s *ObjectTokenStore) syncAuthFromBucket(ctx context.Context) error {
if err := os.RemoveAll(s.authDir); err != nil {
return fmt.Errorf("object store: reset auth directory: %w", err)
}
if err := os.MkdirAll(s.authDir, 0o700); err != nil {
return fmt.Errorf("object store: recreate auth directory: %w", err)
}
prefix := s.prefixedKey(objectStoreAuthPrefix + "/")
objectCh := s.client.ListObjects(ctx, s.cfg.Bucket, minio.ListObjectsOptions{
Prefix: prefix,
Recursive: true,
})
for object := range objectCh {
if object.Err != nil {
return fmt.Errorf("object store: list auth objects: %w", object.Err)
}
rel := strings.TrimPrefix(object.Key, prefix)
if rel == "" || strings.HasSuffix(rel, "/") {
continue
}
relPath := filepath.FromSlash(rel)
if filepath.IsAbs(relPath) {
log.WithField("key", object.Key).Warn("object store: skip auth outside mirror")
continue
}
cleanRel := filepath.Clean(relPath)
if cleanRel == "." || cleanRel == ".." || strings.HasPrefix(cleanRel, ".."+string(os.PathSeparator)) {
log.WithField("key", object.Key).Warn("object store: skip auth outside mirror")
continue
}
local := filepath.Join(s.authDir, cleanRel)
if err := os.MkdirAll(filepath.Dir(local), 0o700); err != nil {
return fmt.Errorf("object store: prepare auth subdir: %w", err)
}
reader, errGet := s.client.GetObject(ctx, s.cfg.Bucket, object.Key, minio.GetObjectOptions{})
if errGet != nil {
return fmt.Errorf("object store: download auth %s: %w", object.Key, errGet)
}
data, errRead := io.ReadAll(reader)
_ = reader.Close()
if errRead != nil {
return fmt.Errorf("object store: read auth %s: %w", object.Key, errRead)
}
if errWrite := os.WriteFile(local, data, 0o600); errWrite != nil {
return fmt.Errorf("object store: write auth %s: %w", local, errWrite)
}
}
return nil
}
func (s *ObjectTokenStore) uploadAuth(ctx context.Context, path string) error {
if path == "" {
return nil
}
rel, err := filepath.Rel(s.authDir, path)
if err != nil {
return fmt.Errorf("object store: resolve auth relative path: %w", err)
}
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return s.deleteAuthObject(ctx, path)
}
return fmt.Errorf("object store: read auth file: %w", err)
}
if len(data) == 0 {
return s.deleteAuthObject(ctx, path)
}
key := objectStoreAuthPrefix + "/" + filepath.ToSlash(rel)
return s.putObject(ctx, key, data, "application/json")
}
func (s *ObjectTokenStore) deleteAuthObject(ctx context.Context, path string) error {
if path == "" {
return nil
}
rel, err := filepath.Rel(s.authDir, path)
if err != nil {
return fmt.Errorf("object store: resolve auth relative path: %w", err)
}
key := objectStoreAuthPrefix + "/" + filepath.ToSlash(rel)
return s.deleteObject(ctx, key)
}
func (s *ObjectTokenStore) putObject(ctx context.Context, key string, data []byte, contentType string) error {
if len(data) == 0 {
return s.deleteObject(ctx, key)
}
fullKey := s.prefixedKey(key)
reader := bytes.NewReader(data)
_, err := s.client.PutObject(ctx, s.cfg.Bucket, fullKey, reader, int64(len(data)), minio.PutObjectOptions{
ContentType: contentType,
})
if err != nil {
return fmt.Errorf("object store: put object %s: %w", fullKey, err)
}
return nil
}
func (s *ObjectTokenStore) deleteObject(ctx context.Context, key string) error {
fullKey := s.prefixedKey(key)
err := s.client.RemoveObject(ctx, s.cfg.Bucket, fullKey, minio.RemoveObjectOptions{})
if err != nil {
if isObjectNotFound(err) {
return nil
}
return fmt.Errorf("object store: delete object %s: %w", fullKey, err)
}
return nil
}
func (s *ObjectTokenStore) prefixedKey(key string) string {
key = strings.TrimLeft(key, "/")
if s.cfg.Prefix == "" {
return key
}
return strings.TrimLeft(s.cfg.Prefix+"/"+key, "/")
}
func (s *ObjectTokenStore) resolveAuthPath(auth *cliproxyauth.Auth) (string, error) {
if auth == nil {
return "", fmt.Errorf("object store: auth is nil")
}
if auth.Attributes != nil {
if path := strings.TrimSpace(auth.Attributes["path"]); path != "" {
if filepath.IsAbs(path) {
return path, nil
}
return filepath.Join(s.authDir, path), nil
}
}
fileName := strings.TrimSpace(auth.FileName)
if fileName == "" {
fileName = strings.TrimSpace(auth.ID)
}
if fileName == "" {
return "", fmt.Errorf("object store: auth %s missing filename", auth.ID)
}
if !strings.HasSuffix(strings.ToLower(fileName), ".json") {
fileName += ".json"
}
return filepath.Join(s.authDir, fileName), nil
}
func (s *ObjectTokenStore) resolveDeletePath(id string) (string, error) {
id = strings.TrimSpace(id)
if id == "" {
return "", fmt.Errorf("object store: id is empty")
}
// Absolute paths are honored as-is; callers must ensure they point inside the mirror.
if filepath.IsAbs(id) {
return id, nil
}
// Treat any non-absolute id (including nested like "team/foo") as relative to the mirror authDir.
// Normalize separators and guard against path traversal.
clean := filepath.Clean(filepath.FromSlash(id))
if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(os.PathSeparator)) {
return "", fmt.Errorf("object store: invalid auth identifier %s", id)
}
// Ensure .json suffix.
if !strings.HasSuffix(strings.ToLower(clean), ".json") {
clean += ".json"
}
return filepath.Join(s.authDir, clean), nil
}
func (s *ObjectTokenStore) 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 := strings.TrimSpace(valueAsString(metadata["type"]))
if provider == "" {
provider = "unknown"
}
info, err := os.Stat(path)
if err != nil {
return nil, fmt.Errorf("stat auth file: %w", err)
}
rel, errRel := filepath.Rel(baseDir, path)
if errRel != nil {
rel = filepath.Base(path)
}
rel = normalizeAuthID(rel)
attr := map[string]string{"path": path}
if email := strings.TrimSpace(valueAsString(metadata["email"])); email != "" {
attr["email"] = email
}
auth := &cliproxyauth.Auth{
ID: rel,
Provider: provider,
FileName: rel,
Label: labelFor(metadata),
Status: cliproxyauth.StatusActive,
Attributes: attr,
Metadata: metadata,
CreatedAt: info.ModTime(),
UpdatedAt: info.ModTime(),
LastRefreshedAt: time.Time{},
NextRefreshAfter: time.Time{},
}
return auth, nil
}
func normalizeLineEndingsBytes(data []byte) []byte {
replaced := bytes.ReplaceAll(data, []byte{'\r', '\n'}, []byte{'\n'})
return bytes.ReplaceAll(replaced, []byte{'\r'}, []byte{'\n'})
}
func isObjectNotFound(err error) bool {
if err == nil {
return false
}
resp := minio.ToErrorResponse(err)
if resp.StatusCode == http.StatusNotFound {
return true
}
switch resp.Code {
case "NoSuchKey", "NotFound", "NoSuchBucket":
return true
}
return false
}

View 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
}

View File

@@ -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)
}

View File

@@ -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

View File

@@ -40,7 +40,7 @@ func ConvertOpenAIResponsesRequestToCodex(modelName string, inputRawJSON []byte,
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())
newInput, _ = sjson.SetRaw(newInput, "0.content.0.text", inputResult.Raw)
inputResults = gjson.Parse(newInput).Array()
}
} else {

View File

@@ -1,7 +1,6 @@
package responses
import (
"bufio"
"bytes"
"context"
"fmt"
@@ -12,6 +11,7 @@ import (
// ConvertCodexResponseToOpenAIResponses converts OpenAI Chat Completions streaming chunks
// to OpenAI Responses SSE events (response.*).
func ConvertCodexResponseToOpenAIResponses(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []string {
if bytes.HasPrefix(rawJSON, []byte("data:")) {
rawJSON = bytes.TrimSpace(rawJSON[5:])
@@ -21,7 +21,8 @@ func ConvertCodexResponseToOpenAIResponses(ctx context.Context, modelName string
rawJSON, _ = sjson.SetBytes(rawJSON, "response.instructions", gjson.GetBytes(originalRequestRawJSON, "instructions").String())
}
}
return []string{fmt.Sprintf("data: %s", string(rawJSON))}
out := fmt.Sprintf("data: %s", string(rawJSON))
return []string{out}
}
return []string{string(rawJSON)}
}
@@ -29,31 +30,13 @@ func ConvertCodexResponseToOpenAIResponses(ctx context.Context, modelName string
// ConvertCodexResponseToOpenAIResponsesNonStream builds a single Responses JSON
// 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, 20_971_520)
scanner.Buffer(buffer, 20_971_520)
dataTag := []byte("data:")
for scanner.Scan() {
line := scanner.Bytes()
if !bytes.HasPrefix(line, dataTag) {
continue
}
line = bytes.TrimSpace(line[5:])
rootResult := gjson.ParseBytes(line)
// Verify this is a response.completed event
if rootResult.Get("type").String() != "response.completed" {
continue
}
responseResult := rootResult.Get("response")
template := responseResult.Raw
template, _ = sjson.Set(template, "instructions", gjson.GetBytes(originalRequestRawJSON, "instructions").String())
return template
rootResult := gjson.ParseBytes(rawJSON)
// Verify this is a response.completed event
if rootResult.Get("type").String() != "response.completed" {
return ""
}
return ""
responseResult := rootResult.Get("response")
template := responseResult.Raw
template, _ = sjson.Set(template, "instructions", gjson.GetBytes(originalRequestRawJSON, "instructions").String())
return template
}

View File

@@ -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)

View File

@@ -20,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
@@ -40,6 +41,7 @@ func ConvertCliResponseToOpenAI(_ context.Context, _ string, originalRequestRawJ
if *param == nil {
*param = &convertCliResponseToOpenAIChatParams{
UnixTimestamp: 0,
FunctionIndex: 0,
}
}
@@ -117,13 +119,18 @@ 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)

View File

@@ -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,
},
)
}

View File

@@ -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,
},
)
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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"

View File

@@ -37,6 +37,8 @@ type ConvertOpenAIResponseToAnthropicParams struct {
ContentBlocksStopped bool
// Track if message_delta has been sent
MessageDeltaSent bool
// Track if message_start has been sent
MessageStarted bool
}
// ToolCallAccumulator holds the state for accumulating tool call data
@@ -84,20 +86,12 @@ func ConvertOpenAIResponseToClaude(_ context.Context, _ string, originalRequestR
return convertOpenAIDoneToAnthropic((*param).(*ConvertOpenAIResponseToAnthropicParams))
}
root := gjson.ParseBytes(rawJSON)
// Check if this is a streaming chunk or non-streaming response
objectType := root.Get("object").String()
if objectType == "chat.completion.chunk" {
// Handle streaming response
return convertOpenAIStreamingChunkToAnthropic(rawJSON, (*param).(*ConvertOpenAIResponseToAnthropicParams))
} else if objectType == "chat.completion" {
// Handle non-streaming response
streamResult := gjson.GetBytes(originalRequestRawJSON, "stream")
if !streamResult.Exists() || (streamResult.Exists() && streamResult.Type == gjson.False) {
return convertOpenAINonStreamingToAnthropic(rawJSON)
} else {
return convertOpenAIStreamingChunkToAnthropic(rawJSON, (*param).(*ConvertOpenAIResponseToAnthropicParams))
}
return []string{}
}
// convertOpenAIStreamingChunkToAnthropic converts OpenAI streaming chunk to Anthropic streaming events
@@ -118,7 +112,7 @@ func convertOpenAIStreamingChunkToAnthropic(rawJSON []byte, param *ConvertOpenAI
// Check if this is the first chunk (has role)
if delta := root.Get("choices.0.delta"); delta.Exists() {
if role := delta.Get("role"); role.Exists() && role.String() == "assistant" {
if role := delta.Get("role"); role.Exists() && role.String() == "assistant" && !param.MessageStarted {
// Send message_start event
messageStart := map[string]interface{}{
"type": "message_start",
@@ -138,6 +132,7 @@ func convertOpenAIStreamingChunkToAnthropic(rawJSON []byte, param *ConvertOpenAI
}
messageStartJSON, _ := json.Marshal(messageStart)
results = append(results, "event: message_start\ndata: "+string(messageStartJSON)+"\n\n")
param.MessageStarted = true
// Don't send content_block_start for text here - wait for actual content
}

View File

@@ -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

View File

@@ -97,8 +97,8 @@ func ConvertOpenAIResponseToGemini(_ context.Context, _ string, originalRequestR
var results []string
choices.ForEach(func(choiceIndex, choice gjson.Result) bool {
// Base Gemini response template
template := `{"candidates":[{"content":{"parts":[],"role":"model"},"finishReason":"STOP","index":0}]}`
// Base Gemini response template without finishReason; set when known
template := `{"candidates":[{"content":{"parts":[],"role":"model"},"index":0}]}`
// Set model if available
if model := root.Get("model"); model.Exists() {
@@ -514,8 +514,8 @@ func tryParseNumber(s string) (interface{}, bool) {
func ConvertOpenAIResponseToGeminiNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) string {
root := gjson.ParseBytes(rawJSON)
// Base Gemini response template
out := `{"candidates":[{"content":{"parts":[],"role":"model"},"finishReason":"STOP","index":0}]}`
// Base Gemini response template without finishReason; set when known
out := `{"candidates":[{"content":{"parts":[],"role":"model"},"index":0}]}`
// Set model if available
if model := root.Get("model"); model.Exists() {

View File

@@ -67,9 +67,20 @@ func ConvertOpenAIChatCompletionsResponseToOpenAIResponses(ctx context.Context,
rawJSON = bytes.TrimSpace(rawJSON[5:])
}
rawJSON = bytes.TrimSpace(rawJSON)
if len(rawJSON) == 0 {
return []string{}
}
if bytes.Equal(rawJSON, []byte("[DONE]")) {
return []string{}
}
root := gjson.ParseBytes(rawJSON)
obj := root.Get("object").String()
if obj != "chat.completion.chunk" {
obj := root.Get("object")
if obj.Exists() && obj.String() != "" && obj.String() != "chat.completion.chunk" {
return []string{}
}
if !root.Get("choices").Exists() || !root.Get("choices").IsArray() {
return []string{}
}

View File

@@ -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
View 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
}

View File

@@ -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,86 +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.GemMode != newConfig.GeminiWeb.GemMode {
log.Debugf(" gemini-web.gem-mode: %s -> %s", oldConfig.GeminiWeb.GemMode, newConfig.GeminiWeb.GemMode)
}
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")
}
}
@@ -706,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.
@@ -723,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.
@@ -951,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,
@@ -960,6 +959,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
"source": full,
"path": full,
},
ProxyURL: proxyURL,
Metadata: metadata,
CreatedAt: now,
UpdatedAt: now,
@@ -1167,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
}

View File

@@ -7,8 +7,9 @@
package claude
import (
"bytes"
"bufio"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
@@ -197,33 +198,65 @@ func (h *ClaudeCodeAPIHandler) handleStreamingResponse(c *gin.Context, rawJSON [
}
func (h *ClaudeCodeAPIHandler) forwardClaudeStream(c *gin.Context, flusher http.Flusher, cancel func(error), data <-chan []byte, errs <-chan *interfaces.ErrorMessage) {
// v6.1: Intelligent Buffered Streamer strategy
// Enhanced buffering with larger buffer size (16KB) and longer flush interval (120ms).
// Smart flush only when buffer is sufficiently filled (≥50%), dramatically reducing
// flush frequency from ~12.5Hz to ~5-8Hz while maintaining low latency.
writer := bufio.NewWriterSize(c.Writer, 16*1024) // 4KB → 16KB
ticker := time.NewTicker(120 * time.Millisecond) // 80ms → 120ms
defer ticker.Stop()
var chunkIdx int
for {
select {
case <-c.Request.Context().Done():
// Context cancelled, flush any remaining data before exit
_ = writer.Flush()
cancel(c.Request.Context().Err())
return
case <-ticker.C:
// Smart flush: only flush when buffer has sufficient data (≥50% full)
// This reduces flush frequency while ensuring data flows naturally
buffered := writer.Buffered()
if buffered >= 8*1024 { // At least 8KB (50% of 16KB buffer)
if err := writer.Flush(); err != nil {
// Error flushing, cancel and return
cancel(err)
return
}
flusher.Flush() // Also flush the underlying http.ResponseWriter
}
case chunk, ok := <-data:
if !ok {
flusher.Flush()
// Stream ended, flush remaining data
_ = writer.Flush()
cancel(nil)
return
}
if bytes.HasPrefix(chunk, []byte("event:")) {
_, _ = c.Writer.Write([]byte("\n"))
// Forward the complete SSE event block directly (already formatted by the translator).
// The translator returns a complete SSE-compliant event block, including event:, data:, and separators.
// The handler just needs to forward it without reassembly.
if len(chunk) > 0 {
_, _ = writer.Write(chunk)
}
chunkIdx++
_, _ = c.Writer.Write(chunk)
_, _ = c.Writer.Write([]byte("\n"))
flusher.Flush()
case errMsg, ok := <-errs:
if !ok {
continue
}
if errMsg != nil {
h.WriteErrorResponse(c, errMsg)
flusher.Flush()
// An error occurred: emit as a proper SSE error event
errorBytes, _ := json.Marshal(h.toClaudeError(errMsg))
_, _ = writer.WriteString("event: error\n")
_, _ = writer.WriteString("data: ")
_, _ = writer.Write(errorBytes)
_, _ = writer.WriteString("\n\n")
_ = writer.Flush()
}
var execErr error
if errMsg != nil {
@@ -231,7 +264,26 @@ func (h *ClaudeCodeAPIHandler) forwardClaudeStream(c *gin.Context, flusher http.
}
cancel(execErr)
return
case <-time.After(500 * time.Millisecond):
}
}
}
type claudeErrorDetail struct {
Type string `json:"type"`
Message string `json:"message"`
}
type claudeErrorResponse struct {
Type string `json:"type"`
Error claudeErrorDetail `json:"error"`
}
func (h *ClaudeCodeAPIHandler) toClaudeError(msg *interfaces.ErrorMessage) claudeErrorResponse {
return claudeErrorResponse{
Type: "error",
Error: claudeErrorDetail{
Type: "api_error",
Message: msg.Error.Error(),
},
}
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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":
@@ -536,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":

View File

@@ -14,6 +14,7 @@ type Record struct {
Model string
APIKey string
AuthID string
Source string
RequestedAt time.Time
Detail Detail
}