Compare commits

...

339 Commits

Author SHA1 Message Date
Luis Pater
ae646fba4b refactor(login): disable geminicloudassist API check in required services list 2025-10-03 16:53:49 +08:00
Luis Pater
2eef6875e9 feat(auth): improve OpenAI compatibility normalization and API key handling
- Refined trimming and normalization logic for `baseURL` and `apiKey` attributes.
- Updated `Authorization` header logic to omit empty API keys.
- Enhanced compatibility processing by handling empty `api-key-entries`.
- Improved legacy format fallback and added safeguards for empty credentials across executor paths.
2025-10-03 02:38:30 +08:00
Luis Pater
12c09f1a46 feat(runtime): remove previous_response_id from Codex executor request body
- Implemented logic to delete `previous_response_id` property from the request body in Codex executor.
- Applied changes consistently across relevant Codex executor paths.
2025-10-02 12:00:06 +08:00
Luis Pater
4a31f763af feat(management): add proxy support for management asset synchronization
- Introduced `proxyURL` parameter for `EnsureLatestManagementHTML` to enable proxy configuration.
- Refactored HTTP client initialization with new `newHTTPClient` to support proxy-aware requests.
- Updated asset download and fetch logic to utilize the proxy-aware HTTP client.
- Adjusted `server.go` to pass `cfg.ProxyURL` for management asset synchronization calls.
2025-10-01 20:18:26 +08:00
Luis Pater
6629cadb87 refactor(server): remove unused context and managementasset references
- Dropped unused `context` and `managementasset` imports from `main.go`.
- Removed logic for management control panel asset synchronization, including `EnsureLatestManagementHTML`.
- Simplified server initialization by eliminating related control panel functionality.
2025-10-01 03:44:30 +08:00
Luis Pater
41975c9e2b docs(management): document remote-management.disable-control-panel option
- Added explanation for the `remote-management.disable-control-panel` configuration in both `README.md` and `README_CN.md`.
- Clarified its functionality to disable the bundled management UI and return 404 for `/management.html`.
2025-10-01 03:26:06 +08:00
Luis Pater
c589c0d998 feat(management): add support for control panel asset synchronization
- Introduced `EnsureLatestManagementHTML` to sync `management.html` asset from the latest GitHub release.
- Added config option `DisableControlPanel` to toggle control panel functionality.
- Serve management control panel via `/management.html` endpoint, with automatic download and update mechanism.
- Updated `.gitignore` to include `static/*` directory for control panel assets.
2025-10-01 03:18:39 +08:00
Luis Pater
7c157d6ab1 refactor(auth): simplify inline API key provider logic and improve configuration consistency
- Replaced `SyncInlineAPIKeys` with `MakeInlineAPIKeyProvider` for better clarity and reduced redundancy.
- Removed legacy logic for inline API key syncing and migration.
- Enhanced provider synchronization logic to handle empty states consistently.
- Added normalization to API key handling across configurations.
- Updated handlers to reflect streamlined provider update logic.
2025-10-01 00:55:09 +08:00
Luis Pater
7c642bee09 feat(auth): normalize OpenAI compatibility entries and enhance proxy configuration
- Added automatic trimming of API keys and migration of legacy `api-keys` to `api-key-entries`.
- Introduced per-key `proxy-url` handling across OpenAI, Codex, and Claude API configurations.
- Updated documentation to clarify usage of `proxy-url` with examples, ensuring backward compatibility.
- Added normalization logic to reduce duplication and improve configuration consistency.
2025-09-30 23:36:22 +08:00
Luis Pater
beba2a7aa0 Merge pull request #78 from router-for-me/gemini
feat: add multi-account polling for Gemini web
2025-09-30 20:47:29 +08:00
hkfires
f2201dabfa feat(gemini-web): Index and look up conversations by suffix 2025-09-30 12:21:51 +08:00
hkfires
108dcb7f70 fix(gemini-web): Correct history on conversation reuse 2025-09-30 12:21:51 +08:00
hkfires
8858e07d8b feat(gemini-web): Add support for custom auth labels 2025-09-30 12:21:51 +08:00
hkfires
d33a89b89f fix(gemini-web): Ignore tool messages to fix sticky selection 2025-09-30 12:21:51 +08:00
hkfires
1d70336a91 fix(gemini-web): Correct ambiguity check in conversation lookup 2025-09-30 12:21:51 +08:00
hkfires
6080527e9e feat(gemini-web): Namespace conversation index by account label 2025-09-30 12:21:51 +08:00
hkfires
82187bffba feat(gemini-web): Add conversation affinity selector 2025-09-30 12:21:51 +08:00
hkfires
f4977e5ef6 Ignore GEMINI.md file 2025-09-30 12:21:51 +08:00
Luis Pater
832268cae7 refactor(proxy): improve SOCKS5 proxy authentication handling
- Added nil check for proxy user credentials to prevent potential nil pointer dereference.
- Enhanced authentication logic for SOCKS5 proxies in `proxy_helpers.go` and `proxy.go`.
2025-09-30 11:23:39 +08:00
Luis Pater
f6de2a709f feat(auth): add per-key proxy support and enhance API key configuration handling
- Introduced `ProxyURL` field to Claude and Codex API key configurations.
- Added support for `api-key-entries` in OpenAI compatibility section with per-key proxy configuration.
- Maintained backward compatibility for legacy `api-keys` format.
- Updated logic to prioritize `api-key-entries` where applicable.
- Improved documentation and examples to reflect new proxy support.
2025-09-30 09:24:40 +08:00
Luis Pater
de796ac1c2 feat(runtime): introduce newProxyAwareHTTPClient for enhanced proxy handling
- Added `newProxyAwareHTTPClient` to centralize proxy configuration with priority on `auth.ProxyURL` and `cfg.ProxyURL`.
- Integrated enhanced proxy support across executors for HTTP, HTTPS, and SOCKS5 protocols.
- Refactored redundant HTTP client initialization to use `newProxyAwareHTTPClient` for consistent behavior.
2025-09-30 09:04:15 +08:00
Luis Pater
6b5aefc27a feat(proxy): add SOCKS5 support and improve proxy handling
- Added SOCKS5 proxy support, including authentication.
- Improved handling of proxy schemes and associated error logging.
- Enhanced transport creation for HTTP, HTTPS, and SOCKS5 proxies with better configuration management.
2025-09-30 08:56:30 +08:00
Luis Pater
5010b09329 chore(gitignore): add cli-proxy-api to ignored files 2025-09-30 02:45:48 +08:00
Luis Pater
368fd27393 docs: add Claude 4.5 Sonnet model to README and README_CN
- Updated documentation to include the newly supported Claude 4.5 Sonnet model.
2025-09-30 02:04:04 +08:00
Luis Pater
b2ca49376c feat(models): add support for Claude 4.5 Sonnet model in registry
- Introduced new model definition for `Claude 4.5 Sonnet` with metadata and creation details.
- Ensures compatibility and access to the latest Claude model variant.
2025-09-30 01:58:16 +08:00
Luis Pater
6d98a71796 feat(login): add interactive project selection and improve onboarding flow
- Introduced `promptForProjectSelection` to enable interactive project selection for better user onboarding.
- Improved project validation and handling when no preset project ID is provided.
- Added a default project prompt mechanism to guide users through project selection seamlessly.
- Refined error handling for onboarding and project selection failures.
2025-09-30 01:52:03 +08:00
Luis Pater
1c91823308 feat(auth): enhance DoLogin to include Gemini CLI user onboarding flow
- Integrated Gemini CLI user setup into the `DoLogin` flow for streamlined authentication.
- Added project selection handling, automatic project detection, and validation of Cloud AI API enablement.
- Implemented new helper functions for Gemini CLI operations, project fetching, and onboarding logic.
- Enhanced token storage and metadata updates for better user and project management.
2025-09-30 00:04:58 +08:00
Luis Pater
352a67857b refactor(runtime): move Anthropic-Beta header setting to applyClaudeHeaders for better header management 2025-09-29 20:51:36 +08:00
Luis Pater
644a3ad220 feat(translator): emit response.output_item.done event for reasoning summary completion
- Added `response.output_item.done` event emission in OpenAI responses.
- Enhanced reasoning output finalization with additional response event for improved tracking.
2025-09-29 17:25:41 +08:00
Luis Pater
19c32f58b2 chore(config): comment out API keys and update default settings for logging and usage statistics 2025-09-29 16:44:20 +08:00
Luis Pater
d01c4904ff refactor(auth): replace TokenRecord with coreauth.Auth and migrate TokenStore to coreauth.Store
- Replaced `TokenRecord` with `coreauth.Auth` for centralized and consistent authentication data structures.
- Migrated `TokenStore` interface to `coreauth.Store` for alignment with core CLIProxy authentication.
- Updated related login methods, token persistence logic, and file storage handling to use the new `coreauth.Auth` model.
2025-09-29 09:31:21 +08:00
Luis Pater
8cfa2282ef Merge pull request #71 from ben-vargas/fix-max_output_tokens-codex-oauth
fix(translator): remove unsupported token limit fields for Codex Responses API
2025-09-28 22:08:10 +08:00
Luis Pater
8e88a61021 Merge pull request #72 from router-for-me/log
Minor adjustments to the logs
2025-09-28 22:07:43 +08:00
hkfires
ad4d045101 feat: Restore API key config in config file 2025-09-28 09:11:58 +08:00
hkfires
5888e04654 refactor(cliproxy): remove unused access provider refresh logic 2025-09-28 08:59:17 +08:00
hkfires
19b10cb894 feat(sdk/auth): extend Gemini Web refresh lead to 1 hour 2025-09-28 08:40:32 +08:00
hkfires
aa25820698 chore(log): Refine debug messages for config reloads 2025-09-28 08:40:25 +08:00
Ben Vargas
9e3b84939f fix(translator): remove unsupported token limit fields for Codex Responses API
The OpenAI Codex Responses API (chatgpt.com/backend-api/codex/responses)
rejects requests containing max_output_tokens and max_completion_tokens fields,
causing Factory CLI to fail with "Unsupported parameter" errors.

This fix strips these incompatible fields during request translation, allowing
Factory CLI to work properly with CLIProxyAPI when using ChatGPT Plus/Pro OAuth.

Fixes compatibility issue where Factory sends token limit parameters that aren't
supported by the Codex Responses endpoint.
2025-09-27 15:44:33 -06:00
Luis Pater
1dbb930660 refactor(access): centralize configaccess.Register and remove redundant calls
- Added centralized `configaccess.Register` invocation in `server` initialization.
- Removed duplicate `Register` calls from `reconcile.go` and `builder.go`.
- Simplified logic by removing unnecessary `nil` checks in provider entry collection.
2025-09-27 16:24:15 +08:00
Luis Pater
6557d9b728 refactor(access): migrate config-api-key provider to internal package
- Moved `config-api-key` provider logic from SDK to the internal `config_access` package.
- Updated provider registration and initialization to ensure proper management via `Register` function.
- Removed redundant `config-api-key` documentation, simplifying configuration examples.
- Adjusted related imports and reconciliations for seamless integration with the new structure.
2025-09-27 15:53:26 +08:00
Luis Pater
250628dae3 Merge pull request #70 from router-for-me/log
Fix for the bug causing configuration to fail, and avoidance of invalid scanning of auth files.
2025-09-27 13:59:13 +08:00
hkfires
da72ac1f6d fix(config): Inline SDKConfig for proper YAML parsing 2025-09-27 12:23:20 +08:00
hkfires
f9a170a3c4 chore(watcher): Clarify API key client reload log message 2025-09-27 11:25:40 +08:00
hkfires
88f06fc305 feat(watcher): Log detailed diff for openai-compatibility on reload 2025-09-27 11:15:30 +08:00
hkfires
562a49a194 feat(provider/gemini-web): Prioritize explicit label for account identification 2025-09-27 10:56:15 +08:00
hkfires
6136a77eb3 refactor(util): Centralize auth directory path resolution
Introduces a new utility function, `util.ResolveAuthDir`, to handle the normalization and resolution of the authentication directory path.

Previously, the logic for expanding the tilde (~) to the user's home directory was implemented inline in `main.go`. This refactoring extracts that logic into a reusable function within the `util` package.

The new `ResolveAuthDir` function is now used consistently across the application:
- During initial server startup in `main.go`.
- When counting authentication files in `util.CountAuthFiles`.
- When the configuration is reloaded by the watcher.

This change eliminates code duplication, improves consistency, and makes the path resolution logic more robust and maintainable.
2025-09-27 09:06:51 +08:00
hkfires
afff9216ea perf(watcher): Avoid unnecessary auth dir scan on config reload 2025-09-27 08:43:06 +08:00
hkfires
b56edd4db0 refactor(access): Introduce ApplyAccessProviders helper function
The logic for reconciling access providers, updating the manager, and logging the changes was previously handled directly in the service layer.

This commit introduces a new `ApplyAccessProviders` helper function in the `internal/access` package to encapsulate this entire process. The service layer is updated to use this new helper, which simplifies its implementation and reduces code duplication.

This refactoring centralizes the provider update logic and improves overall code maintainability. Additionally, the `sdk/access` package import is now aliased to `sdkaccess` for clarity.
2025-09-27 08:23:24 +08:00
Luis Pater
d512f20c56 refactor(access): migrate to SDKConfig for authentication and provider management
- Replaced `config.Config` with `SDKConfig` in authentication and provider logic for consistency with SDK changes.
- Updated provider registration, reconciliation, and build functions to align with the `SDKConfig` structure.
- Refactored related imports and handlers to support the new configuration approach.
- Improved clarity and reduced redundancy in API key synchronization and provider initialization.
2025-09-27 05:18:11 +08:00
Luis Pater
57c9ba49f4 refactor(config): migrate to SDKConfig and streamline proxy handling
- Replaced `config.Config` with `config.SDKConfig` across components for simpler configuration management.
- Updated proxy setup functions and handlers to align with `SDKConfig` improvements.
- Reorganized handler imports to match new SDK structure.
2025-09-27 04:50:23 +08:00
Luis Pater
40255b128e feat(translator): add usage metadata aggregation for Claude and OpenAI responses
- Integrated input, output, reasoning, and total token tracking in response processing for Claude and OpenAI.
- Ensured support for usage details even when specific fields are missing in the response.
- Enhanced completion outputs with aggregated usage details for accurate reporting.
2025-09-27 01:12:47 +08:00
Luis Pater
6524d3a51e feat(translator): add usage metadata mapping for Gemini responses
- Implemented mapping for input, output, and total token usage in Gemini OpenAI response processing.
- Ensured compatibility with existing response structure even when specific token details are unavailable.
2025-09-27 00:23:09 +08:00
Luis Pater
92c8cd7c72 refactor(translator): remove unnecessary debug log in ConvertOpenAIRequestToGeminiCLI function 2025-09-26 23:18:58 +08:00
Luis Pater
c678ca21d5 Merge pull request #69 from router-for-me/reload
Implement minimal incremental updates for models and keys
2025-09-26 23:06:27 +08:00
Luis Pater
6d4b43dd7a feat(translator): add user metadata generation for Claude transformation requests
- Introduced unique `user_id` metadata generation in OpenAI to Claude transformation functions.
- Utilized `uuid` and `sha256` for deterministic `account`, `session`, and `user` values.
- Embedded `user_id` into request payloads to enhance request tracking and identification.
2025-09-26 22:47:21 +08:00
hkfires
b0f2ad7cfe fix(cliproxy): Clear stale compatibility model registrations
Previously, if an OpenAI compatibility configuration was removed from the
config file or its model list was emptied, the associated models for
that auth entry were not unregistered from the global model registry.
This resulted in stale registrations persisting.

This change ensures that when an auth entry is identified as being for
a compatibility provider, its models are explicitly unregistered if:
- The corresponding configuration is found but has an empty model list.
- The corresponding configuration is no longer found in the config file.
2025-09-26 22:04:32 +08:00
hkfires
cd0b1be46c fix(log): Reduce noise on metadata updates and provider sync 2025-09-26 21:42:42 +08:00
hkfires
08856a97fb fix(access): Exclude inline provider from reconciliation changes
The `ReconcileProviders` function was incorrectly including the default
inline provider (`access.teleport.dev`) in the lists of added, updated,
and removed providers.

The inline provider is a special case managed directly by the access
controller and does not correspond to a separate, reloadable resource.
Including it in the change lists could lead to errors when attempting
to perform lifecycle operations on it.

This commit modifies the reconciliation logic to explicitly ignore the
inline provider when calculating changes. This ensures that only
external, reloadable providers are reported as changed, preventing
incorrect lifecycle management.
2025-09-26 20:48:20 +08:00
hkfires
b6d5ce2d4d fix(access): Force rebuild of aliased provider configurations
The provider reconciliation logic did not correctly handle aliased provider configurations (e.g., using YAML anchors). When a provider config was aliased, the check for configuration equality would pass, causing the system to reuse the existing provider instance without rebuilding it, even if the underlying configuration had changed.

This change introduces a check to detect if the old and new provider configurations point to the same object in memory. If they are aliased, the provider is now always rebuilt to ensure it reflects the latest configuration. The optimization to reuse an existing provider based on deep equality is now only applied to non-aliased providers.
2025-09-26 20:05:43 +08:00
hkfires
0f55e550cf refactor(registry): Preserve duplicate models in client registration
The `RegisterClient` function previously deduplicated the list of models provided by a client. This could lead to an inaccurate representation of the client's state if it intentionally registered the same model ID multiple times.

This change refactors the registration logic to store the raw, unfiltered list of models, preserving their original order and count.

A new `rawModelIDs` slice tracks the complete list for storage in `clientModels`, while the logic for processing changes continues to use a unique set of model IDs for efficiency. This ensures the registry's state accurately reflects what the client provides.
2025-09-26 19:38:44 +08:00
hkfires
e1de04230f fix(registry): Reset client status on model re-registration
When a client re-registers with the model registry, its previous status for a given model (e.g., quota exceeded or suspended) was not being cleared. This could lead to a situation where a client is permanently unable to use a model even after re-registering.

This change ensures that when a client re-registers an existing model, its ID is removed from the model's `QuotaExceededClients` and `SuspendedClients` lists. This effectively resets the client's status for that model, allowing for a fresh start upon reconnection.
2025-09-26 19:19:24 +08:00
hkfires
a887a337a5 fix(registry): Handle duplicate model IDs in client registration
The previous model registration logic used a set-like map to track the models associated with a client. This caused issues when a client registered multiple instances of the same model ID, as they were all treated as a single registration.

This commit refactors the registration logic to use count maps for both the old and new model lists. This allows the system to accurately track the number of instances for each model ID provided by a client.

The changes ensure that:
- When a client updates its model list, the exact number of added or removed instances for each model ID is correctly calculated.
- Provider counts are accurately incremented or decremented based on the number of model instances being added, removed, or having their provider changed.
- The registry correctly handles scenarios where a client reduces the number of duplicate model registrations (e.g., from `[A, A]` to `[A]`), properly deregistering the surplus instance.
2025-09-26 18:52:58 +08:00
hkfires
2717ba3e50 fix(registry): Avoid provider update when new provider is empty
When a client re-registered and changed its provider from a non-empty value to an empty string, the logic would still trigger a provider update for the client's models. An empty provider string should not cause an update.

This commit fixes this behavior by adding a check to ensure the new provider is a non-empty string before updating the model's provider information.

Additionally, the logic for detecting a provider change has been simplified by removing an unnecessary variable.
2025-09-26 18:32:47 +08:00
hkfires
63af4c551d fix(registry): Fix provider change logic for new models
When a client changed its provider and registered a new model in the same `RegisterClient` call, the logic would incorrectly attempt to decrement the provider count for the new model from the old provider. This was because the loop iterated over all new model IDs without checking if they were part of the client's previous registration.

This commit adds a check to ensure that a model existed in the client's old model set before attempting to decrement the old provider's usage count. This prevents incorrect state updates in the registry during provider transitions that also introduce new models.
2025-09-26 18:32:47 +08:00
hkfires
c675cf5e72 refactor(config): Implement reconciliation for providers and clients
This commit introduces a reconciliation mechanism for handling configuration updates, significantly improving efficiency and resource management.

Previously, reloading the configuration would tear down and recreate all access providers from scratch, regardless of whether their individual configurations had changed. This was inefficient and could disrupt services.

The new `sdkaccess.ReconcileProviders` function now compares the old and new configurations to intelligently manage the provider lifecycle:
- Unchanged providers are kept.
- New providers are created.
- Providers removed from the config are closed and discarded.
- Providers with updated configurations are gracefully closed and recreated.

To support this, a `Close()` method has been added to the `Provider` interface.

A similar reconciliation logic has been applied to the client registration state in `state.RegisterClient`. This ensures that model registrations are accurately tracked when a client's configuration is updated, correctly handling added, removed, and unchanged models. Enhanced logging provides visibility into these operations.
2025-09-26 18:32:47 +08:00
Luis Pater
4fd95ead3b Merge pull request #68 from router-for-me/log
refactor(logging): Improve client loading and registration logs
2025-09-26 18:22:49 +08:00
Luis Pater
514add4b85 refactor(executor): remove redundant handling of "reasoning.effort" in gpt-5 and gpt-5-codex models 2025-09-26 18:13:28 +08:00
hkfires
3ca01b60a5 refactor(logging): Improve client loading and registration logs 2025-09-26 14:01:41 +08:00
Luis Pater
39e398ae02 feat(watcher): ensure reload callback triggers before auth refresh
- Moved `w.reloadCallback(cfg)` invocation to occur before `refreshAuthState()` for proper configuration updates.
2025-09-26 12:10:52 +08:00
Luis Pater
9bbe64489f Merge pull request #67 from router-for-me/rr
fix(auth): Make round-robin auth selection deterministic
2025-09-26 11:17:11 +08:00
Luis Pater
7e54156f2f Merge pull request #66 from router-for-me/log
feat(auth): Enhance Gemini web auth with flexible input and UI
2025-09-26 11:15:35 +08:00
hkfires
9b80820b17 refactor(auth): Move candidate sorting to RoundRobinSelector 2025-09-26 10:50:15 +08:00
hkfires
e836b4ac10 fix(auth): Make round-robin auth selection deterministic 2025-09-26 09:49:53 +08:00
hkfires
f228a4dcca feat(auth): Enhance Gemini web auth with flexible input and UI 2025-09-26 09:43:26 +08:00
Luis Pater
3297f75edd feat(watcher, auth): add stable hash for OpenAI compatibility models
- Introduced `computeOpenAICompatModelsHash` for generating a stable hash of compatibility models.
- Enhanced `watcher` to include the hash in auth attributes, enabling dynamic updates on model list changes.
2025-09-26 03:41:26 +08:00
Luis Pater
25ba042493 feat(config, usage): add usage-statistics-enabled option and dynamic toggling
- Introduced `usage-statistics-enabled` configuration to control in-memory usage aggregation.
- Updated API to include handlers for managing `usage-statistics-enabled` and `logging-to-file` options.
- Enhanced `watcher` to log changes to both configurations dynamically.
- Updated documentation and examples to reflect new configuration options.
2025-09-26 03:19:44 +08:00
Luis Pater
483229779c chore(docs): update README with GUI client, WebUI links, and detailed SDK documentation
- Added references to EasyCLI and Cli-Proxy-API-Management-Center in both English and Chinese README files.
- Updated SDK documentation section with new links for access, watcher, and custom provider examples.
2025-09-26 02:57:49 +08:00
Luis Pater
5a50856fc1 feat(server): add keep-alive endpoint with timeout handling
- Introduced a keep-alive endpoint to monitor service activity.
- Added timeout-specific shutdown functionality when the endpoint is idle.
- Implemented password-protected access for the keep-alive endpoint.
- Updated server startup to support configurable keep-alive options.
2025-09-26 01:45:30 +08:00
Luis Pater
cf734f7e7b feat(logging): introduce centralized logging with custom format and Gin integration
- Implemented a global logger with structured formatting for consistent log output.
- Added support for rotating log files using Lumberjack.
- Integrated new logging functionality with Gin HTTP server for unified log handling.
- Replaced direct `log.Info` calls with `fmt.Printf` in non-critical paths to simplify core functionality.
2025-09-26 00:54:52 +08:00
Luis Pater
72325f792c chore(docs): remove outdated allow-localhost-unauthenticated section from API docs
- Deleted references to `allow-localhost-unauthenticated` endpoint in `MANAGEMENT_API.md` and `MANAGEMENT_API_CN.md`.
2025-09-25 22:54:50 +08:00
Luis Pater
9761ac5045 feat(auth, docs): add label support for Gemini web token management
- Added `label` field to the management API for better token identification.
- Updated request payload and validation logic to include `label` as a required field.
- Adjusted documentation (`MANAGEMENT_API.md`, `MANAGEMENT_API_CN.md`) to reflect changes.
2025-09-25 22:12:50 +08:00
Luis Pater
8fa52e9d31 feat(auth): enhance Gemini web auth with macOS support and input fallback
- Added detection for macOS to adjust behavior for cookie input.
- Improved fallback prompts for missing cookies and email inputs.
2025-09-25 21:57:52 +08:00
Luis Pater
80b6a95eba Merge pull request #65 from router-for-me/gemini-web
feat(auth): Improve Gemini web auth with email label detection
2025-09-25 20:50:04 +08:00
hkfires
96cebd2a35 feat(auth): add interactive prompts to Gemini web auth flow 2025-09-25 20:39:15 +08:00
Luis Pater
fc103f6c17 Merge pull request #64 from router-for-me/bugfix
fix(auth): Scope unavailability checks to specific models
2025-09-25 20:33:26 +08:00
hkfires
a45d2109f3 feat(auth): Improve Gemini web auth with email label detection 2025-09-25 20:17:47 +08:00
hkfires
7a30e65175 refactor(gemini-web): Remove file-based PSIDTS cookie caching 2025-09-25 18:52:31 +08:00
hkfires
c63dc7fe2f fix(auth): Scope unavailability checks to specific models 2025-09-25 18:51:50 +08:00
Luis Pater
830b51c75b Merge pull request #63 from router-for-me/gemini-web
Gemini-web
2025-09-25 11:53:22 +08:00
Luis Pater
cc8c46d5de Merge pull request #62 from router-for-me/dev
feat(auth, docs): add SDK guides and local password support for manag…
2025-09-25 11:42:49 +08:00
Luis Pater
a4767fdd8e feat(auth, docs): add SDK guides and local password support for management
- Added extensive SDK usage guides for `cliproxy`, `sdk/access`, and watcher integration.
- Introduced `--password` flag for specifying local management access passwords.
- Enhanced management API with local password checks to secure localhost requests.
- Updated documentation to reflect the new password functionality.
2025-09-25 11:32:14 +08:00
hkfires
2a274d4a08 Merge branch 'main' into gemini-web 2025-09-25 11:05:43 +08:00
hkfires
2175a10932 feat(gemini-web): Introduce stable account label for identification 2025-09-25 10:59:20 +08:00
hkfires
20f3e62529 feat(auth): Add stable label to Gemini Web tokens for logging 2025-09-25 10:34:46 +08:00
hkfires
7f2e2fee56 refactor(gemini-web): Consolidate conversation data into single BoltDB file 2025-09-25 10:34:46 +08:00
hkfires
9810834f20 refactor(gemini-web): Centralize HTTP client creation for media 2025-09-25 10:34:46 +08:00
hkfires
0d4cb9e9fb refactor(gemini-web): Improve client robustness and code reuse 2025-09-25 10:34:46 +08:00
Luis Pater
f5dc380b63 rebuild branch 2025-09-25 10:32:48 +08:00
Luis Pater
3f69254f43 remove all 2025-09-25 10:31:02 +08:00
Luis Pater
84248b6ec2 merge 2025-09-25 10:30:41 +08:00
Luis Pater
688547b063 refactor(config, auth): remove allow-localhost-unauthenticated support and related handlers
- Eliminated `allow-localhost-unauthenticated` configuration field and its usage.
- Removed associated management API handlers and middleware logic.
- Simplified authentication middleware by deprecating localhost-specific checks.
2025-09-25 09:00:38 +08:00
Luis Pater
ac93641946 refactor(logging, mgmt): update timestamp format in logs and remove force-gpt-5-codex handlers
- Changed log timestamp format in `request_logger.go` to align with ISO standards for improved readability.
- Removed deprecated `force-gpt-5-codex` handlers from management API.
2025-09-25 08:27:53 +08:00
Luis Pater
58f74ebad1 refactor(config): remove force-gpt-5-codex option and related handlers
- Eliminated the deprecated `force-gpt-5-codex` configuration option from all configs, handlers, and documentation.
- Updated examples and README files to reflect the removal.
- Streamlined related code by dropping unused fields and logging.
2025-09-25 08:19:45 +08:00
Luis Pater
e3be548e8d refactor(auth): replace log calls with fmt for improved consistency and standard output usage
- Updated all authentication commands to use `fmt` instead of `log` for errors, info, and success messages.
- Ensured message formatting aligns across commands for consistent user experience.
2025-09-25 08:17:56 +08:00
Luis Pater
2724630430 feat(auth): add Gemini Web token saving endpoint
- Introduced `POST /gemini-web-token` endpoint to save Gemini Web cookies directly.
- Added payload validation and hashed-based file naming for persistence.
- Updated documentation to reflect the new management API functionality.
2025-09-25 08:06:58 +08:00
Luis Pater
bb8f93146f refactor(auth): replace FileStore with FileTokenStore for unified token persistence
- Removed `FileStore` in favor of the new `FileTokenStore`.
- Centralized auth JSON handling and token operations through `FileTokenStore`.
- Updated all components to utilize `FileTokenStore` for consistent storage operations.
- Introduced `SetBaseDir` and directory locking mechanisms for flexible configurations.
- Enhanced metadata management, including path resolution and deep JSON comparisons.
2025-09-25 07:19:25 +08:00
Luis Pater
8fc73874de feat(auth): centralize token store management and enhance persistence
- Introduced `RegisterTokenStore` and `GetTokenStore` to centralize token store access.
- Replaced direct file operations with a unified token persistence API.
- Updated all components to use the shared token store for consistent behavior.
- Improved logging for token save operations to include file paths.
2025-09-25 03:17:50 +08:00
Luis Pater
19609db13c feat(usage): add in-memory usage statistics tracking and API endpoint
- Introduced in-memory request statistics aggregation in `LoggerPlugin`.
- Added new structures for detailed metrics collection (e.g., token breakdown, request success/failure).
- Implemented `/usage` management API endpoint for retrieving aggregated statistics.
- Updated management handlers to support the new usage statistics functionality.
- Enhanced documentation to describe the usage metrics API.
2025-09-25 01:08:26 +08:00
Luis Pater
0db0b03db9 chore(docs): add and refine package-level comments across modules
- Added detailed package-level comments to improve documentation coverage.
- Clarified parameter descriptions, return types, and functionality of exported methods across packages.
- Enhanced overall code readability and API documentation consistency.
2025-09-25 00:14:17 +08:00
Luis Pater
3c5390a87e Merge remote-tracking branch 'origin/v6' into v6 2025-09-25 00:09:59 +08:00
Luis Pater
e9d2905a82 Merge pull request #61 from router-for-me/gemini-web
Reduce the size of gemini-web's package files
2025-09-24 23:52:23 +08:00
Luis Pater
48bbd9e214 fix(gemini): handle "[DONE]" chunk, trim "data:" prefix, and remove session_id from requests
- Adjusted stream handling to skip "[DONE]" chunks.
- Ensured "data:" prefix is trimmed for non-prefixed input in translation.
- Removed `session_id` from request bodies before processing.
2025-09-24 23:34:46 +08:00
hkfires
4ecc798b1b Reduce the size of gemini-web's package files 2025-09-24 22:30:00 +08:00
Luis Pater
68be2f023f Merge pull request #60 from router-for-me/v6-test
Move gemini-web to provider
2025-09-24 22:15:54 +08:00
hkfires
c76b8785f8 refactor(gemini-web): Standardize logging with logrus 2025-09-24 22:13:10 +08:00
hkfires
d4f5ec2492 Removed the cookie snapshot feature. 2025-09-24 22:12:29 +08:00
hkfires
06a3e9792d chore: Ignore .serena directory 2025-09-24 22:12:29 +08:00
hkfires
e9707c2f9e refactor(gemini-web): Move provider logic to its own package
The Gemini Web API client logic has been relocated from `internal/client/gemini-web` to a new, more specific `internal/provider/gemini-web` package. This refactoring improves code organization and modularity by better isolating provider-specific implementations.

As a result of this move, the `GeminiWebState` struct and its methods have been exported (capitalized) to make them accessible from the executor. All call sites have been updated to use the new package path and the exported identifiers.
2025-09-24 22:12:29 +08:00
Luis Pater
ab55373bc5 feat(auth): introduce per-model state tracking and enhanced error handling
- Added `ModelState` for detailed per-model runtime status management.
- Implemented methods to manage model-specific error handling, quotas, and recovery logic.
- Enhanced aggregated availability calculations for auth entries with model-specific states.
- Updated retry and recovery logic to operate separately for models and auth entries.
- Improved selector logic to filter based on model states and availability.
2025-09-24 21:48:31 +08:00
Luis Pater
a2c5fdaf66 refactor(executor): remove ClientAdapter and legacy fallback logic
- Deleted `ClientAdapter` implementation and associated fallback methods.
- Removed legacy executor logic from `codex`, `claude`, `gemini`, and `qwen` executors.
- Simplified `handlers` by eliminating `UnwrapError` handling and related dependencies.
- Cleaned up `model_registry` by removing logic associated with suspended clients.
- Updated `.gitignore` to ignore `.serena/` directory.
2025-09-24 21:09:36 +08:00
hkfires
b86ed46845 fix(codex): Remove reasoning.effort for default gpt-5-codex model 2025-09-24 13:17:19 +08:00
Luis Pater
3dd5095792 feat(translators): add token counting support for Claude and Gemini responses
- Implemented `TokenCount` transform method across translators to calculate token usage.
- Integrated token counting logic into executor pipelines for Claude, Gemini, and CLI translators.
- Added corresponding API endpoints and handlers (`/messages/count_tokens`) for token usage retrieval.
- Enhanced translation registry to support `TokenCount` functionality alongside existing response types.
2025-09-24 11:59:38 +08:00
Luis Pater
582677d067 refactor(usage): replace channel-based queue with mutex-protected slice
- Switched to a slice-based queue with mutex and condition variable for better control over queuing and dispatching.
- Removed fixed buffer size to handle dynamic queuing.
- Enhanced shutdown logic to safely close the queue and wake up waiting goroutines.
2025-09-24 03:59:26 +08:00
Luis Pater
3ade03f3b3 feat(usage): implement usage tracking infrastructure across executors
- Added `LoggerPlugin` to log usage metrics for observability.
- Introduced a new `Manager` to handle usage record queuing and plugin registration.
- Integrated new usage reporter and detailed metrics parsing into executors, covering providers like OpenAI, Codex, Claude, and Gemini.
- Improved token usage breakdown across streaming and non-streaming responses.
2025-09-24 03:49:09 +08:00
Luis Pater
5090d9853b feat(translators): improve system instruction extraction and input handling for OpenAI and Claude responses
- Enhanced support for extracting system instructions from input arrays.
- Improved input message role and type determination logic for consistent message processing.
- Refined instruction handling logic across translator types for better compatibility.
2025-09-24 00:20:49 +08:00
Luis Pater
d41ff2076f feat(translators): improve system instruction extraction and input handling for OpenAI and Claude responses
- Enhanced support for extracting system instructions from input arrays.
- Improved input message role and type determination logic for consistent message processing.
- Refined instruction handling logic across translator types for better compatibility.
2025-09-23 23:12:34 +08:00
Luis Pater
b018072914 fix(gemini): trim "data:" prefix in raw JSON and resolve variable shadowing in stream translation 2025-09-23 21:22:41 +08:00
Luis Pater
361a69f4de Merge pull request #59 from router-for-me/gemini-web-v6
fix(gemini-web): Correct stream translation and reduce auth refresh lead
2025-09-23 21:09:41 +08:00
hkfires
73cf491478 fix(gemini-web): Correct stream translation and reduce auth refresh lead 2025-09-23 20:51:55 +08:00
Luis Pater
9df04d71e2 feat(translators/claude): implement non-streaming response parsing for various translator types
- Added `ConvertCodexResponseToClaudeNonStream`, `ConvertGeminiCLIResponseToClaudeNonStream`, `ConvertGeminiResponseToClaudeNonStream`, and `ConvertOpenAIResponseToClaudeNonStream` methods for handling non-streaming JSON response conversion.
- Introduced logic for parsing and structuring content, handling reasoning, text, and tool usage blocks.
- Enhanced support for stop reasons and refined token usage data aggregation.
2025-09-23 20:42:48 +08:00
Luis Pater
c159180589 Merge pull request #58 from router-for-me/v6-test
refactor(gemini-web): Remove auto-refresh, auto-close, and caching
2025-09-23 18:20:47 +08:00
hkfires
8e485e5868 refactor(gemini-web): Remove auto-refresh, auto-close, and caching
This commit simplifies the Gemini web client by removing several complex, stateful features. The previous implementation for auto-refreshing cookies and auto-closing the client involved background goroutines, timers, and file system caching, which made the client's lifecycle difficult to manage.

The following features have been removed:
- The cookie auto-refresh mechanism, including the background goroutine (`rotateCookies`) and related configuration fields.
- The file-based caching for the `__Secure-1PSIDTS` token. The `rotate1PSIDTS` function now fetches a new token on every call.
- The auto-close functionality, which used timers to close the client after a period of inactivity.
- Associated configuration options and methods (`WithAccountLabel`, `WithOnCookiesRefreshed`, `Close`, etc.).

By removing this logic, the client becomes more stateless and predictable. The responsibility for managing the client's lifecycle and handling token expiration is now shifted to the caller, leading to a simpler and more robust integration.
2025-09-23 12:48:30 +08:00
Luis Pater
11b0efc38f feat(claude-executor): add ZSTD decoding support for Claude executor responses
- Integrated ZSTD decompression via `github.com/klauspost/compress` for responses with "zstd" content-encoding.
- Added helper `hasZSTDEcoding` to detect ZSTD-encoded responses.
- Updated response handling logic to initialize and use a ZSTD decoder when necessary.

refactor(api-handlers): split streaming and non-streaming response handling

- Introduced `handleNonStreamingResponse` for processing non-streaming requests in `ClaudeCodeAPIHandler`.
- Improved code clarity by separating streaming and non-streaming logic.

fix(service): remove redundant token refresh interval assignment logic in `cliproxy` service.
2025-09-23 12:44:44 +08:00
Luis Pater
45d382f344 Merge pull request #57 from router-for-me/v6-test
feat(gemini-web): Inject fallback text for image-only flash model responses
2025-09-23 10:24:57 +08:00
Luis Pater
5bf7a9575c fix(auth): address index logic bug and remove redundant conditions
- Updated loop iteration in `AuthSelector` to correct index management for selecting candidates.
- Fixed cursor index reset condition for large values to prevent overflow.
- Removed unnecessary conditional reassignment of `allowRemote` in management handler for clarity and correctness.
2025-09-23 10:21:45 +08:00
hkfires
50c8f7f96f feat(gemini-web): Inject fallback text for image-only flash model responses 2025-09-23 10:05:59 +08:00
hkfires
e8e00d4cb8 refactor(watcher): Remove unnecessary log separator 2025-09-23 10:05:59 +08:00
hkfires
49232372a7 fix(log): Trim trailing newlines to prevent blank log lines 2025-09-23 10:05:59 +08:00
Luis Pater
72ffeb73d3 refactor(auth): replace NextRefreshAfter with NextRetryAfter for clarity and consistency
- Renamed field `NextRefreshAfter` to `NextRetryAfter` across `AuthManager`, `types`, and selector logic.
- Updated references to ensure proper handling of retry timing logic.
- Improved code readability and clarified retry behavior for different auth states.
2025-09-23 09:48:49 +08:00
Luis Pater
e68a6037e2 feat(auth): enable model suspension and resumption logic in AuthManager
- Added model suspension with reason tracking for 401 (unauthorized) and 402/403 (payment-related) errors.
- Implemented resumption logic upon model quota recovery or auth state changes.
- Enhanced registry to manage suspended clients, including counts and observability data.
- Updated availability computation to exclude suspended clients, ensuring accurate client model tracking.
2025-09-23 09:24:55 +08:00
Luis Pater
ec08500924 fix(auth): resolve drain label for correct loop termination in cliproxy service 2025-09-23 08:20:36 +08:00
Luis Pater
6046a8c95b feat(auth): enhance watcher with asynchronous dispatch and buffering
- Added async dispatch loop to `Watcher` for handling incremental `AuthUpdate` with in-memory buffering.
- Improved resilience against high-frequency auth changes by coalescing updates and reducing redundant processing.
- Updated `cliproxy` service to increase auth update queue capacity and optimize backlog consumption.
- Added detailed SDK integration documentation in English and Chinese (`sdk-watcher.md`, `sdk-watcher_CN.md`).
2025-09-23 04:33:48 +08:00
Luis Pater
792ec49e5b feat(auth): implement incremental auth updates with queue integration
- Added support for incremental auth updates using `AuthUpdate` and `AuthUpdateAction`.
- Integrated `SetAuthUpdateQueue` to propagate updates through a dedicated channel.
- Introduced new methods for handling auth add, modify, and delete actions.
- Updated service to ensure auth update queues are correctly initialized and consumed.
- Improved auth state synchronization across core and file-based clients with real-time updates.
- Refactored redundant auth handling logic for better efficiency and maintainability.
2025-09-23 04:16:22 +08:00
Luis Pater
3ffd87d8de docs(sdk-access): add SDK usage guides in English and Chinese
- Added `sdk-access.md` and `sdk-access_CN.md` documentation files.
- Included detailed guidelines for authentication manager lifecycle, configuration, built-in and custom providers.
- Documented integration steps with `cliproxy` and instructions for hot reloading.
2025-09-23 03:27:03 +08:00
Luis Pater
e313d39be8 feat(gemini-executor): implement CountTokens method with request translation and API integration
- Added `CountTokens` for token counting requests in Gemini executor.
- Integrated request translation via `sdktranslator` and response handling.
- Improved error handling, logging, and API request configuration with headers.
2025-09-23 02:45:08 +08:00
Luis Pater
ac59023abb feat(executor): add CountTokens support across all executors
- Introduced `CountTokens` method to Codex, Claude, Gemini, Qwen, OpenAI-compatible, and other executors.
- Implemented `ExecuteCount` in `AuthManager` for token counting via provider round-robin.
- Updated handlers to leverage `ExecuteCountWithAuthManager` for streamlined token counting.
- Added fallback and error handling logic for token counting requests.
2025-09-23 02:27:51 +08:00
Luis Pater
d32fc0400e refactor(headers): centralize header logic using EnsureHeader utility
- Introduced `EnsureHeader` in `internal/misc/header_utils.go` to streamline header setting across executors.
- Updated Codex, Claude, and Gemini executors to utilize `EnsureHeader` for consistent header application.
- Incorporated Gin context headers (if available) into request header manipulation for better integration.
2025-09-23 02:01:57 +08:00
Luis Pater
7ea88358f0 refactor(executor): centralize header application logic for executors
- Replaced repetitive header setting logic with helper methods (`applyCodexHeaders`, `applyClaudeHeaders`, `applyQwenHeaders`) in Codex, Claude, and Qwen executors.
- Ensured consistent headers in HTTP requests across all executors.
- Introduced UUID and additional structured headers for better traceability (e.g., session IDs, metadata).
2025-09-23 01:20:10 +08:00
Luis Pater
c5df806ad2 chore(executor): add debug logging for API request errors
- Added detailed debug logs in all executors (Codex, Claude, Gemini, Qwen, OpenAI-compatible) to capture HTTP status and response body for failed API requests.
2025-09-23 00:04:31 +08:00
Luis Pater
c6b391304d chore(executor): add debug logging for API request errors
- Added detailed debug logs in all executors (Codex, Claude, Gemini, Qwen, OpenAI-compatible) to capture HTTP status and response body for failed API requests.
2025-09-22 23:37:53 +08:00
Luis Pater
2e836cee88 feat(auth): standardize last_refresh metadata handling across executors
- Added `last_refresh` timestamp to metadata for Codex, Claude, Qwen, and Gemini executors.
- Implemented `extractLastRefreshTimestamp` utility for parsing diverse timestamp formats in management handlers.
- Ensured consistent update and preservation of `last_refresh` in file-based auth handling.
2025-09-22 23:23:31 +08:00
Luis Pater
e41d127732 feat(openai-compat): enhance provider key handling and model resolution
- Introduced dynamic `providerKey` resolution for OpenAI-compatible providers, incorporating attributes like `provider_key` and `compat_name`.
- Implemented upstream model overrides via `resolveUpstreamModel` and `overrideModel` methods in the OpenAI executor.
- Updated registry logic to correctly store provider mappings and register clients using normalized keys.
- Ensured consistency in handling empty or default provider names across components.
2025-09-22 22:54:21 +08:00
Luis Pater
f1c4caf14a feat(logging): integrate logrus with custom Gin middleware for enhanced request logging and recovery
- Added `GinLogrusLogger` for structured request logging using Logrus.
- Implemented `GinLogrusRecovery` to handle panics and log stack traces.
- Configured log rotation using Lumberjack for efficient log management.
- Replaced Gin's default logger and recovery middleware with the custom implementations.
2025-09-22 22:17:12 +08:00
Luis Pater
c9ce3a5464 Merge pull request #55 from router-for-me/v6-test
feat(gemini-web): Implement proactive PSIDTS cookie rotation
2025-09-22 22:02:34 +08:00
hkfires
22a69333a0 feat(gemini-web): Implement proactive PSIDTS cookie rotation 2025-09-22 21:54:52 +08:00
Luis Pater
ed87dda0a6 refactor(client): remove deprecated and unused methods for cleaner session handling and core auth management 2025-09-22 21:22:32 +08:00
Luis Pater
053134f66e refactor(auth): remove unused Refresh methods from authenticators
- Deleted `Refresh` implementations in Codex, Claude, Gemini, Qwen, and Gemini-web authenticators.
- Updated the `Authenticator` interface to exclude `Refresh` for cleaner design.
- Revised `Manager` and related components to handle refresh logic improvements.
- Simplified token refresh behavior and eliminated redundant code paths.
2025-09-22 21:11:53 +08:00
Luis Pater
837ae1b1b3 chore(logging): add debug logs for executor Refresh methods
- Introduced `logrus` for structured debugging across all executors.
- Added debug log messages in `Refresh` methods for better traceability.
- Updated `Manager` to log additional details during refresh checks.
2025-09-22 20:03:31 +08:00
Luis Pater
4008be19f4 feat(auth): introduce auth.providers for flexible authentication configuration
- Replaced legacy `api-keys` field with `auth.providers` in configuration, supporting multiple authentication providers including `config-api-key`.
- Added synchronization to maintain compatibility with legacy `api-keys`.
- Updated core components like request handling and middleware to use the new provider system.
- Enhanced management API endpoints for seamless integration with `auth.providers`.
2025-09-22 17:36:31 +08:00
Luis Pater
c28a5d24f8 Merge pull request #54 from router-for-me/v6-test
fix(auth): Improve file-based auth handling and consistency
2025-09-22 17:25:27 +08:00
hkfires
314125e7ec fix(logging): Prevent race conditions in FileStreamingLogWriter 2025-09-22 16:46:01 +08:00
hkfires
759bb88a90 fix(auth): Improve file-based auth handling and consistency 2025-09-22 13:03:47 +08:00
Luis Pater
0607e52767 feat: implement token refresh support for executors
- Added `Refresh` method implementations for Codex, Claude, Gemini, and Qwen executors.
- Introduced OAuth-based token handling for Gemini and Qwen with support for refresh tokens.
- Updated Codex and Claude to use new internal auth services.
- Enhanced metadata structure and consistency for token storage across all executors.
2025-09-22 09:27:03 +08:00
hkfires
d6bb143978 refactor(translator): Remove unused logrus imports 2025-09-22 08:01:37 +08:00
Luis Pater
f81898c906 feat: introduce custom provider example and remove redundant debug logs
- Added `examples/custom-provider/main.go` showcasing custom executor and translator integration using the SDK.
- Removed redundant debug logs from translator modules to enhance code cleanliness.
- Updated SDK documentation with new usage and advanced examples.
- Expanded the management API with new endpoints, including request logging and GPT-5 Codex features.
2025-09-22 03:37:53 +08:00
Luis Pater
d5ad5fab87 feat: enhance request logging and account handling in CLI proxy
- Added helper functions to log API request and response payloads in the Gin context.
- Improved `AccountInfo` to support cookie-based authentication in addition to API key and OAuth.
- Updated log messages for better clarity on account types used.
2025-09-22 03:09:15 +08:00
Luis Pater
d9ad65622a refactor: standardize constant naming and improve file-based auth handling
- Renamed constants from uppercase to CamelCase for consistency.
- Replaced redundant file-based auth handling logic with the new `util.CountAuthFiles` helper.
- Fixed various error-handling inconsistencies and enhanced robustness in file operations.
- Streamlined auth client reload logic in server and watcher components.
- Applied minor code readability improvements across multiple packages.
2025-09-22 02:56:45 +08:00
Luis Pater
4999fce7f4 v6 version first commit 2025-09-22 01:40:24 +08:00
Luis Pater
e5a6fd2d4f refactor: standardize dataTag processing across response translators
- Unified `dataTag` initialization by removing spaces after `data:`.
- Replaced manual slicing with `bytes.TrimSpace` for consistent and robust handling of JSON payloads.
2025-09-21 11:16:03 +08:00
Luis Pater
83a1fa618d Merge pull request #52 from router-for-me/gemini-web
Add support for image generation with Gemini models through the OpenAI chat completions translator.
2025-09-20 20:12:51 +08:00
hkfires
9253bdbf77 feat(provider): Introduce dedicated provider type for Gemini-Web 2025-09-20 19:47:58 +08:00
hkfires
41effa5aeb feat(gemini-web): Add support for image generation with Gemini models through the OpenAI chat completions translator. 2025-09-20 19:34:53 +08:00
Luis Pater
b07ed71de2 Merge pull request #51 from router-for-me/gemini-web
feat(gemini-web): Add support for real Nano Banana model
2025-09-20 14:26:03 +08:00
hkfires
deaa64b080 feat(gemini-web): Add support for real Nano Banana model 2025-09-20 13:35:27 +08:00
Luis Pater
d42384cdb7 Merge branch 'dev' 2025-09-20 01:38:36 +08:00
Luis Pater
24f243a1bc feat: add support for Gemini 2.5 Flash image preview alias
- Introduced `gemini-2.5-flash-image-preview` alias in `GeminiWebAliasMap` for enhanced model handling.
- Added `gemini-2.5-flash-image-preview` as a new model variant with custom ID, name, display name, and description.
2025-09-20 01:37:42 +08:00
Luis Pater
67a4fe703c Merge branch 'dev' 2025-09-20 00:33:36 +08:00
Luis Pater
c16a989287 Merge pull request #50 from router-for-me/auth-dev 2025-09-20 00:31:08 +08:00
Luis Pater
bc376ad419 Merge pull request #49 from router-for-me/gemini-web 2025-09-20 00:28:46 +08:00
hkfires
aba719f5fe refactor(auth): Centralize auth file reading with snapshot preference
The logic for reading authentication files, which includes retries and a preference for cookie snapshot files, was previously implemented locally within the `watcher` package. This was done to handle potential file locks during writes.

This change moves this functionality into a shared `ReadAuthFileWithRetry` function in the `util` package to promote code reuse and consistency.

The `watcher` package is updated to use this new centralized function. Additionally, the initial token loading in the `run` command now also uses this logic, making it more resilient to file access issues and consistent with the watcher's behavior.
2025-09-20 00:14:26 +08:00
hkfires
1d7abc95b8 fix(gemini-web): ensure colon spacing in JSON output for compatibility 2025-09-19 23:32:52 +08:00
hkfires
1dccdb7ff2 Merge branch 'cookie_snapshot' into dev 2025-09-19 12:40:59 +08:00
hkfires
395164e2d4 feat(log): Add separator when saving client credentials 2025-09-19 12:36:17 +08:00
Luis Pater
b449d17124 Merge pull request #47 from router-for-me/dev 2025-09-19 12:03:30 +08:00
Luis Pater
6ad5e0709c Merge pull request #46 from router-for-me/cookie_snapshot 2025-09-19 12:01:21 +08:00
hkfires
4bfafbe3aa refactor(watcher): Move API key client creation to watcher package 2025-09-19 11:46:17 +08:00
hkfires
2274d7488b refactor(auth): Centralize logging for saving credentials
The logic for logging the path where credentials are saved was duplicated across several client implementations.

This commit refactors this behavior by creating a new centralized function, `misc.LogSavingCredentials`, to handle this logging. The `SaveTokenToFile` method in each authentication token storage struct now calls this new function, ensuring consistent logging and reducing code duplication.

The redundant logging statements in the client-level `SaveTokenToFile` methods have been removed.
2025-09-19 11:46:17 +08:00
hkfires
39518ec633 refactor(client): Improve auth file handling and client lifecycle 2025-09-19 11:46:17 +08:00
hkfires
6bd37b2a2b fix(client): Prevent overwriting auth file on update 2025-09-19 11:46:16 +08:00
hkfires
f17ec7ffd8 fix(client): Prevent overwriting auth file on update 2025-09-19 11:46:16 +08:00
hkfires
d9f8129a32 fix(client): Add reason to unregistration to skip persistence 2025-09-19 11:46:16 +08:00
hkfires
8f0a345e2a refactor(watcher): Filter irrelevant file system events early 2025-09-19 11:46:16 +08:00
hkfires
56b2dabcca refactor(auth): Introduce generic cookie snapshot manager
This commit introduces a generic `cookies.Manager` to centralize the logic for handling cookie snapshots, which was previously duplicated across the Gemini and PaLM clients. This refactoring eliminates code duplication and improves maintainability.

The new `cookies.Manager[T]` in `internal/auth/cookies` orchestrates the lifecycle of cookie data between a temporary snapshot file and the main token file. It provides `Apply`, `Persist`, and `Flush` methods to manage this process.

Key changes:
- A generic `Manager` is created in `internal/auth/cookies`, usable for any token storage type.
- A `Hooks` struct allows for customizable behavior, such as custom merging strategies for different token types.
- Duplicated snapshot handling code has been removed from the `gemini-web` and `palm` persistence packages.
- The `GeminiWebClient` and `PaLMClient` have been updated to use the new `cookies.Manager`.
- The `auth_gemini` and `auth_palm` CLI commands now leverage the client's `Flush` method, simplifying the command logic.
- Cookie snapshot utility functions have been moved from `internal/util/files.go` to a new `internal/util/cookies.go` for better organization.
2025-09-19 11:46:09 +08:00
hkfires
7632204966 refactor(cookie): Extract cookie snapshot logic to util package
The logic for managing cookie persistence files was previously implemented directly within the `gemini-web` client's persistence layer. This approach was not reusable and led to duplicated helper functions.

This commit refactors the cookie persistence mechanism by:
- Renaming the concept from "sidecar" to "snapshot" for clarity.
- Extracting file I/O and path manipulation logic into a new, generic `internal/util/cookie_snapshot.go` file.
- Creating reusable utility functions: `WriteCookieSnapshot`, `TryReadCookieSnapshotInto`, and `RemoveCookieSnapshot`.
- Updating the `gemini-web` persistence code to use these new centralized utility functions.

This change improves code organization, reduces duplication, and makes the cookie snapshot functionality easier to maintain and potentially reuse across other clients.
2025-09-19 11:44:27 +08:00
Luis Pater
c0fbc1979e refactor: remove redundant Codex instruction validation logic
- Eliminated unnecessary calls to `misc.CodexInstructions` and corresponding checks in response processing.
- Streamlined instruction handling by directly updating "instructions" field from the original request payload.
2025-09-19 09:19:18 +08:00
Luis Pater
d00604dd28 Refine Codex instructions for GPT-5 use case
- Simplified `gpt_5_instructions.txt` by removing redundancy and aligning scope with AGENTS.md standard instructions.
- Enhanced clarity and relevance of directives per Codex context.
2025-09-19 09:09:22 +08:00
Luis Pater
869a3dfbb4 feat: implement model-specific Codex instructions for GPT-5
- Added `CodexInstructions(modelName string)` function to dynamically select instructions based on the model (e.g., GPT-5 Codex).
- Introduced `gpt_5_instructions.txt` and `gpt_5_codex_instructions.txt` for respective model configurations.
- Updated translators to pass `modelName` and use the new instruction logic.
2025-09-19 08:47:54 +08:00
Luis Pater
df66046b14 feat: add client availability tracking and error handling improvements
- Introduced `IsAvailable` and `SetUnavailable` methods to clients for availability tracking.
- Integrated availability checks in client selection logic to skip unavailable clients.
- Enhanced error handling by marking clients unavailable on specific error codes (e.g., 401, 402).
- Removed redundant quota verification logs in client reordering logic.
2025-09-19 01:53:38 +08:00
Luis Pater
9ec8478b41 Merge pull request #44 from luispater/gemini-web
Merge gemini-web into dev
2025-09-18 16:00:18 +08:00
hkfires
bb6ec7ca81 fix(gemini-web): Correct inaccurate cookie refresh log message 2025-09-18 12:35:39 +08:00
hkfires
1b2e3dc7af feat(gemini): Implement pseudo-streaming and improve context reuse
This commit introduces two major enhancements to the Gemini Web client to improve user experience and conversation continuity.

First, it implements a pseudo-streaming mechanism for non-code mode. The Gemini Web API returns the full response at once in this mode, leading to a poor user experience with a long wait for output. This change splits the full response into smaller chunks and sends them with an 80ms delay, simulating a real-time streaming effect.

Second, the conversation context reuse logic is now more robust. A fallback mechanism has been added to reuse conversation metadata when a clear continuation context is detected (e.g., a user replies to an assistant's turn). This improves conversational flow. Metadata lookups have also been improved to check both the canonical model key and its alias for better compatibility.
2025-09-18 11:22:56 +08:00
hkfires
580ec737d3 feat: Add support for Gemini Web via cookie authentication 2025-09-17 20:36:07 +08:00
hkfires
e4dd22b260 feat(gemini-web): squash all features and fixes for gemini-web 2025-09-17 20:24:23 +08:00
Luis Pater
172f282e9e feat: add graceful shutdown for streaming response handling
- Introduced `streamDone` channel to signal the completion of streaming goroutines.
- Updated `processStreamingChunks` to incorporate proper cleanup and defer close operations.
- Ensured `streamWriter` and associated resources are released when streaming completes.
2025-09-17 19:46:57 +08:00
Luis Pater
7f0c9b1942 Merge pull request #43 from kyle-the-dev/fix/gemini-schema-sanitization
fix: comprehensive JSON Schema sanitization for Claude to Gemini
2025-09-16 20:26:32 +08:00
Luis Pater
8c2063aeea Fix log key name from gpt-5-codex to force-gpt-5-codex for consistency 2025-09-16 20:03:48 +08:00
Luis Pater
ed6e7750e2 Update config key gpt-5-codex to force-gpt-5-codex for clarity in example configuration file 2025-09-16 20:00:21 +08:00
Kyle Ryan
a0c389a854 fix: comprehensive JSON Schema sanitization for Claude to Gemini
- Add SanitizeSchemaForGemini utility handling union types, allOf, exclusiveMinimum
- Fix both gemini-cli and gemini API translators
- Resolve "Proto field is not repeating, cannot start list" errors
- Maintain backward compatibility with fallback logic

This fixes Claude Code CLI compatibility issues when using tools with either
Gemini CLI credentials or direct Gemini API keys by properly sanitizing
JSON Schema fields that are incompatible with Gemini's Protocol Buffer validation.
2025-09-16 18:24:49 +08:00
hkfires
e9037fceb0 fix(windows): Improve path handling and file read reliability 2025-09-16 10:01:58 +08:00
Luis Pater
2406cc775e Add GPT-5 Codex model support and configuration options in documentation 2025-09-16 04:53:25 +08:00
Luis Pater
b84cbee77a Add support for forcing GPT-5 Codex model configuration
- Introduced a new `ForceGPT5Codex` configuration option in settings.
- Added relevant API endpoints for managing `ForceGPT5Codex`.
- Enhanced Codex client to handle GPT-5 Codex-specific logic and mapping.
- Updated example configuration file to include the new option.

Add GPT-5 Codex model support and configuration options in documentation
2025-09-16 04:40:19 +08:00
Luis Pater
fa762e69a4 Merge branch 'dev' of github.com:luispater/CLIProxyAPI into dev 2025-09-14 22:55:18 +08:00
Luis Pater
7e0fd1e260 Add Keep-Alive header 2025-09-14 22:54:36 +08:00
hkfires
d6037e5549 Refine .gitignore and .dockerignore files 2025-09-14 16:29:06 +08:00
Luis Pater
9fce13fe03 Update internal module imports to use v5 package path
- Updated all `github.com/luispater/CLIProxyAPI/internal/...` imports to point to `github.com/luispater/CLIProxyAPI/v5/internal/...`.
- Adjusted `go.mod` to specify `module github.com/luispater/CLIProxyAPI/v5`.
2025-09-13 23:34:32 +08:00
Luis Pater
4375822cbb Resolve logsDir path relative to configuration directory in FileRequestLogger 2025-09-12 15:28:07 +08:00
Luis Pater
e0d13148ef Merge branch 'main' into dev 2025-09-12 13:12:02 +08:00
Luis Pater
bd68472d3c Merge pull request #41 from kaixxx/main
Codex CLI - setting 'store = false' to prevent the request being rejected by OpenAI
2025-09-12 13:10:22 +08:00
kaixxx
b3c534bae5 Build instructions reformatting 2025-09-12 01:16:54 +02:00
kaixxx
b7d6ae1b48 Windows build instructions 2025-09-12 01:12:51 +02:00
kaixxx
aacfcae382 Codex CLI - setting 'store = false'
store = true leads to:
BadRequestError("Error code: 400 - {'detail': 'Store must be set to false'}")
2025-09-12 00:59:49 +02:00
Luis Pater
1c92034191 Implement IP-based rate limiting and ban mechanism for management API
- Introduced a new `attemptInfo` structure to track failed login attempts per IP.
- Added logic to temporarily ban IPs exceeding the allowed number of failures.
- Enhanced middleware to reset failed attempt counters on successful authentication.
- Updated `Handler` to include a `failedAttempts` map with thread-safe access.
2025-09-12 03:00:37 +08:00
Luis Pater
ef8820e4e4 Default tokenType to an empty string instead of "gemini" in watcher.go and run.go. 2025-09-11 21:09:27 +08:00
Luis Pater
35daffdb2f Ensure auth directory existence before processing tokens in run.go 2025-09-11 18:03:42 +08:00
Luis Pater
0983119ae2 Remove API key truncation in Gemini client ID generation 2025-09-11 10:22:42 +08:00
Luis Pater
0371062e86 Normalize select to STRING type in Gemini OpenAI request outputs 2025-09-10 23:54:16 +08:00
Luis Pater
74bae32c83 Filter OpenAI models response to include only essential fields (id, object, created, owned_by). 2025-09-10 17:50:33 +08:00
Luis Pater
4e67cd4baf Resolve relative logsDir to executable directory in FileRequestLogger 2025-09-10 03:15:58 +08:00
Luis Pater
0449fefa60 Enhance OAuth handling for Anthropic, Codex, Gemini, and Qwen tokens
- Transitioned OAuth callback handling from temporary servers to predefined persistent endpoints.
- Simplified token retrieval by replacing in-memory handling with state-file-based persistence.
- Introduced unified `oauthStatus` map for tracking flow progress and errors.
- Added new `/auth/*/callback` routes, streamlining code and state management for OAuth flows.
- Improved error handling and logging in token exchange and callback flows.
2025-09-10 02:34:22 +08:00
Luis Pater
156e3b017d Restrict management key validation to non-localhost requests only 2025-09-09 23:28:16 +08:00
Luis Pater
d4dc7b0a34 Merge pull request #39 from luispater/config_hash
Avoid unnecessary config.yaml reloads via hash check
2025-09-09 09:26:34 +08:00
hkfires
ebf2a26e72 Avoid unnecessary config.yaml reloads via hash check 2025-09-09 09:11:57 +08:00
Luis Pater
545dff8b64 Add OAuth support for Gemini CLI, Claude, Codex, and Qwen authentication
- Implemented new handlers (`RequestGeminiCLIToken`, `RequestAnthropicToken`, `RequestCodexToken`, `RequestQwenToken`) for initiating OAuth flows.
- Added endpoints for authorization URLs in management API.
- Extracted `GenerateRandomState` to a reusable utility in `misc` package.
- Refactored related logic in `openai_login.go` and `anthropic_login.go` for consistency.

Add endpoints for initiating OAuth flows in management API documentation

- Documented new endpoints for Anthropic, Codex, Gemini CLI, and Qwen login URLs.
- Provided request and response examples for each endpoint in both English and Chinese versions.
2025-09-09 02:54:06 +08:00
Luis Pater
7353bc0b2b Fix bug: #38 about lobechat cors policy
Relax CORS policy by allowing all headers in API responses
2025-09-08 23:36:43 +08:00
Luis Pater
99c9f3069c Fixed bug #38
Add support for API key indexing in OpenAI compatibility clients

- Updated `NewOpenAICompatibilityClient` to accept `apiKeyIndex` for managing multiple API keys.
- Modified client instantiation loops to initialize one client per API key.
- Adjusted client ID format to include `apiKeyIndex` for unique identification.
- Removed API key rotation logic within `GetCurrentAPIKey`.
- Updated `.gitignore` to include `AGENTS.md`.
2025-09-08 22:36:44 +08:00
Luis Pater
f9f2333997 Fix model name update during quota check to avoid incorrect logging 2025-09-08 22:17:21 +08:00
Luis Pater
179b8aa88f Merge pull request #36 from luispater/ssh-tunnel
Add SSH tunnel guidance for login fallback
2025-09-08 09:16:52 +08:00
hkfires
040d66f0bb Add SSH tunnel guidance for login fallback 2025-09-08 09:01:15 +08:00
Luis Pater
c875088be2 Add dynamic log level adjustment and "type" field to auth files response
- Introduced `SetLogLevel` utility function for unified log level management.
- Updated dynamic log level handling across server and watcher components.
- Extended auth files response by extracting and including the `type` field from file content.
- Updated management API documentation with the new `type` field in auth files response.
2025-09-08 01:09:39 +08:00
Luis Pater
46fa32f087 Update log level in OpenURL function from Debug to Info 2025-09-07 21:28:10 +08:00
Luis Pater
551bc1a4a8 Enhance README formatting and update .dockerignore 2025-09-07 12:26:08 +08:00
Luis Pater
1305f2f6dc Merge pull request #33 from luispater/docker
Modify docker compose for remote image and local build
2025-09-07 12:11:15 +08:00
hkfires
2a2a276e3b Update README 2025-09-07 11:58:43 +08:00
hkfires
5aba4ca1b1 Refactor docker-compose config for simplicity and consistency 2025-09-07 11:35:54 +08:00
hkfires
47b5ebfc43 Modify docker compose for remote image and local build 2025-09-07 10:39:29 +08:00
hkfires
1bb0d11f62 Update README 2025-09-07 09:36:27 +08:00
Luis Pater
6164f5c35b Add JSON annotations to configuration structs and new /config management endpoint
- Added JSON annotations across all configuration structs in `config.go`.
- Introduced `/config` management API endpoint to fetch complete configuration.
- Updated management API documentation (`MANAGEMENT_API.md`, `MANAGEMENT_API_CN.md`) with `/config` usage.
- Implemented `GetConfig` handler in `config_basic.go`.
2025-09-06 20:45:51 +08:00
Luis Pater
c263398423 Add Telegram group link to Chinese README for user support 2025-09-06 15:56:28 +08:00
Luis Pater
ef922b29c2 Update workflows and build process for enhanced metadata injection
- Upgraded GitHub Actions (`actions/checkout` to v4, `actions/setup-go` to v4, `goreleaser-action` to v4).
- Added detailed build metadata (`VERSION`, `COMMIT`, `BUILD_DATE`) to workflows.
- Unified metadata injection into binaries and Docker images.
- Enhanced `.goreleaser.yml` with checksum, snapshot, and changelog configurations.
2025-09-06 15:37:48 +08:00
Luis Pater
d10ef7b58a Merge pull request #31 from luispater/docker-build-sh
Inject build metadata into binary during release and docker build
2025-09-06 15:28:58 +08:00
hkfires
e074e957d1 Update README 2025-09-06 10:24:48 +08:00
hkfires
7b546ea2ee build(goreleaser): inject build metadata into binary during release 2025-09-06 10:13:48 +08:00
hkfires
506e2e12a6 feat(server): inject build metadata into application logs and container image 2025-09-06 09:41:27 +08:00
Luis Pater
c52255e2a4 Merge branch 'dev' 2025-09-05 23:05:03 +08:00
Luis Pater
b05d00ede9 Add versioning support to build artifacts and log outputs
- Introduced `Version` variable, set during build via `-ldflags`, to embed application version.
- Updated Dockerfile to accept `APP_VERSION` argument for version injection during build.
- Modified `.goreleaser.yml` to pass GitHub release tag as version via `ldflags`.
- Added version logging in the application startup.
2025-09-05 22:57:22 +08:00
Luis Pater
8d05489973 Add versioning support to build artifacts and log outputs
- Introduced `Version` variable, set during build via `-ldflags`, to embed application version.
- Updated Dockerfile to accept `APP_VERSION` argument for version injection during build.
- Modified `.goreleaser.yml` to pass GitHub release tag as version via `ldflags`.
- Added version logging in the application startup.
2025-09-05 22:53:49 +08:00
Luis Pater
4f18809500 Merge pull request #29 from luispater/bugfix
Enhance client counting and logging
2025-09-05 21:48:30 +08:00
hkfires
28218ec550 feat(api): implement granular client type metrics in server updates 2025-09-05 19:26:57 +08:00
hkfires
f97954c811 fix(watcher): enhance API key client counting and logging 2025-09-05 18:02:45 +08:00
Luis Pater
798f65b35e Merge pull request #28 from luispater/bugfix
Optimize and fix bugs for hot reloading
2025-09-05 15:20:27 +08:00
hkfires
57484b97bb fix(watcher): improve client reload logic and prevent redundant updates
- replace debounce timing with content-based change detection using SHA256 hashes
- skip client reload when auth file content is unchanged
- handle empty auth files gracefully by ignoring them
- ensure hash cache is updated only on successful client creation
- clean up hash cache when clients are removed
2025-09-05 13:53:15 +08:00
hkfires
0e0602c553 refactor(watcher): restructure client management and API key handling
- separate file-based and API key-based clients in watcher
- improve client reloading logic with better locking and error handling
- add dedicated functions for building API key clients and loading file clients
- update combined client map generation to include cached API key clients
- enhance logging and debugging information during client reloads
- fix potential race conditions in client updates and removals
2025-09-05 13:25:30 +08:00
Luis Pater
54ffb52838 Add FunctionCallIndex to ConvertCliToOpenAIParams and enhance tool call handling
- Introduced `FunctionCallIndex` to track and manage function call indices within `ConvertCliToOpenAIParams`.
- Enhanced handling for `response.completed` and `response.output_item.done` data types to support tool call scenarios.
- Improved logic for restoring original tool names and setting function arguments during response parsing.
2025-09-05 09:02:24 +08:00
Luis Pater
c62e45ee88 Add Codex API key support and Gemini 2.5 Flash-Lite model documentation updates
- Documented Gemini 2.5 Flash-Lite model in English and Chinese README files.
- Updated README and example configuration to include Codex API key settings.
- Added examples for custom Codex API endpoint configuration.
2025-09-04 18:23:52 +08:00
Luis Pater
56a05d2cce Merge pull request #26 from luispater/flash-lite
Add Gemini 2.5 Flash-Lite Model
2025-09-04 16:11:43 +08:00
hkfires
3e09bc9470 Add Gemini 2.5 Flash-Lite Model 2025-09-04 11:59:48 +08:00
hkfires
5ed79e5aa3 Add debounce logic for file events to prevent duplicate reloads 2025-09-04 10:28:54 +08:00
hkfires
f38b78dbe6 Update the README to include Docker Compose usage instructions 2025-09-04 10:00:56 +08:00
Luis Pater
f1d6f01585 Add reasoning/thinking configuration handling for Claude and OpenAI translators
- Implemented `thinkingConfig` handling to allow reasoning effort configuration in request generation.
- Added support for reasoning content deltas (`thinking_delta`) in response processing.
- Enhanced reasoning-related token budget mappings for various reasoning levels.
- Improved response handling logic to ensure proper reasoning content inclusion.
2025-09-04 09:43:22 +08:00
hkfires
9b627a93ac Add Docker Compose 2025-09-04 09:23:35 +08:00
Luis Pater
d4709ffcf9 Replace path with filepath for cross-platform compatibility
- Updated imports and function calls to use `filepath` across all token storage implementations and server entry point.
- Ensured consistent handling of directory and file paths for improved portability.
2025-09-04 08:23:51 +08:00
Luis Pater
ad943b2d4d Add reverse mappings for original tool names and improve error logging
- Introduced reverse mapping logic for tool names in translators to restore original names when shortened.
- Enhanced error handling by logging API response errors consistently across handlers.
- Refactored request and response loggers to include API error details, improving debugging capabilities.
- Integrated robust tool name shortening and uniqueness mechanisms for OpenAI, Gemini, and Claude requests.
- Improved handler retry logic to properly capture and respond to errors.
2025-09-04 02:39:56 +08:00
Luis Pater
7209fa233f Refactor client map construction to include all client types and enhance callback updates
- Added `buildCombinedClientMap` to merge file-based clients with API key and compatibility clients.
- Updated callbacks to use the combined client map for consistency.
- Improved error logging and variable naming for clarity in client creation logic.
2025-09-03 22:26:07 +08:00
Luis Pater
7b9cfbc3f7 Merge pull request #23 from luispater/dev
Improve hot reloading and fix api response logging
2025-09-03 21:30:22 +08:00
Luis Pater
70e916942e Refactor cliCancel calls to remove unused resp argument across handlers. 2025-09-03 21:13:22 +08:00
hkfires
f60ef0b2e7 feat(watcher): implement incremental client hot-reloading 2025-09-03 20:47:43 +08:00
Luis Pater
6d2f7e3ce0 Enhance parseArgsToMap with tolerant JSON parsing
- Introduced `tolerantParseJSONMap` to handle bareword values in streamed tool calls.
- Added robust handling for JSON strings, objects, arrays, and numerical values.
- Improved fallback mechanisms to ensure reliable parsing.
2025-09-03 16:11:26 +08:00
Luis Pater
caf386c877 Update MANAGEMENT_API.md with expanded documentation for endpoints
- Refined API documentation structure and enhanced clarity for various endpoints, including `/debug`, `/proxy-url`, `/quota-exceeded`, and authentication management.
- Added comprehensive examples for request and response bodies across endpoints.
- Detailed object-array API key management for Codex, Gemini, Claude, and OpenAI compatibility providers.
- Enhanced descriptions for request retry logic and request logging endpoints.
- Improved authentication key handling descriptions and updated examples for YAML configuration impacts.
2025-09-03 09:07:43 +08:00
Luis Pater
c4a42eb1f0 Add support for Codex API key authentication
- Introduced functionality to handle Codex API keys, including initialization and management via new endpoints in the management API.
- Updated Codex client to support both OAuth and API key authentication.
- Documented Codex API key configuration in both English and Chinese README files.
- Enhanced logging to distinguish between API key and OAuth usage scenarios.
2025-09-03 03:36:56 +08:00
Luis Pater
b6f8677b01 Remove commented debug logging in ConvertOpenAIResponsesRequestToGeminiCLI 2025-09-03 03:03:07 +08:00
Luis Pater
36ee21ea8f Update README to include Codex support and multi-account load balancing details
- Added OpenAI Codex (GPT models) support in both English and Chinese README versions.
- Documented multi-account load balancing for Codex in the feature list.
2025-09-03 02:47:53 +08:00
Luis Pater
30d5d87ca6 Update README to include Codex support and multi-account load balancing details
- Added OpenAI Codex (GPT models) support in both English and Chinese README versions.
- Documented multi-account load balancing for Codex in the feature list.
2025-09-03 02:16:56 +08:00
Luis Pater
67e0b71c18 Add Codex load balancing documentation and refine JSON handling logic
- Updated README and README_CN to include a guide for configuring multiple account load balancing with CLI Proxy API.
- Enhanced JSON handling in gemini translators by differentiating object and string outputs.
- Added commented debug logging for Gemini CLI response conversion.
2025-09-03 01:33:26 +08:00
Luis Pater
b0f72736b0 Remove redundant dataUglyTag parsing logic in streaming responses
Eliminated duplicate blocks handling `dataUglyTag` in `openai-compatibility_client.go`, simplifying the streaming response logic.
2025-09-03 00:44:35 +08:00
Luis Pater
ae06f13e0e Extract argument parsing logic into parseArgsToMap helper function
Simplifies parsing and error handling for function arguments across OpenAI response processing methods. Replaces repeated logic with a reusable utility function.
2025-09-03 00:41:16 +08:00
Luis Pater
0652241519 Update README to rename gpt-5-nano to gpt-5-minimal in usage examples 2025-09-03 00:20:47 +08:00
Luis Pater
edf9d9b747 Merge branch 'main' of github.com:luispater/CLIProxyAPI 2025-09-03 00:16:04 +08:00
Luis Pater
3acdec51bd Add OpenAI Responses support 2025-09-03 00:15:35 +08:00
Luis Pater
ce5d2bad97 Add OpenAI Responses support 2025-09-03 00:09:23 +08:00
Luis Pater
34855bc647 **Fix model switch logic when quota is exceeded**
Ensure `modelName` is updated after switching to a new model, avoiding inconsistencies in subsequent iterations.
2025-09-01 21:37:03 +08:00
Luis Pater
56c8297f6b **Handle data: without trailing space in streaming responses**
Add support for API providers that emit `data:` (no space) in Server‑Sent Events. Introduces a new `dataUglyTag` and corresponding parsing logic to correctly process and forward these lines, ensuring compatibility with non‑standard streaming formats.

Fuck for them all
2025-09-01 17:38:24 +08:00
Luis Pater
e11637dc62 Refactor translator packages for OpenAI Chat Completions
- Renamed `openai` packages to `chat_completions` across translator modules.
- Introduced `openai_responses_handlers` with handlers for `/v1/models` and OpenAI-compatible chat completions endpoints.
- Updated constants and registry identifiers for OpenAI response type.
- Simplified request/response conversions and added detailed retry/error handling.
- Added `golang.org/x/crypto` for additional cryptographic functions.
2025-09-01 11:00:47 +08:00
Luis Pater
e0bff9f212 Refactor translator packages for OpenAI Chat Completions
- Renamed `openai` packages to `chat_completions` across translator modules.
- Introduced `openai_responses_handlers` with handlers for `/v1/models` and OpenAI-compatible chat completions endpoints.
- Updated constants and registry identifiers for OpenAI response type.
- Simplified request/response conversions and added detailed retry/error handling.
- Added `golang.org/x/crypto` for additional cryptographic functions.
2025-09-01 10:28:29 +08:00
Luis Pater
bff6f6679b Refactor translator packages for OpenAI Chat Completions
- Renamed `openai` packages to `chat_completions` across translator modules.
- Introduced `openai_responses_handlers` with handlers for `/v1/models` and OpenAI-compatible chat completions endpoints.
- Updated constants and registry identifiers for OpenAI response type.
- Simplified request/response conversions and added detailed retry/error handling.
- Added `golang.org/x/crypto` for additional cryptographic functions.
2025-09-01 10:20:50 +08:00
Luis Pater
305916f5a9 Refactor translator packages for OpenAI Chat Completions
- Renamed `openai` packages to `chat_completions` across translator modules.
- Introduced `openai_responses_handlers` with handlers for `/v1/models` and OpenAI-compatible chat completions endpoints.
- Updated constants and registry identifiers for OpenAI response type.
- Simplified request/response conversions and added detailed retry/error handling.
- Added `golang.org/x/crypto` for additional cryptographic functions.
2025-09-01 10:07:33 +08:00
Luis Pater
1f46dc2715 Refactor translator packages for OpenAI Chat Completions
- Renamed `openai` packages to `chat_completions` across translator modules.
- Introduced `openai_responses_handlers` with handlers for `/v1/models` and OpenAI-compatible chat completions endpoints.
- Updated constants and registry identifiers for OpenAI response type.
- Simplified request/response conversions and added detailed retry/error handling.
- Added `golang.org/x/crypto` for additional cryptographic functions.
2025-09-01 08:37:41 +08:00
Luis Pater
e3994ace33 Refactor translator packages for OpenAI Chat Completions
- Renamed `openai` packages to `chat_completions` across translator modules.
- Introduced `openai_responses_handlers` with handlers for `/v1/models` and OpenAI-compatible chat completions endpoints.
- Updated constants and registry identifiers for OpenAI response type.
- Simplified request/response conversions and added detailed retry/error handling.
- Added `golang.org/x/crypto` for additional cryptographic functions.
2025-09-01 08:18:59 +08:00
Luis Pater
bdac24bb4e Update PassthroughGeminiResponseStream to handle [DONE] marker
- Added logic to return an empty slice when the raw JSON equals the `[DONE]` marker.
- Ensures proper termination of streamed Gemini responses.
2025-09-01 02:00:55 +08:00
Luis Pater
6d30faf9c9 Update Management API CN docs for authentication requirements
- Changed authentication requirements to mandate management keys for all requests, including local access.
- Clarified remote access setup and key provision methods.
- Adjusted the section header.
2025-08-31 15:29:20 +08:00
Luis Pater
c0eaa41c7a Add Gemini-to-Gemini request normalization and passthrough support
- Introduced a `ConvertGeminiRequestToGemini` function to normalize Gemini v1beta requests by ensuring valid or default roles.
- Added passthrough response handlers for both streamed and non-streamed Gemini responses.
- Registered translators for Gemini-to-Gemini traffic in the initialization process.
- Updated `gemini-cli` request normalization to align with the new Gemini translator logic.
2025-08-31 15:05:16 +08:00
Luis Pater
8a2285e706 Reorganize and reintroduce Management API section in README files 2025-08-31 14:41:57 +08:00
Luis Pater
db43930b98 Add management API handlers for config and auth file management
- Implemented CRUD operations for authentication files.
- Added endpoints for managing API keys, quotas, proxy settings, and other configurations.
- Enhanced management access with robust validation, remote access control, and persistence support.
- Updated README with new configuration details.

Fixed OpenAI Chat Completions for codex
2025-08-31 14:29:23 +08:00
Luis Pater
b1254106ee Enhance client reload process with new OpenAI compatibility support
- Added handling for OpenAI-compatible providers during client reload.
- Implemented client unregistration for old clients during reload.
- Improved logging for detailed client reload insights.

Expand `AuthDir` handling to support tilde (`~`) for home directory resolution

- Added logic to replace `~` with the user's home directory in `AuthDir`.
- Prevents errors when using `~` in configuration paths.
2025-08-31 03:04:46 +08:00
Luis Pater
9c9ea99380 Add support for new GPT-5 model variants
- Renamed existing GPT-5 variants for consistency (`nano` → `minimal`, `mini` → `low`, etc.).
- Added metadata definitions for new variants: `gpt-5-minimal`, `gpt-5-low`, `gpt-5-medium`, and updated logic to reflect variant-specific reasoning efforts.
2025-08-30 22:00:37 +08:00
Luis Pater
ba4c11428c Merge pull request #16 from hkfires/main
Set the default Docker timezone to Asia/Shanghai
2025-08-30 19:05:32 +08:00
Luis Pater
0331660fe2 Add token refresh handling for 401 responses across clients
- Implemented `RefreshTokens` method in client interfaces and Gemini clients.
- Updated handlers to call `RefreshTokens` on 401 responses and retry requests if token refresh succeeds.
- Enhanced error handling and retry logic to accommodate token refresh flow.

Update README to include Qwen login instructions

- Added Qwen OAuth login command instructions in both English and Chinese README files.
- Made minor updates to existing command examples for consistency.
2025-08-30 17:26:41 +08:00
hkfires
3f7840188e Set the default Docker timezone to Asia/Shanghai 2025-08-30 16:41:27 +08:00
Luis Pater
512c8b600a Add token refresh handling for 401 responses across clients
- Implemented `RefreshTokens` method in client interfaces and Gemini clients.
- Updated handlers to call `RefreshTokens` on 401 responses and retry requests if token refresh succeeds.
- Enhanced error handling and retry logic to accommodate token refresh flow.
2025-08-30 16:10:56 +08:00
Luis Pater
1aad033fec Update issue templates 2025-08-30 04:18:17 +08:00
Luis Pater
f1d9364ef4 Update README documentation to clarify auth-dir configuration for Windows users
- Added a note for setting `auth-dir` on Windows systems in both English and Chinese README files.
- Improved descriptions for existing configuration options.

Address Qwen3 tool injection issue to prevent random token insertions

- Modify Qwen client to insert a placeholder tool when none is defined, avoiding erratic behavior in streaming responses.
2025-08-29 17:28:55 +08:00
Luis Pater
c2b2c9eafe Update README documentation to clarify auth-dir configuration for Windows users
- Added a note for setting `auth-dir` on Windows systems in both English and Chinese README files.
- Improved descriptions for existing configuration options.
2025-08-29 09:52:10 +08:00
Luis Pater
09b9d3b3fa Unlock mutex before returning error in handlers.go to prevent deadlocks 2025-08-29 09:46:12 +08:00
Luis Pater
e9e0016a63 Fix some bugs. 2025-08-29 04:05:08 +08:00
Luis Pater
3704dae342 Add nil-check for GetRequestMutex across handlers to prevent potential panics
- Updated all handlers to safely unlock the request mutex only if it's non-nil.
- Enhanced mutex locking and unlocking logic to avoid runtime errors.
- Improved robustness of resource cleanup across clients.

Add `GetRequestMutex` method for synchronization across clients

- Introduced a new `GetRequestMutex` method in OpenAICompatibilityClient, CodexClient, GeminiCLIClient, GeminiClient, and QwenClient for request synchronization.
- Ensures only one request is processed at a time to manage quotas effectively.
2025-08-29 00:23:37 +08:00
Luis Pater
bea5f97cbf Add /v1/completions endpoint with OpenAI compatibility
- Implemented `/v1/completions` endpoint mirroring OpenAI's completions API specification.
- Added conversion functions to translate between completions and chat completions formats.
- Introduced streaming and non-streaming response handling for completions requests.
- Updated `server.go` to register the new endpoint and include it in the API's metadata.
2025-08-28 00:30:46 +08:00
Luis Pater
7a6adfa97e Suppress debug logs for model routing and ignore empty tools arrays
- Comment out verbose routing logs in the API server to reduce noise.
- Remove the `tools` field from Qwen client requests when it is an empty array.
- Add guards in Claude, Codex, Gemini‑CLI, and Gemini translators to skip tool conversion when the `tools` array is empty, preventing unnecessary payload modifications.
2025-08-27 22:29:08 +08:00
Luis Pater
1c4183d943 Add support for localhost unauthenticated requests
- Introduced `AllowLocalhostUnauthenticated` flag allowing unauthenticated requests from localhost.
- Updated authentication middleware to bypass checks for localhost when enabled.

Add new Gemini CLI models and update model registry function

- Introduced `GetGeminiCLIModels` for updated Gemini CLI model definitions.
- Added new models: "Gemini 2.5 Flash Lite" and "Gemini 2.5 Pro".
- Updated `RegisterModels` to use `GetGeminiCLIModels` in Gemini client initialization.
2025-08-27 21:20:25 +08:00
Luis Pater
dff31a7a4c Improved the /v1/models endpoint 2025-08-27 21:01:37 +08:00
Luis Pater
ed8873fbb0 Add OpenAI compatibility support and improve resource cleanup
- Introduced OpenAI compatibility configurations for external providers, enabling model alias routing via the OpenAI API format.
- Enhanced provider logic in `GetProviderName` to handle OpenAI aliases and added new helper functions for compatibility checks.
- Updated API handlers and client initialization to support OpenAI compatibility models.
- Improved resource cleanup across clients by closing response bodies and streams using deferred functions.
2025-08-26 03:33:46 +08:00
Luis Pater
9102ff031d Refactor API handlers to implement retry mechanism with configurable limits and improved error handling
- Introduced retry counter with a configurable ` RequestRetry ` limit in all handlers.
- Enhanced error handling with specific HTTP status codes for switching clients.
- Standardized response forwarding for non-retriable errors.
- Improved logging for quota and client switch scenarios.
2025-08-25 23:43:49 +08:00
Luis Pater
8c555c4e69 Refactor codebase 2025-08-25 16:58:16 +08:00
Luis Pater
2b1762be16 Update README to reflect usage of Qwen models instead of Claude models 2025-08-22 00:34:09 +08:00
Luis Pater
aa2f37d54d Add Qwen support 2025-08-21 15:22:53 +08:00
Luis Pater
d58cc55cb2 Add claude code support 2025-08-21 02:53:28 +08:00
Luis Pater
c5cc238308 Refactor error handling and variable declarations in browser and logging modules
- Simplified variable initialization in `browser.go` for readability.
- Updated error handling in `request_logger.go` with better resource cleanup using deferred anonymous functions.

Refactor API handlers to use `GetContextWithCancel` for streamlined context creation and response handling

- Replaced redundant `context.WithCancel` and `context.WithValue` logic with the new `GetContextWithCancel` utility in all handlers.
- Centralized API response storage in the given context during cancellation.
- Updated associated cancellation calls for consistency and improved resource management.

- Replaced `apiResponseData` with `AddAPIResponseData` for centralized response recording.
- Simplified cancellation logic by switching to a boolean-based `cliCancel` method.
- Removed unused `apiResponseData` slices across handlers to reduce memory usage.
- Updated `handlers.go` to support unified response data storage per request context.
2025-08-17 20:13:45 +08:00
Luis Pater
6bbdf67f96 Refactor Gemini API handlers to standardize response field names and improve model descriptions 2025-08-17 00:28:13 +08:00
Luis Pater
fcadf08921 Add request logging capabilities to API handlers and update .gitignore
Enhance API response handling by storing responses in context and updating request logger to include API responses
2025-08-16 06:09:04 +08:00
Luis Pater
4155805ad6 Add support for Codex model in provider logic and update documentation for claude code 2025-08-16 02:02:44 +08:00
Luis Pater
de7b8501cc Add openai codex support 2025-08-16 01:22:33 +08:00
Luis Pater
d2394b0be9 Update .goreleaser.yml to specify archive formats for different OS targets
- Added `tar.gz` as the default archive format.
- Introduced `zip` format override for Windows builds.
2025-08-08 14:28:02 +08:00
Luis Pater
ebcd4dbf3d Fix activation URL extraction logic and improve warning message formatting
- Corrected JSON path for error code and activation URL extraction in client error handling.
- Improved readability of the activation warning message with better spacing.
2025-08-05 23:58:43 +08:00
Luis Pater
1483c31c73 Refactor API handlers organization and simplify error response handling
- Modularized handlers into dedicated packages (`gemini`, `claude`, `cli`) for better structure.
- Centralized `ErrorResponse` and `ErrorDetail` types under `handlers` package for reuse.
- Updated all handlers to utilize the shared `ErrorResponse` model.
- Introduced specialization of handler structs (`GeminiAPIHandlers`, `ClaudeCodeAPIHandlers`, `GeminiCLIAPIHandlers`) for improved clarity and separation of concerns.
- Refactored `getClient` logic with additional properties and better state management.

Refactor `translator` package by modularizing code for `claude` and `gemini`

- Moved Claude-specific logic (`PrepareClaudeRequest`, `ConvertCliToClaude`) to `translator/claude/code`.
- Moved Gemini-specific logic (`FixCLIToolResponse`) to `translator/gemini/cli` for better package structure.
- Updated affected handler imports and method references.

Add comprehensive package-level documentation across key modules

- Introduced detailed package-level documentation for core modules: `auth`, `client`, `cmd`, `handlers`, `util`, `watcher`, `config`, `translator`, and `api`.
- Enhanced code readability and maintainability by clarifying the purpose and functionality of each package.
- Aligned documentation style and tone with existing codebase conventions.

Refactor API handlers and translator modules for improved clarity and consistency

- Standardized handler struct names (`GeminiAPIHandlers`, `ClaudeCodeAPIHandlers`, `GeminiCLIAPIHandlers`, `OpenAIAPIHandlers`) and updated related comments.
- Fixed unnecessary `else` blocks in streaming logic for cleaner error handling.
- Renamed variables for better readability (`responseIdResult` to `responseIDResult`, `activationUrl` to `activationURL`, etc.).
- Addressed minor inconsistencies in API handler comments and SSE header initialization.
- Improved modularization of `claude` and `gemini` translator components.

Standardize configuration field naming for consistency across modules

- Renamed `ProxyUrl` to `ProxyURL`, `ApiKeys` to `APIKeys`, and `ConfigQuotaExceeded` to `QuotaExceeded`.
- Updated all relevant references and comments in `config`, `auth`, `api`, `util`, and `watcher`.
- Ensured consistent casing for `GlAPIKey` debug logs.
2025-08-05 23:11:31 +08:00
Luis Pater
00f33f5f3a Enhance Gemini request handling for contents support and improve error logging
- Added conditional logic to process `contents` in Gemini request templates, ensuring fallback behavior.
- Introduced detailed debug logs for quota errors and request issues.
- Updated handling of `rawJson` to construct templates more dynamically.
2025-08-04 02:51:00 +08:00
Luis Pater
3c4dc07980 Add file watcher for dynamic configuration and client reloading
- Introduced `Watcher` for monitoring updates to the configuration file and authentication directory.
- Integrated file watching into `StartService` to handle dynamic changes without restarting.
- Enhanced API server and handlers to support client and configuration updates.
- Updated `.gitignore` to include `docs/` directory.
- Modified go dependencies to include `fsnotify` for the file watcher.
2025-08-02 16:15:56 +08:00
Luis Pater
3b4634e2dc Improve getClient logic with optional content generation flag
- Added `isGenerateContent` optional parameter to `getClient` for conditional client selection.
- Updated `gemini-handlers` to utilize the new parameter for enhanced control.
2025-07-27 02:30:08 +08:00
Luis Pater
00bd6a3e46 Update .goreleaser.yml to include config.example.yaml instead of config.yaml in release assets 2025-07-26 22:19:33 +08:00
Luis Pater
5812229d9b Add .gitignore and ignore config.yaml 2025-07-26 22:10:07 +08:00
Luis Pater
0b026933a7 Update example configuration file (config.example.yaml) 2025-07-26 22:08:25 +08:00
Luis Pater
3b2ab0d7bd Fix SSE headers initialization for geminiStreamGenerateContent and internalStreamGenerateContent
- Added conditional logic to properly initialize SSE headers only when `alt` is empty.
- Ensured headers like `Content-Type`, `Cache-Control`, and `Access-Control-Allow-Origin` are set for better compatibility.
2025-07-26 17:16:55 +08:00
244 changed files with 41888 additions and 3620 deletions

33
.dockerignore Normal file
View File

@@ -0,0 +1,33 @@
# Git and GitHub folders
.git/*
.github/*
# Docker and CI/CD related files
docker-compose.yml
.dockerignore
.gitignore
.goreleaser.yml
Dockerfile
# Documentation and license
docs/*
README.md
README_CN.md
MANAGEMENT_API.md
MANAGEMENT_API_CN.md
LICENSE
# Example configuration
config.example.yaml
# Runtime data folders (should be mounted as volumes)
auths/*
logs/*
conv/*
config.yaml
# Development/editor
bin/*
.claude/*
.vscode/*
.serena/*

37
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,37 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**CLI Type**
What type of CLI account do you use? (gemini-cli, gemini, codex, claude code or openai-compatibility)
**Model Name**
What model are you using? (example: gemini-2.5-pro, claude-sonnet-4-20250514, gpt-5, etc.)
**LLM Client**
What LLM Client are you using? (example: roo-code, cline, claude code, etc.)
**Request Information**
The best way is to paste the cURL command of the HTTP request here.
Alternatively, you can set `request-log: true` in the `config.yaml` file and then upload the detailed log file.
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**OS Type**
- OS: [e.g. macOS]
- Version [e.g. 15.6.0]
**Additional context**
Add any other context about the problem here.

View File

@@ -24,8 +24,11 @@ jobs:
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Generate App Version
run: echo APP_VERSION=`git describe --tags --always` >> $GITHUB_ENV
- name: Generate Build Metadata
run: |
echo VERSION=`git describe --tags --always --dirty` >> $GITHUB_ENV
echo COMMIT=`git rev-parse --short HEAD` >> $GITHUB_ENV
echo BUILD_DATE=`date -u +%Y-%m-%dT%H:%M:%SZ` >> $GITHUB_ENV
- name: Build and push
uses: docker/build-push-action@v6
with:
@@ -35,8 +38,9 @@ jobs:
linux/arm64
push: true
build-args: |
APP_NAME=${{ env.APP_NAME }}
APP_VERSION=${{ env.APP_VERSION }}
VERSION=${{ env.VERSION }}
COMMIT=${{ env.COMMIT }}
BUILD_DATE=${{ env.BUILD_DATE }}
tags: |
${{ env.DOCKERHUB_REPO }}:latest
${{ env.DOCKERHUB_REPO }}:${{ env.APP_VERSION }}
${{ env.DOCKERHUB_REPO }}:${{ env.VERSION }}

View File

@@ -13,18 +13,26 @@ jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
with:
fetch-depth: 0
- run: git fetch --force --tags
- uses: actions/setup-go@v3
- uses: actions/setup-go@v4
with:
go-version: '>=1.24.0'
cache: true
- uses: goreleaser/goreleaser-action@v3
- name: Generate Build Metadata
run: |
echo VERSION=`git describe --tags --always --dirty` >> $GITHUB_ENV
echo COMMIT=`git rev-parse --short HEAD` >> $GITHUB_ENV
echo BUILD_DATE=`date -u +%Y-%m-%dT%H:%M:%SZ` >> $GITHUB_ENV
- uses: goreleaser/goreleaser-action@v4
with:
distribution: goreleaser
version: latest
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ env.VERSION }}
COMMIT: ${{ env.COMMIT }}
BUILD_DATE: ${{ env.BUILD_DATE }}

17
.gitignore vendored Normal file
View File

@@ -0,0 +1,17 @@
config.yaml
bin/*
docs/*
logs/*
conv/*
auths/*
!auths/.gitkeep
.vscode/*
.claude/*
.serena/*
AGENTS.md
CLAUDE.md
GEMINI.md
*.exe
temp/*
cli-proxy-api
static/*

View File

@@ -9,10 +9,29 @@ builds:
- arm64
main: ./cmd/server/
binary: cli-proxy-api
ldflags:
- -s -w -X 'main.Version={{.Version}}' -X 'main.Commit={{.ShortCommit}}' -X 'main.BuildDate={{.Date}}'
archives:
- id: "cli-proxy-api"
format: tar.gz
format_overrides:
- goos: windows
format: zip
files:
- LICENSE
- README.md
- README_CN.md
- config.yaml
- config.example.yaml
checksum:
name_template: 'checksums.txt'
snapshot:
name_template: "{{ incpatch .Version }}-next"
changelog:
sort: asc
filters:
exclude:
- '^docs:'
- '^test:'

View File

@@ -8,10 +8,16 @@ RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o ./CLIProxyAPI ./cmd/server/
ARG VERSION=dev
ARG COMMIT=none
ARG BUILD_DATE=unknown
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X 'main.Version=${VERSION}' -X 'main.Commit=${COMMIT}' -X 'main.BuildDate=${BUILD_DATE}'" -o ./CLIProxyAPI ./cmd/server/
FROM alpine:3.22.0
RUN apk add --no-cache tzdata
RUN mkdir /CLIProxyAPI
COPY --from=builder ./app/CLIProxyAPI /CLIProxyAPI/CLIProxyAPI
@@ -20,4 +26,8 @@ WORKDIR /CLIProxyAPI
EXPOSE 8317
ENV TZ=Asia/Shanghai
RUN cp /usr/share/zoneinfo/${TZ} /etc/localtime && echo "${TZ}" > /etc/timezone
CMD ["./CLIProxyAPI"]

691
MANAGEMENT_API.md Normal file
View File

@@ -0,0 +1,691 @@
# Management API
Base path: `http://localhost:8317/v0/management`
This API manages the CLI Proxy APIs runtime configuration and authentication files. All changes are persisted to the YAML config file and hotreloaded by the service.
Note: The following options cannot be modified via API and must be set in the config file (restart if needed):
- `allow-remote-management`
- `remote-management-key` (if plaintext is detected at startup, it is automatically bcrypthashed and written back to the config)
## Authentication
- All requests (including localhost) must provide a valid management key.
- Remote access requires enabling remote management in the config: `allow-remote-management: true`.
- Provide the management key (in plaintext) via either:
- `Authorization: Bearer <plaintext-key>`
- `X-Management-Key: <plaintext-key>`
Additional notes:
- If `remote-management.secret-key` is empty, the entire Management API is disabled (all `/v0/management` routes return 404).
- For remote IPs, 5 consecutive authentication failures trigger a temporary ban (~30 minutes) before further attempts are allowed.
If a plaintext key is detected in the config at startup, it will be bcrypthashed and written back to the config file automatically.
## Request/Response Conventions
- Content-Type: `application/json` (unless otherwise noted).
- Boolean/int/string updates: request body is `{ "value": <type> }`.
- Array PUT: either a raw array (e.g. `["a","b"]`) or `{ "items": [ ... ] }`.
- Array PATCH: supports `{ "old": "k1", "new": "k2" }` or `{ "index": 0, "value": "k2" }`.
- Object-array PATCH: supports matching by index or by key field (specified per endpoint).
## Endpoints
### Usage Statistics
- GET `/usage` — Retrieve aggregated in-memory request metrics
- Response:
```json
{
"usage": {
"total_requests": 24,
"success_count": 22,
"failure_count": 2,
"total_tokens": 13890,
"requests_by_day": {
"2024-05-20": 12
},
"requests_by_hour": {
"09": 4,
"18": 8
},
"tokens_by_day": {
"2024-05-20": 9876
},
"tokens_by_hour": {
"09": 1234,
"18": 865
},
"apis": {
"POST /v1/chat/completions": {
"total_requests": 12,
"total_tokens": 9021,
"models": {
"gpt-4o-mini": {
"total_requests": 8,
"total_tokens": 7123,
"details": [
{
"timestamp": "2024-05-20T09:15:04.123456Z",
"tokens": {
"input_tokens": 523,
"output_tokens": 308,
"reasoning_tokens": 0,
"cached_tokens": 0,
"total_tokens": 831
}
}
]
}
}
}
}
}
}
```
- Notes:
- Statistics are recalculated for every request that reports token usage; data resets when the server restarts.
- Hourly counters fold all days into the same hour bucket (`00``23`).
### Config
- GET `/config` — Get the full config
- Request:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/config
```
- Response:
```json
{"debug":true,"proxy-url":"","api-keys":["1...5","JS...W"],"quota-exceeded":{"switch-project":true,"switch-preview-model":true},"generative-language-api-key":["AI...01","AI...02","AI...03"],"request-log":true,"request-retry":3,"claude-api-key":[{"api-key":"cr...56","base-url":"https://example.com/api","proxy-url":"socks5://proxy.example.com:1080"},{"api-key":"cr...e3","base-url":"http://example.com:3000/api","proxy-url":""},{"api-key":"sk-...q2","base-url":"https://example.com","proxy-url":""}],"codex-api-key":[{"api-key":"sk...01","base-url":"https://example/v1","proxy-url":""}],"openai-compatibility":[{"name":"openrouter","base-url":"https://openrouter.ai/api/v1","api-key-entries":[{"api-key":"sk...01","proxy-url":""}],"models":[{"name":"moonshotai/kimi-k2:free","alias":"kimi-k2"}]},{"name":"iflow","base-url":"https://apis.iflow.cn/v1","api-key-entries":[{"api-key":"sk...7e","proxy-url":"socks5://proxy.example.com:1080"}],"models":[{"name":"deepseek-v3.1","alias":"deepseek-v3.1"},{"name":"glm-4.5","alias":"glm-4.5"},{"name":"kimi-k2","alias":"kimi-k2"}]}]}
```
### Debug
- GET `/debug` — Get the current debug state
- Request:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/debug
```
- Response:
```json
{ "debug": false }
```
- PUT/PATCH `/debug` — Set debug (boolean)
- Request:
```bash
curl -X PUT -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '{"value":true}' \
http://localhost:8317/v0/management/debug
```
- Response:
```json
{ "status": "ok" }
```
### Force GPT-5 Codex
- GET `/force-gpt-5-codex` — Get current flag
- Request:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/force-gpt-5-codex
```
- Response:
```json
{ "gpt-5-codex": false }
```
- PUT/PATCH `/force-gpt-5-codex` — Set boolean
- Request:
```bash
curl -X PUT -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '{"value":true}' \
http://localhost:8317/v0/management/force-gpt-5-codex
```
- Response:
```json
{ "status": "ok" }
```
### Proxy Server URL
- GET `/proxy-url` — Get the proxy URL string
- Request:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/proxy-url
```
- Response:
```json
{ "proxy-url": "socks5://user:pass@127.0.0.1:1080/" }
```
- PUT/PATCH `/proxy-url` — Set the proxy URL string
- Request (PUT):
```bash
curl -X PUT -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '{"value":"socks5://user:pass@127.0.0.1:1080/"}' \
http://localhost:8317/v0/management/proxy-url
```
- Request (PATCH):
```bash
curl -X PATCH -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '{"value":"http://127.0.0.1:8080"}' \
http://localhost:8317/v0/management/proxy-url
```
- Response:
```json
{ "status": "ok" }
```
- DELETE `/proxy-url` — Clear the proxy URL
- Request:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE http://localhost:8317/v0/management/proxy-url
```
- Response:
```json
{ "status": "ok" }
```
### Quota Exceeded Behavior
- GET `/quota-exceeded/switch-project`
- Request:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/quota-exceeded/switch-project
```
- Response:
```json
{ "switch-project": true }
```
- PUT/PATCH `/quota-exceeded/switch-project` — Boolean
- Request:
```bash
curl -X PUT -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '{"value":false}' \
http://localhost:8317/v0/management/quota-exceeded/switch-project
```
- Response:
```json
{ "status": "ok" }
```
- GET `/quota-exceeded/switch-preview-model`
- Request:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/quota-exceeded/switch-preview-model
```
- Response:
```json
{ "switch-preview-model": true }
```
- PUT/PATCH `/quota-exceeded/switch-preview-model` — Boolean
- Request:
```bash
curl -X PATCH -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '{"value":true}' \
http://localhost:8317/v0/management/quota-exceeded/switch-preview-model
```
- Response:
```json
{ "status": "ok" }
```
### API Keys (proxy service auth)
These endpoints update the inline `config-api-key` provider inside the `auth.providers` section of the configuration. Legacy top-level `api-keys` remain in sync automatically.
- GET `/api-keys` — Return the full list
- Request:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/api-keys
```
- Response:
```json
{ "api-keys": ["k1","k2","k3"] }
```
- PUT `/api-keys` — Replace the full list
- Request:
```bash
curl -X PUT -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '["k1","k2","k3"]' \
http://localhost:8317/v0/management/api-keys
```
- Response:
```json
{ "status": "ok" }
```
- PATCH `/api-keys` — Modify one item (`old/new` or `index/value`)
- Request (by old/new):
```bash
curl -X PATCH -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '{"old":"k2","new":"k2b"}' \
http://localhost:8317/v0/management/api-keys
```
- Request (by index/value):
```bash
curl -X PATCH -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '{"index":0,"value":"k1b"}' \
http://localhost:8317/v0/management/api-keys
```
- Response:
```json
{ "status": "ok" }
```
- DELETE `/api-keys` — Delete one (`?value=` or `?index=`)
- Request (by value):
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/api-keys?value=k1'
```
- Request (by index):
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/api-keys?index=0'
```
- Response:
```json
{ "status": "ok" }
```
### Gemini API Key (Generative Language)
- GET `/generative-language-api-key`
- Request:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/generative-language-api-key
```
- Response:
```json
{ "generative-language-api-key": ["AIzaSy...01","AIzaSy...02"] }
```
- PUT `/generative-language-api-key`
- Request:
```bash
curl -X PUT -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '["AIzaSy-1","AIzaSy-2"]' \
http://localhost:8317/v0/management/generative-language-api-key
```
- Response:
```json
{ "status": "ok" }
```
- PATCH `/generative-language-api-key`
- Request:
```bash
curl -X PATCH -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '{"old":"AIzaSy-1","new":"AIzaSy-1b"}' \
http://localhost:8317/v0/management/generative-language-api-key
```
- Response:
```json
{ "status": "ok" }
```
- DELETE `/generative-language-api-key`
- Request:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/generative-language-api-key?value=AIzaSy-2'
```
- Response:
```json
{ "status": "ok" }
```
### Codex API KEY (object array)
- GET `/codex-api-key` — List all
- Request:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/codex-api-key
```
- Response:
```json
{ "codex-api-key": [ { "api-key": "sk-a", "base-url": "", "proxy-url": "" } ] }
```
- PUT `/codex-api-key` — Replace the list
- Request:
```bash
curl -X PUT -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '[{"api-key":"sk-a","proxy-url":"socks5://proxy.example.com:1080"},{"api-key":"sk-b","base-url":"https://c.example.com","proxy-url":""}]' \
http://localhost:8317/v0/management/codex-api-key
```
- Response:
```json
{ "status": "ok" }
```
- PATCH `/codex-api-key` — Modify one (by `index` or `match`)
- Request (by index):
```bash
curl -X PATCH -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '{"index":1,"value":{"api-key":"sk-b2","base-url":"https://c.example.com","proxy-url":""}}' \
http://localhost:8317/v0/management/codex-api-key
```
- Request (by match):
```bash
curl -X PATCH -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '{"match":"sk-a","value":{"api-key":"sk-a","base-url":"","proxy-url":"socks5://proxy.example.com:1080"}}' \
http://localhost:8317/v0/management/codex-api-key
```
- Response:
```json
{ "status": "ok" }
```
- DELETE `/codex-api-key` — Delete one (`?api-key=` or `?index=`)
- Request (by api-key):
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/codex-api-key?api-key=sk-b2'
```
- Request (by index):
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/codex-api-key?index=0'
```
- Response:
```json
{ "status": "ok" }
```
### Request Retry Count
- GET `/request-retry` — Get integer
- Request:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/request-retry
```
- Response:
```json
{ "request-retry": 3 }
```
- PUT/PATCH `/request-retry` — Set integer
- Request:
```bash
curl -X PATCH -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '{"value":5}' \
http://localhost:8317/v0/management/request-retry
```
- Response:
```json
{ "status": "ok" }
```
### Request Log
- GET `/request-log` — Get boolean
- Request:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/request-log
```
- Response:
```json
{ "request-log": false }
```
- PUT/PATCH `/request-log` — Set boolean
- Request:
```bash
curl -X PATCH -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '{"value":true}' \
http://localhost:8317/v0/management/request-log
```
- Response:
```json
{ "status": "ok" }
```
### Claude API KEY (object array)
- GET `/claude-api-key` — List all
- Request:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/claude-api-key
```
- Response:
```json
{ "claude-api-key": [ { "api-key": "sk-a", "base-url": "", "proxy-url": "" } ] }
```
- PUT `/claude-api-key` — Replace the list
- Request:
```bash
curl -X PUT -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '[{"api-key":"sk-a","proxy-url":"socks5://proxy.example.com:1080"},{"api-key":"sk-b","base-url":"https://c.example.com","proxy-url":""}]' \
http://localhost:8317/v0/management/claude-api-key
```
- Response:
```json
{ "status": "ok" }
```
- PATCH `/claude-api-key` — Modify one (by `index` or `match`)
- Request (by index):
```bash
curl -X PATCH -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '{"index":1,"value":{"api-key":"sk-b2","base-url":"https://c.example.com","proxy-url":""}}' \
http://localhost:8317/v0/management/claude-api-key
```
- Request (by match):
```bash
curl -X PATCH -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '{"match":"sk-a","value":{"api-key":"sk-a","base-url":"","proxy-url":"socks5://proxy.example.com:1080"}}' \
http://localhost:8317/v0/management/claude-api-key
```
- Response:
```json
{ "status": "ok" }
```
- DELETE `/claude-api-key` — Delete one (`?api-key=` or `?index=`)
- Request (by api-key):
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/claude-api-key?api-key=sk-b2'
```
- Request (by index):
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/claude-api-key?index=0'
```
- Response:
```json
{ "status": "ok" }
```
### OpenAI Compatibility Providers (object array)
- GET `/openai-compatibility` — List all
- Request:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/openai-compatibility
```
- Response:
```json
{ "openai-compatibility": [ { "name": "openrouter", "base-url": "https://openrouter.ai/api/v1", "api-key-entries": [ { "api-key": "sk", "proxy-url": "" } ], "models": [] } ] }
```
- PUT `/openai-compatibility` — Replace the list
- Request:
```bash
curl -X PUT -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '[{"name":"openrouter","base-url":"https://openrouter.ai/api/v1","api-key-entries":[{"api-key":"sk","proxy-url":""}],"models":[{"name":"m","alias":"a"}]}]' \
http://localhost:8317/v0/management/openai-compatibility
```
- Response:
```json
{ "status": "ok" }
```
- PATCH `/openai-compatibility` — Modify one (by `index` or `name`)
- Request (by name):
```bash
curl -X PATCH -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '{"name":"openrouter","value":{"name":"openrouter","base-url":"https://openrouter.ai/api/v1","api-key-entries":[{"api-key":"sk","proxy-url":""}],"models":[]}}' \
http://localhost:8317/v0/management/openai-compatibility
```
- Request (by index):
```bash
curl -X PATCH -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '{"index":0,"value":{"name":"openrouter","base-url":"https://openrouter.ai/api/v1","api-key-entries":[{"api-key":"sk","proxy-url":""}],"models":[]}}' \
http://localhost:8317/v0/management/openai-compatibility
```
- Response:
```json
{ "status": "ok" }
```
- Notes:
- Legacy `api-keys` input remains accepted; keys are migrated into `api-key-entries` automatically so the legacy field will eventually remain empty in responses.
- DELETE `/openai-compatibility` — Delete (`?name=` or `?index=`)
- Request (by name):
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/openai-compatibility?name=openrouter'
```
- Request (by index):
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/openai-compatibility?index=0'
```
- Response:
```json
{ "status": "ok" }
```
### Auth File Management
Manage JSON token files under `auth-dir`: list, download, upload, delete.
- GET `/auth-files` — List
- Request:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/auth-files
```
- Response:
```json
{ "files": [ { "name": "acc1.json", "size": 1234, "modtime": "2025-08-30T12:34:56Z", "type": "google" } ] }
```
- GET `/auth-files/download?name=<file.json>` — Download a single file
- Request:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -OJ 'http://localhost:8317/v0/management/auth-files/download?name=acc1.json'
```
- POST `/auth-files` — Upload
- Request (multipart):
```bash
curl -X POST -F 'file=@/path/to/acc1.json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
http://localhost:8317/v0/management/auth-files
```
- Request (raw JSON):
```bash
curl -X POST -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d @/path/to/acc1.json \
'http://localhost:8317/v0/management/auth-files?name=acc1.json'
```
- Response:
```json
{ "status": "ok" }
```
- DELETE `/auth-files?name=<file.json>` — Delete a single file
- Request:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/auth-files?name=acc1.json'
```
- Response:
```json
{ "status": "ok" }
```
- DELETE `/auth-files?all=true` — Delete all `.json` files under `auth-dir`
- Request:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/auth-files?all=true'
```
- Response:
```json
{ "status": "ok", "deleted": 3 }
```
### Login/OAuth URLs
These endpoints initiate provider login flows and return a URL to open in a browser. Tokens are saved under `auths/` once the flow completes.
- GET `/anthropic-auth-url` — Start Anthropic (Claude) login
- Request:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
http://localhost:8317/v0/management/anthropic-auth-url
```
- Response:
```json
{ "status": "ok", "url": "https://..." }
```
- GET `/codex-auth-url` — Start Codex login
- Request:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
http://localhost:8317/v0/management/codex-auth-url
```
- Response:
```json
{ "status": "ok", "url": "https://..." }
```
- GET `/gemini-cli-auth-url` — Start Google (Gemini CLI) login
- Query params:
- `project_id` (optional): Google Cloud project ID.
- Request:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
'http://localhost:8317/v0/management/gemini-cli-auth-url?project_id=<PROJECT_ID>'
```
- Response:
```json
{ "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
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
http://localhost:8317/v0/management/qwen-auth-url
```
- Response:
```json
{ "status": "ok", "url": "https://..." }
```
- GET `/get-auth-status?state=<state>` — Poll OAuth flow status
- Request:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
'http://localhost:8317/v0/management/get-auth-status?state=<STATE_FROM_AUTH_URL>'
```
- Response examples:
```json
{ "status": "wait" }
{ "status": "ok" }
{ "status": "error", "error": "Authentication failed" }
```
## Error Responses
Generic error format:
- 400 Bad Request: `{ "error": "invalid body" }`
- 401 Unauthorized: `{ "error": "missing management key" }` or `{ "error": "invalid management key" }`
- 403 Forbidden: `{ "error": "remote management disabled" }`
- 404 Not Found: `{ "error": "item not found" }` or `{ "error": "file not found" }`
- 500 Internal Server Error: `{ "error": "failed to save config: ..." }`
## Notes
- Changes are written back to the YAML config file and hotreloaded by the file watcher and clients.
- `allow-remote-management` and `remote-management-key` cannot be changed via the API; configure them in the config file.

691
MANAGEMENT_API_CN.md Normal file
View File

@@ -0,0 +1,691 @@
# 管理 API
基础路径:`http://localhost:8317/v0/management`
该 API 用于管理 CLI Proxy API 的运行时配置与认证文件。所有变更会持久化写入 YAML 配置文件,并由服务自动热重载。
注意:以下选项不能通过 API 修改,需在配置文件中设置(如有必要可重启):
- `allow-remote-management`
- `remote-management-key`(若在启动时检测到明文,会自动进行 bcrypt 加密并写回配置)
## 认证
- 所有请求(包括本地访问)都必须提供有效的管理密钥.
- 远程访问需要在配置文件中开启远程访问: `allow-remote-management: true`
- 通过以下任意方式提供管理密钥(明文):
- `Authorization: Bearer <plaintext-key>`
- `X-Management-Key: <plaintext-key>`
若在启动时检测到配置中的管理密钥为明文,会自动使用 bcrypt 加密并回写到配置文件中。
其它说明:
-`remote-management.secret-key` 为空,则管理 API 整体被禁用(所有 `/v0/management` 路由均返回 404
- 对于远程 IP连续 5 次认证失败会触发临时封禁(约 30 分钟)。
## 请求/响应约定
- Content-Type`application/json`(除非另有说明)。
- 布尔/整数/字符串更新:请求体为 `{ "value": <type> }`
- 数组 PUT既可使用原始数组`["a","b"]`),也可使用 `{ "items": [ ... ] }`
- 数组 PATCH支持 `{ "old": "k1", "new": "k2" }``{ "index": 0, "value": "k2" }`
- 对象数组 PATCH支持按索引或按关键字段匹配各端点中单独说明
## 端点说明
### Usage请求统计
- GET `/usage` — 获取内存中的请求统计
- 响应:
```json
{
"usage": {
"total_requests": 24,
"success_count": 22,
"failure_count": 2,
"total_tokens": 13890,
"requests_by_day": {
"2024-05-20": 12
},
"requests_by_hour": {
"09": 4,
"18": 8
},
"tokens_by_day": {
"2024-05-20": 9876
},
"tokens_by_hour": {
"09": 1234,
"18": 865
},
"apis": {
"POST /v1/chat/completions": {
"total_requests": 12,
"total_tokens": 9021,
"models": {
"gpt-4o-mini": {
"total_requests": 8,
"total_tokens": 7123,
"details": [
{
"timestamp": "2024-05-20T09:15:04.123456Z",
"tokens": {
"input_tokens": 523,
"output_tokens": 308,
"reasoning_tokens": 0,
"cached_tokens": 0,
"total_tokens": 831
}
}
]
}
}
}
}
}
}
```
- 说明:
- 仅统计带有 token 使用信息的请求,服务重启后数据会被清空。
- 小时维度会将所有日期折叠到 `00``23` 的统一小时桶中。
### Config
- GET `/config` — 获取完整的配置
- 请求:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/config
```
- 响应:
```json
{"debug":true,"proxy-url":"","api-keys":["1...5","JS...W"],"quota-exceeded":{"switch-project":true,"switch-preview-model":true},"generative-language-api-key":["AI...01","AI...02","AI...03"],"request-log":true,"request-retry":3,"claude-api-key":[{"api-key":"cr...56","base-url":"https://example.com/api","proxy-url":"socks5://proxy.example.com:1080"},{"api-key":"cr...e3","base-url":"http://example.com:3000/api","proxy-url":""},{"api-key":"sk-...q2","base-url":"https://example.com","proxy-url":""}],"codex-api-key":[{"api-key":"sk...01","base-url":"https://example/v1","proxy-url":""}],"openai-compatibility":[{"name":"openrouter","base-url":"https://openrouter.ai/api/v1","api-key-entries":[{"api-key":"sk...01","proxy-url":""}],"models":[{"name":"moonshotai/kimi-k2:free","alias":"kimi-k2"}]},{"name":"iflow","base-url":"https://apis.iflow.cn/v1","api-key-entries":[{"api-key":"sk...7e","proxy-url":"socks5://proxy.example.com:1080"}],"models":[{"name":"deepseek-v3.1","alias":"deepseek-v3.1"},{"name":"glm-4.5","alias":"glm-4.5"},{"name":"kimi-k2","alias":"kimi-k2"}]}]}
```
### Debug
- GET `/debug` — 获取当前 debug 状态
- 请求:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/debug
```
- 响应:
```json
{ "debug": false }
```
- PUT/PATCH `/debug` — 设置 debug布尔值
- 请求:
```bash
curl -X PUT -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '{"value":true}' \
http://localhost:8317/v0/management/debug
```
- 响应:
```json
{ "status": "ok" }
```
### 强制 GPT-5 Codex
- GET `/force-gpt-5-codex` — 获取当前标志
- 请求:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/force-gpt-5-codex
```
- 响应:
```json
{ "gpt-5-codex": false }
```
- PUT/PATCH `/force-gpt-5-codex` — 设置布尔值
- 请求:
```bash
curl -X PUT -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '{"value":true}' \
http://localhost:8317/v0/management/force-gpt-5-codex
```
- 响应:
```json
{ "status": "ok" }
```
### 代理服务器 URL
- GET `/proxy-url` — 获取代理 URL 字符串
- 请求:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/proxy-url
```
- 响应:
```json
{ "proxy-url": "socks5://user:pass@127.0.0.1:1080/" }
```
- PUT/PATCH `/proxy-url` — 设置代理 URL 字符串
- 请求PUT
```bash
curl -X PUT -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '{"value":"socks5://user:pass@127.0.0.1:1080/"}' \
http://localhost:8317/v0/management/proxy-url
```
- 请求PATCH
```bash
curl -X PATCH -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '{"value":"http://127.0.0.1:8080"}' \
http://localhost:8317/v0/management/proxy-url
```
- 响应:
```json
{ "status": "ok" }
```
- DELETE `/proxy-url` — 清空代理 URL
- 请求:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE http://localhost:8317/v0/management/proxy-url
```
- 响应:
```json
{ "status": "ok" }
```
### 超出配额行为
- GET `/quota-exceeded/switch-project`
- 请求:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/quota-exceeded/switch-project
```
- 响应:
```json
{ "switch-project": true }
```
- PUT/PATCH `/quota-exceeded/switch-project` — 布尔值
- 请求:
```bash
curl -X PUT -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '{"value":false}' \
http://localhost:8317/v0/management/quota-exceeded/switch-project
```
- 响应:
```json
{ "status": "ok" }
```
- GET `/quota-exceeded/switch-preview-model`
- 请求:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/quota-exceeded/switch-preview-model
```
- 响应:
```json
{ "switch-preview-model": true }
```
- PUT/PATCH `/quota-exceeded/switch-preview-model` — 布尔值
- 请求:
```bash
curl -X PATCH -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '{"value":true}' \
http://localhost:8317/v0/management/quota-exceeded/switch-preview-model
```
- 响应:
```json
{ "status": "ok" }
```
### API Keys代理服务认证
这些接口会更新配置中 `auth.providers` 内置的 `config-api-key` 提供方,旧版顶层 `api-keys` 会自动保持同步。
- GET `/api-keys` — 返回完整列表
- 请求:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/api-keys
```
- 响应:
```json
{ "api-keys": ["k1","k2","k3"] }
```
- PUT `/api-keys` — 完整改写列表
- 请求:
```bash
curl -X PUT -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '["k1","k2","k3"]' \
http://localhost:8317/v0/management/api-keys
```
- 响应:
```json
{ "status": "ok" }
```
- PATCH `/api-keys` — 修改其中一个(`old/new` 或 `index/value`
- 请求(按 old/new
```bash
curl -X PATCH -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '{"old":"k2","new":"k2b"}' \
http://localhost:8317/v0/management/api-keys
```
- 请求(按 index/value
```bash
curl -X PATCH -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '{"index":0,"value":"k1b"}' \
http://localhost:8317/v0/management/api-keys
```
- 响应:
```json
{ "status": "ok" }
```
- DELETE `/api-keys` — 删除其中一个(`?value=` 或 `?index=`
- 请求(按值删除):
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/api-keys?value=k1'
```
- 请求(按索引删除):
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/api-keys?index=0'
```
- 响应:
```json
{ "status": "ok" }
```
### Gemini API Key生成式语言
- GET `/generative-language-api-key`
- 请求:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/generative-language-api-key
```
- 响应:
```json
{ "generative-language-api-key": ["AIzaSy...01","AIzaSy...02"] }
```
- PUT `/generative-language-api-key`
- 请求:
```bash
curl -X PUT -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '["AIzaSy-1","AIzaSy-2"]' \
http://localhost:8317/v0/management/generative-language-api-key
```
- 响应:
```json
{ "status": "ok" }
```
- PATCH `/generative-language-api-key`
- 请求:
```bash
curl -X PATCH -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '{"old":"AIzaSy-1","new":"AIzaSy-1b"}' \
http://localhost:8317/v0/management/generative-language-api-key
```
- 响应:
```json
{ "status": "ok" }
```
- DELETE `/generative-language-api-key`
- 请求:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/generative-language-api-key?value=AIzaSy-2'
```
- 响应:
```json
{ "status": "ok" }
```
### Codex API KEY对象数组
- GET `/codex-api-key` — 列出全部
- 请求:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/codex-api-key
```
- 响应:
```json
{ "codex-api-key": [ { "api-key": "sk-a", "base-url": "", "proxy-url": "" } ] }
```
- PUT `/codex-api-key` — 完整改写列表
- 请求:
```bash
curl -X PUT -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '[{"api-key":"sk-a","proxy-url":"socks5://proxy.example.com:1080"},{"api-key":"sk-b","base-url":"https://c.example.com","proxy-url":""}]' \
http://localhost:8317/v0/management/codex-api-key
```
- 响应:
```json
{ "status": "ok" }
```
- PATCH `/codex-api-key` — 修改其中一个(按 `index` 或 `match`
- 请求(按索引):
```bash
curl -X PATCH -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '{"index":1,"value":{"api-key":"sk-b2","base-url":"https://c.example.com","proxy-url":""}}' \
http://localhost:8317/v0/management/codex-api-key
```
- 请求(按匹配):
```bash
curl -X PATCH -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '{"match":"sk-a","value":{"api-key":"sk-a","base-url":"","proxy-url":"socks5://proxy.example.com:1080"}}' \
http://localhost:8317/v0/management/codex-api-key
```
- 响应:
```json
{ "status": "ok" }
```
- DELETE `/codex-api-key` — 删除其中一个(`?api-key=` 或 `?index=`
- 请求(按 api-key
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/codex-api-key?api-key=sk-b2'
```
- 请求(按索引):
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/codex-api-key?index=0'
```
- 响应:
```json
{ "status": "ok" }
```
### 请求重试次数
- GET `/request-retry` — 获取整数
- 请求:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/request-retry
```
- 响应:
```json
{ "request-retry": 3 }
```
- PUT/PATCH `/request-retry` — 设置整数
- 请求:
```bash
curl -X PATCH -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '{"value":5}' \
http://localhost:8317/v0/management/request-retry
```
- 响应:
```json
{ "status": "ok" }
```
### 请求日志开关
- GET `/request-log` — 获取布尔值
- 请求:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/request-log
```
- 响应:
```json
{ "request-log": false }
```
- PUT/PATCH `/request-log` — 设置布尔值
- 请求:
```bash
curl -X PATCH -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '{"value":true}' \
http://localhost:8317/v0/management/request-log
```
- 响应:
```json
{ "status": "ok" }
```
### Claude API KEY对象数组
- GET `/claude-api-key` — 列出全部
- 请求:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/claude-api-key
```
- 响应:
```json
{ "claude-api-key": [ { "api-key": "sk-a", "base-url": "", "proxy-url": "" } ] }
```
- PUT `/claude-api-key` — 完整改写列表
- 请求:
```bash
curl -X PUT -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '[{"api-key":"sk-a","proxy-url":"socks5://proxy.example.com:1080"},{"api-key":"sk-b","base-url":"https://c.example.com","proxy-url":""}]' \
http://localhost:8317/v0/management/claude-api-key
```
- 响应:
```json
{ "status": "ok" }
```
- PATCH `/claude-api-key` — 修改其中一个(按 `index` 或 `match`
- 请求(按索引):
```bash
curl -X PATCH -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '{"index":1,"value":{"api-key":"sk-b2","base-url":"https://c.example.com","proxy-url":""}}' \
http://localhost:8317/v0/management/claude-api-key
```
- 请求(按匹配):
```bash
curl -X PATCH -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '{"match":"sk-a","value":{"api-key":"sk-a","base-url":"","proxy-url":"socks5://proxy.example.com:1080"}}' \
http://localhost:8317/v0/management/claude-api-key
```
- 响应:
```json
{ "status": "ok" }
```
- DELETE `/claude-api-key` — 删除其中一个(`?api-key=` 或 `?index=`
- 请求(按 api-key
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/claude-api-key?api-key=sk-b2'
```
- 请求(按索引):
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/claude-api-key?index=0'
```
- 响应:
```json
{ "status": "ok" }
```
### OpenAI 兼容提供商(对象数组)
- GET `/openai-compatibility` — 列出全部
- 请求:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/openai-compatibility
```
- 响应:
```json
{ "openai-compatibility": [ { "name": "openrouter", "base-url": "https://openrouter.ai/api/v1", "api-key-entries": [ { "api-key": "sk", "proxy-url": "" } ], "models": [] } ] }
```
- PUT `/openai-compatibility` — 完整改写列表
- 请求:
```bash
curl -X PUT -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '[{"name":"openrouter","base-url":"https://openrouter.ai/api/v1","api-key-entries":[{"api-key":"sk","proxy-url":""}],"models":[{"name":"m","alias":"a"}]}]' \
http://localhost:8317/v0/management/openai-compatibility
```
- 响应:
```json
{ "status": "ok" }
```
- PATCH `/openai-compatibility` — 修改其中一个(按 `index` 或 `name`
- 请求(按名称):
```bash
curl -X PATCH -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '{"name":"openrouter","value":{"name":"openrouter","base-url":"https://openrouter.ai/api/v1","api-key-entries":[{"api-key":"sk","proxy-url":""}],"models":[]}}' \
http://localhost:8317/v0/management/openai-compatibility
```
- 请求(按索引):
```bash
curl -X PATCH -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d '{"index":0,"value":{"name":"openrouter","base-url":"https://openrouter.ai/api/v1","api-key-entries":[{"api-key":"sk","proxy-url":""}],"models":[]}}' \
http://localhost:8317/v0/management/openai-compatibility
```
- 响应:
```json
{ "status": "ok" }
```
- 说明:
- 仍可提交遗留的 `api-keys` 字段,但所有密钥会自动迁移到 `api-key-entries` 中,返回结果中的 `api-keys` 会逐步留空。
- DELETE `/openai-compatibility` — 删除(`?name=` 或 `?index=`
- 请求(按名称):
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/openai-compatibility?name=openrouter'
```
- 请求(按索引):
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/openai-compatibility?index=0'
```
- 响应:
```json
{ "status": "ok" }
```
### 认证文件管理
管理 `auth-dir` 下的 JSON 令牌文件:列出、下载、上传、删除。
- GET `/auth-files` — 列表
- 请求:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' http://localhost:8317/v0/management/auth-files
```
- 响应:
```json
{ "files": [ { "name": "acc1.json", "size": 1234, "modtime": "2025-08-30T12:34:56Z", "type": "google" } ] }
```
- GET `/auth-files/download?name=<file.json>` — 下载单个文件
- 请求:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -OJ 'http://localhost:8317/v0/management/auth-files/download?name=acc1.json'
```
- POST `/auth-files` — 上传
- 请求multipart
```bash
curl -X POST -F 'file=@/path/to/acc1.json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
http://localhost:8317/v0/management/auth-files
```
- 请求(原始 JSON
```bash
curl -X POST -H 'Content-Type: application/json' \
-H 'Authorization: Bearer <MANAGEMENT_KEY>' \
-d @/path/to/acc1.json \
'http://localhost:8317/v0/management/auth-files?name=acc1.json'
```
- 响应:
```json
{ "status": "ok" }
```
- DELETE `/auth-files?name=<file.json>` — 删除单个文件
- 请求:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/auth-files?name=acc1.json'
```
- 响应:
```json
{ "status": "ok" }
```
- DELETE `/auth-files?all=true` — 删除 `auth-dir` 下所有 `.json` 文件
- 请求:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' -X DELETE 'http://localhost:8317/v0/management/auth-files?all=true'
```
- 响应:
```json
{ "status": "ok", "deleted": 3 }
```
### 登录/授权 URL
以下端点用于发起各提供商的登录流程,并返回需要在浏览器中打开的 URL。流程完成后令牌会保存到 `auths/` 目录。
- GET `/anthropic-auth-url` — 开始 AnthropicClaude登录
- 请求:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
http://localhost:8317/v0/management/anthropic-auth-url
```
- 响应:
```json
{ "status": "ok", "url": "https://..." }
```
- GET `/codex-auth-url` — 开始 Codex 登录
- 请求:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
http://localhost:8317/v0/management/codex-auth-url
```
- 响应:
```json
{ "status": "ok", "url": "https://..." }
```
- GET `/gemini-cli-auth-url` — 开始 GoogleGemini CLI登录
- 查询参数:
- `project_id`可选Google Cloud 项目 ID。
- 请求:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
'http://localhost:8317/v0/management/gemini-cli-auth-url?project_id=<PROJECT_ID>'
```
- 响应:
```json
{ "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
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
http://localhost:8317/v0/management/qwen-auth-url
```
- 响应:
```json
{ "status": "ok", "url": "https://..." }
```
- GET `/get-auth-status?state=<state>` — 轮询 OAuth 流程状态
- 请求:
```bash
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
'http://localhost:8317/v0/management/get-auth-status?state=<STATE_FROM_AUTH_URL>'
```
- 响应示例:
```json
{ "status": "wait" }
{ "status": "ok" }
{ "status": "error", "error": "Authentication failed" }
```
## 错误响应
通用错误格式:
- 400 Bad Request: `{ "error": "invalid body" }`
- 401 Unauthorized: `{ "error": "missing management key" }` 或 `{ "error": "invalid management key" }`
- 403 Forbidden: `{ "error": "remote management disabled" }`
- 404 Not Found: `{ "error": "item not found" }` 或 `{ "error": "file not found" }`
- 500 Internal Server Error: `{ "error": "failed to save config: ..." }`
## 说明
- 变更会写回 YAML 配置文件,并由文件监控器热重载配置与客户端。
- `allow-remote-management` 与 `remote-management-key` 不能通过 API 修改,需在配置文件中设置。

533
README.md
View File

@@ -2,25 +2,43 @@
English | [中文](README_CN.md)
A proxy server that provides an OpenAI/Gemini/Claude compatible API interface for CLI. This allows you to use CLI models with tools and libraries designed for the OpenAI/Gemini/Claude API.
A proxy server that provides OpenAI/Gemini/Claude/Codex compatible API interfaces for CLI.
It now also supports OpenAI Codex (GPT models) and Claude Code via OAuth.
So you can use local or multi-account CLI access with OpenAI(include Responses)/Gemini/Claude-compatible clients and SDKs.
The first Chinese provider has now been added: [Qwen Code](https://github.com/QwenLM/qwen-code).
## Features
- OpenAI/Gemini/Claude compatible API endpoints for CLI models
- Support for both streaming and non-streaming responses
- OpenAI Codex support (GPT models) via OAuth login
- Claude Code support via OAuth login
- Qwen Code 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)
- Multiple account support with load balancing
- Simple CLI authentication flow
- Support for Generative Language API Key
- Support Gemini CLI with multiple account load balancing
- Multiple accounts with round-robin load balancing (Gemini, OpenAI, Claude and Qwen)
- Simple CLI authentication flows (Gemini, OpenAI, Claude and Qwen)
- Generative Language API Key support
- Gemini CLI multi-account load balancing
- Claude Code multi-account load balancing
- Qwen Code multi-account load balancing
- OpenAI Codex multi-account load balancing
- OpenAI-compatible upstream providers via config (e.g., OpenRouter)
- Reusable Go SDK for embedding the proxy (see `docs/sdk-usage.md`, 中文: `docs/sdk-usage_CN.md`)
## Installation
### Prerequisites
- Go 1.24 or higher
- A Google account with access to CLI models
- A Google account with access to Gemini CLI models (optional)
- An OpenAI account for Codex/GPT access (optional)
- An Anthropic account for Claude Code access (optional)
- A Qwen Chat account for Qwen Code access (optional)
### Building from Source
@@ -31,25 +49,72 @@ A proxy server that provides an OpenAI/Gemini/Claude compatible API interface fo
```
2. Build the application:
Linux, macOS:
```bash
go build -o cli-proxy-api ./cmd/server
```
Windows:
```bash
go build -o cli-proxy-api.exe ./cmd/server
```
## Usage
### GUI Client & Official WebUI
#### [EasyCLI](https://github.com/router-for-me/EasyCLI)
A cross-platform desktop GUI client for CLIProxyAPI.
#### [Cli-Proxy-API-Management-Center](https://github.com/router-for-me/Cli-Proxy-API-Management-Center)
A web-based management center for CLIProxyAPI.
Set `remote-management.disable-control-panel` to `true` if you prefer to host the management UI elsewhere; the server will skip downloading `management.html` and `/management.html` will return 404.
### Authentication
Before using the API, you need to authenticate with your Google account:
You can authenticate for Gemini, OpenAI, and/or Claude. All can coexist in the same `auth-dir` and will be load balanced.
```bash
./cli-proxy-api --login
```
- Gemini (Google):
```bash
./cli-proxy-api --login
```
If you are an existing Gemini Code user, you may need to specify a project ID:
```bash
./cli-proxy-api --login --project_id <your_project_id>
```
The local OAuth callback uses port `8085`.
If you are an old gemini code user, you may need to specify a project ID:
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
```
Options: add `--no-browser` to print the login URL instead of opening a browser. The local OAuth callback uses port `1455`.
- Claude (Anthropic via OAuth):
```bash
./cli-proxy-api --claude-login
```
Options: add `--no-browser` to print the login URL instead of opening a browser. The local OAuth callback uses port `54545`.
- Qwen (Qwen Chat via OAuth):
```bash
./cli-proxy-api --qwen-login
```
Options: add `--no-browser` to print the login URL instead of opening a browser. Use the Qwen Chat's OAuth device flow.
```bash
./cli-proxy-api --login --project_id <your_project_id>
```
### Starting the Server
@@ -90,6 +155,15 @@ Request body example:
}
```
Notes:
- Use a `gemini-*` model for Gemini (e.g., "gemini-2.5-pro"), a `gpt-*` model for OpenAI (e.g., "gpt-5"), a `claude-*` model for Claude (e.g., "claude-3-5-sonnet-20241022"), or a `qwen-*` model for Qwen (e.g., "qwen3-coder-plus"). The proxy will route to the correct provider automatically.
#### Claude Messages (SSE-compatible)
```
POST http://localhost:8317/v1/messages
```
### Using with OpenAI Libraries
You can use this proxy with any OpenAI-compatible library by setting the base URL to your local server:
@@ -104,14 +178,32 @@ client = OpenAI(
base_url="http://localhost:8317/v1"
)
response = client.chat.completions.create(
# Gemini example
gemini = client.chat.completions.create(
model="gemini-2.5-pro",
messages=[
{"role": "user", "content": "Hello, how are you?"}
]
messages=[{"role": "user", "content": "Hello, how are you?"}]
)
print(response.choices[0].message.content)
# Codex/GPT example
gpt = client.chat.completions.create(
model="gpt-5",
messages=[{"role": "user", "content": "Summarize this project in one sentence."}]
)
# Claude example (using messages endpoint)
import requests
claude_response = requests.post(
"http://localhost:8317/v1/messages",
json={
"model": "claude-3-5-sonnet-20241022",
"messages": [{"role": "user", "content": "Summarize this project in one sentence."}],
"max_tokens": 1000
}
)
print(gemini.choices[0].message.content)
print(gpt.choices[0].message.content)
print(claude_response.json())
```
#### JavaScript/TypeScript
@@ -124,43 +216,101 @@ const openai = new OpenAI({
baseURL: 'http://localhost:8317/v1',
});
const response = await openai.chat.completions.create({
// Gemini
const gemini = await openai.chat.completions.create({
model: 'gemini-2.5-pro',
messages: [
{ role: 'user', content: 'Hello, how are you?' }
],
messages: [{ role: 'user', content: 'Hello, how are you?' }],
});
console.log(response.choices[0].message.content);
// Codex/GPT
const gpt = await openai.chat.completions.create({
model: 'gpt-5',
messages: [{ role: 'user', content: 'Summarize this project in one sentence.' }],
});
// Claude example (using messages endpoint)
const claudeResponse = await fetch('http://localhost:8317/v1/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'claude-3-5-sonnet-20241022',
messages: [{ role: 'user', content: 'Summarize this project in one sentence.' }],
max_tokens: 1000
})
});
console.log(gemini.choices[0].message.content);
console.log(gpt.choices[0].message.content);
console.log(await claudeResponse.json());
```
## Supported Models
- gemini-2.5-pro
- gemini-2.5-flash
- And it automates switching to various preview versions
- gemini-2.5-flash-lite
- gpt-5
- gpt-5-codex
- claude-opus-4-1-20250805
- claude-opus-4-20250514
- claude-sonnet-4-20250514
- claude-sonnet-4-5-20250929
- claude-3-7-sonnet-20250219
- claude-3-5-haiku-20241022
- qwen3-coder-plus
- qwen3-coder-flash
- Gemini models auto-switch to preview variants when needed
## Configuration
The server uses a YAML configuration file (`config.yaml`) located in the project root directory by default. You can specify a different configuration file path using the `--config` flag:
```bash
./cli-proxy --config /path/to/your/config.yaml
./cli-proxy-api --config /path/to/your/config.yaml
```
### Configuration Options
| Parameter | Type | Default | Description |
|---------------------------------------|----------|--------------------|----------------------------------------------------------------------------------------------|
| `port` | integer | 8317 | The port number on which the server will listen |
| `auth-dir` | string | "~/.cli-proxy-api" | Directory where authentication tokens are stored. Supports using `~` for home directory |
| `proxy-url` | string | "" | Proxy url, support socks5/http/https protocol, example: socks5://user:pass@192.168.1.1:1080/ |
| `quota-exceeded` | object | {} | Configuration for handling quota exceeded |
| `quota-exceeded.switch-project` | boolean | true | Whether to automatically switch to another project when a quota is exceeded |
| `quota-exceeded.switch-preview-model` | boolean | true | Whether to automatically switch to a preview model when a quota is exceeded |
| `debug` | boolean | false | Enable debug mode for verbose logging |
| `api-keys` | string[] | [] | List of API keys that can be used to authenticate requests |
| `generative-language-api-key` | string[] | [] | List of Generative Language API keys |
| Parameter | Type | Default | Description |
|-----------------------------------------|----------|--------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `port` | integer | 8317 | The port number on which the server will listen. |
| `auth-dir` | string | "~/.cli-proxy-api" | Directory where authentication tokens are stored. Supports using `~` for the home directory. If you use Windows, please set the directory like this: `C:/cli-proxy-api/` |
| `proxy-url` | string | "" | Proxy URL. Supports socks5/http/https protocols. Example: socks5://user:pass@192.168.1.1:1080/ |
| `request-retry` | integer | 0 | Number of times to retry a request. Retries will occur if the HTTP response code is 403, 408, 500, 502, 503, or 504. |
| `remote-management.allow-remote` | boolean | false | Whether to allow remote (non-localhost) access to the management API. If false, only localhost can access. A management key is still required for localhost. |
| `remote-management.secret-key` | string | "" | Management key. If a plaintext value is provided, it will be hashed on startup using bcrypt and persisted back to the config file. If empty, the entire management API is disabled (404). |
| `remote-management.disable-control-panel` | boolean | false | When true, skip downloading `management.html` and return 404 for `/management.html`, effectively disabling the bundled management UI. |
| `quota-exceeded` | object | {} | Configuration for handling quota exceeded. |
| `quota-exceeded.switch-project` | boolean | true | Whether to automatically switch to another project when a quota is exceeded. |
| `quota-exceeded.switch-preview-model` | boolean | true | Whether to automatically switch to a preview model when a quota is exceeded. |
| `debug` | boolean | false | Enable debug mode for verbose logging. |
| `logging-to-file` | boolean | true | Write application logs to rotating files instead of stdout. Set to `false` to log to stdout/stderr. |
| `usage-statistics-enabled` | boolean | true | Enable in-memory usage aggregation for management APIs. Disable to drop all collected usage metrics. |
| `api-keys` | string[] | [] | Legacy shorthand for inline API keys. Values are mirrored into the `config-api-key` provider for backwards compatibility. |
| `generative-language-api-key` | string[] | [] | List of Generative Language API keys. |
| `codex-api-key` | object | {} | List of Codex API keys. |
| `codex-api-key.api-key` | string | "" | Codex API key. |
| `codex-api-key.base-url` | string | "" | Custom Codex API endpoint, if you use a third-party API endpoint. |
| `codex-api-key.proxy-url` | string | "" | Proxy URL for this specific API key. Overrides the global proxy-url setting. Supports socks5/http/https protocols. |
| `claude-api-key` | object | {} | List of Claude API keys. |
| `claude-api-key.api-key` | string | "" | Claude API key. |
| `claude-api-key.base-url` | string | "" | Custom Claude API endpoint, if you use a third-party API endpoint. |
| `claude-api-key.proxy-url` | string | "" | Proxy URL for this specific API key. Overrides the global proxy-url setting. Supports socks5/http/https protocols. |
| `openai-compatibility` | object[] | [] | Upstream OpenAI-compatible providers configuration (name, base-url, api-keys, models). |
| `openai-compatibility.*.name` | string | "" | The name of the provider. It will be used in the user agent and other places. |
| `openai-compatibility.*.base-url` | string | "" | The base URL of the provider. |
| `openai-compatibility.*.api-keys` | string[] | [] | (Deprecated) The API keys for the provider. Use api-key-entries instead for per-key proxy support. |
| `openai-compatibility.*.api-key-entries` | object[] | [] | API key entries with optional per-key proxy configuration. Preferred over api-keys. |
| `openai-compatibility.*.api-key-entries.*.api-key` | string | "" | The API key for this entry. |
| `openai-compatibility.*.api-key-entries.*.proxy-url` | string | "" | Proxy URL for this specific API key. Overrides the global proxy-url setting. Supports socks5/http/https protocols. |
| `openai-compatibility.*.models` | object[] | [] | The actual model name. |
| `openai-compatibility.*.models.*.name` | string | "" | The models supported by the provider. |
| `openai-compatibility.*.models.*.alias` | string | "" | The alias used in the API. |
| `gemini-web` | object | {} | Configuration specific to the Gemini Web client. |
| `gemini-web.context` | boolean | true | Enables conversation context reuse for continuous dialogue. |
| `gemini-web.code-mode` | boolean | false | Enables code mode for optimized responses in coding-related tasks. |
| `gemini-web.max-chars-per-request` | integer | 1,000,000 | The maximum number of characters to send to Gemini Web in a single request. |
| `gemini-web.disable-continuation-hint` | boolean | false | Disables the continuation hint for split prompts. |
### Example Configuration File
@@ -168,24 +318,48 @@ The server uses a YAML configuration file (`config.yaml`) located in the project
# Server port
port: 8317
# Authentication directory (supports ~ for home directory)
# Management API settings
remote-management:
# Whether to allow remote (non-localhost) management access.
# When false, only localhost can access management endpoints (a key is still required).
allow-remote: false
# Management key. If a plaintext value is provided here, it will be hashed on startup.
# All management requests (even from localhost) require this key.
# Leave empty to disable the Management API entirely (404 for all /v0/management routes).
secret-key: ""
# Disable the bundled management control panel asset download and HTTP route when true.
disable-control-panel: false
# 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"
# Enable debug logging
debug: false
# Proxy url, support socks5/http/https protocol, example: socks5://user:pass@192.168.1.1:1080/
# When true, write application logs to rotating files instead of stdout
logging-to-file: true
# When false, disable in-memory usage statistics aggregation
usage-statistics-enabled: true
# Proxy URL. Supports socks5/http/https protocols. Example: socks5://user:pass@192.168.1.1:1080/
proxy-url: ""
# Number of times to retry a request. Retries will occur if the HTTP response code is 403, 408, 500, 502, 503, or 504.
request-retry: 3
# Quota exceeded behavior
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
# API keys for authentication
api-keys:
- "your-api-key-1"
- "your-api-key-2"
# Gemini Web client configuration
gemini-web:
context: true # Enable conversation context reuse
code-mode: false # Enable code mode
max-chars-per-request: 1000000 # Max characters per request
# API keys for official Generative Language API
generative-language-api-key:
@@ -193,24 +367,113 @@ generative-language-api-key:
- "AIzaSy...02"
- "AIzaSy...03"
- "AIzaSy...04"
# Codex API keys
codex-api-key:
- api-key: "sk-atSM..."
base-url: "https://www.example.com" # use the custom codex API endpoint
proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
# Claude API keys
claude-api-key:
- api-key: "sk-atSM..." # use the official claude API key, no need to set the base url
- api-key: "sk-atSM..."
base-url: "https://www.example.com" # use the custom claude API endpoint
proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
# OpenAI compatibility providers
openai-compatibility:
- name: "openrouter" # The name of the provider; it will be used in the user agent and other places.
base-url: "https://openrouter.ai/api/v1" # The base URL of the provider.
# New format with per-key proxy support (recommended):
api-key-entries:
- api-key: "sk-or-v1-...b780"
proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
- api-key: "sk-or-v1-...b781" # without proxy-url
# Legacy format (still supported, but cannot specify proxy per key):
# api-keys:
# - "sk-or-v1-...b780"
# - "sk-or-v1-...b781"
models: # The models supported by the provider.
- name: "moonshotai/kimi-k2:free" # The actual model name.
alias: "kimi-k2" # The alias used in the API.
```
### OpenAI Compatibility Providers
Configure upstream OpenAI-compatible providers (e.g., OpenRouter) via `openai-compatibility`.
- name: provider identifier used internally
- base-url: provider base URL
- api-key-entries: list of API key entries with optional per-key proxy configuration (recommended)
- api-keys: (deprecated) simple list of API keys without proxy support
- models: list of mappings from upstream model `name` to local `alias`
Example with per-key proxy support:
```yaml
openai-compatibility:
- name: "openrouter"
base-url: "https://openrouter.ai/api/v1"
api-key-entries:
- api-key: "sk-or-v1-...b780"
proxy-url: "socks5://proxy.example.com:1080"
- api-key: "sk-or-v1-...b781"
models:
- name: "moonshotai/kimi-k2:free"
alias: "kimi-k2"
```
Legacy format (still supported):
```yaml
openai-compatibility:
- name: "openrouter"
base-url: "https://openrouter.ai/api/v1"
api-keys:
- "sk-or-v1-...b780"
- "sk-or-v1-...b781"
models:
- name: "moonshotai/kimi-k2:free"
alias: "kimi-k2"
```
Usage:
Call OpenAI's endpoint `/v1/chat/completions` with `model` set to the alias (e.g., `kimi-k2`). The proxy routes to the configured provider/model automatically.
Also, you may call Claude's endpoint `/v1/messages`, Gemini's `/v1beta/models/model-name:streamGenerateContent` or `/v1beta/models/model-name:generateContent`.
And you can always use Gemini CLI with `CODE_ASSIST_ENDPOINT` set to `http://127.0.0.1:8317` for these OpenAI-compatible provider's models.
### Authentication Directory
The `auth-dir` parameter specifies where authentication tokens are stored. When you run the login command, the application will create JSON files in this directory containing the authentication tokens for your Google accounts. Multiple accounts can be used for load balancing.
### API Keys
### Request Authentication Providers
The `api-keys` parameter allows you to define a list of API keys that can be used to authenticate requests to your proxy server. When making requests to the API, you can include one of these keys in the `Authorization` header:
Configure inbound authentication through the `auth.providers` section. The built-in `config-api-key` provider works with inline keys:
```
Authorization: Bearer your-api-key-1
auth:
providers:
- name: default
type: config-api-key
api-keys:
- your-api-key-1
```
Clients should send requests with an `Authorization: Bearer your-api-key-1` header (or `X-Goog-Api-Key`, `X-Api-Key`, or `?key=` as before). The legacy top-level `api-keys` array is still accepted and automatically synced to the default provider for backwards compatibility.
### Official Generative Language API
The `generative-language-api-key` parameter allows you to define a list of API keys that can be used to authenticate requests to the official Generative Language API.
## Hot Reloading
The server watches the config file and the `auth-dir` for changes and reloads clients and settings automatically. You can add or remove Gemini/OpenAI token JSON files while the server is running; no restart is required.
## Gemini CLI with multiple account load balancing
Start CLI Proxy API server, and then set the `CODE_ASSIST_ENDPOINT` environment variable to the URL of the CLI Proxy API server.
@@ -222,23 +485,193 @@ export CODE_ASSIST_ENDPOINT="http://127.0.0.1:8317"
The server will relay the `loadCodeAssist`, `onboardUser`, and `countTokens` requests. And automatically load balance the text generation requests between the multiple accounts.
> [!NOTE]
> This feature only allows local access because I couldn't find a way to authenticate the requests.
> I hardcoded `127.0.0.1` into the load balancing.
> This feature only allows local access because there is currently no way to authenticate the requests.
> 127.0.0.1 is hardcoded for load balancing.
## Claude Code with multiple account load balancing
Start CLI Proxy API server, and then set the `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL` environment variables.
Using Gemini models:
```bash
export ANTHROPIC_BASE_URL=http://127.0.0.1:8317
export ANTHROPIC_AUTH_TOKEN=sk-dummy
export ANTHROPIC_MODEL=gemini-2.5-pro
export ANTHROPIC_SMALL_FAST_MODEL=gemini-2.5-flash
```
Using OpenAI GPT 5 models:
```bash
export ANTHROPIC_BASE_URL=http://127.0.0.1:8317
export ANTHROPIC_AUTH_TOKEN=sk-dummy
export ANTHROPIC_MODEL=gpt-5
export ANTHROPIC_SMALL_FAST_MODEL=gpt-5-minimal
```
Using OpenAI GPT 5 Codex models:
```bash
export ANTHROPIC_BASE_URL=http://127.0.0.1:8317
export ANTHROPIC_AUTH_TOKEN=sk-dummy
export ANTHROPIC_MODEL=gpt-5-codex
export ANTHROPIC_SMALL_FAST_MODEL=gpt-5-codex-low
```
Using Claude models:
```bash
export ANTHROPIC_BASE_URL=http://127.0.0.1:8317
export ANTHROPIC_AUTH_TOKEN=sk-dummy
export ANTHROPIC_MODEL=claude-sonnet-4-20250514
export ANTHROPIC_SMALL_FAST_MODEL=claude-3-5-haiku-20241022
```
Using Qwen models:
```bash
export ANTHROPIC_BASE_URL=http://127.0.0.1:8317
export ANTHROPIC_AUTH_TOKEN=sk-dummy
export ANTHROPIC_MODEL=qwen3-coder-plus
export ANTHROPIC_SMALL_FAST_MODEL=qwen3-coder-flash
```
## Codex with multiple account load balancing
Start CLI Proxy API server, and then edit the `~/.codex/config.toml` and `~/.codex/auth.json` files.
config.toml:
```toml
model_provider = "cliproxyapi"
model = "gpt-5-codex" # Or gpt-5, you can also use any of the models that we support.
model_reasoning_effort = "high"
[model_providers.cliproxyapi]
name = "cliproxyapi"
base_url = "http://127.0.0.1:8317/v1"
wire_api = "responses"
```
auth.json:
```json
{
"OPENAI_API_KEY": "sk-dummy"
}
```
## Run with Docker
Run the following command to login:
Run the following command to login (Gemini OAuth on port 8085):
```bash
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
docker run --rm -p 1455:1455 -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 --codex-login
```
Run the following command to logi (Claude OAuth on port 54545):
```bash
docker run -rm -p 54545:54545 -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 --claude-login
```
Run the following command to login (Qwen OAuth):
```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 --qwen-login
```
Run the following command to start the server:
```bash
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
```
## Run with Docker Compose
1. Clone the repository and navigate into the directory:
```bash
git clone https://github.com/luispater/CLIProxyAPI.git
cd CLIProxyAPI
```
2. Prepare the configuration file:
Create a `config.yaml` file by copying the example and customize it to your needs.
```bash
cp config.example.yaml config.yaml
```
*(Note for Windows users: You can use `copy config.example.yaml config.yaml` in CMD or PowerShell.)*
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.
```bash
docker compose up -d
```
- **For advanced users:**
If you have modified the source code and need to build a new image, use the interactive helper scripts:
- For Windows (PowerShell):
```powershell
.\docker-build.ps1
```
- For Linux/macOS:
```bash
bash docker-build.sh
```
The script will prompt you to choose how to run the application:
- **Option 1: Run using Pre-built Image (Recommended)**: Pulls the latest official image from the registry and starts the container. This is the easiest way to get started.
- **Option 2: Build from Source and Run (For Developers)**: Builds the image from the local source code, tags it as `cli-proxy-api:local`, and then starts the container. This is useful if you are making changes to the source code.
4. To authenticate with providers, run the login command inside the container:
- **Gemini**:
```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
```
- **Claude**:
```bash
docker compose exec cli-proxy-api /CLIProxyAPI/CLIProxyAPI -no-browser --claude-login
```
- **Qwen**:
```bash
docker compose exec cli-proxy-api /CLIProxyAPI/CLIProxyAPI -no-browser --qwen-login
```
5. To view the server logs:
```bash
docker compose logs -f
```
6. To stop the application:
```bash
docker compose down
```
## Management API
see [MANAGEMENT_API.md](MANAGEMENT_API.md)
## SDK Docs
- Usage: [docs/sdk-usage.md](docs/sdk-usage.md)
- Advanced (executors & translators): [docs/sdk-advanced.md](docs/sdk-advanced.md)
- Access: [docs/sdk-access.md](docs/sdk-access.md)
- Watcher: [docs/sdk-watcher.md](docs/sdk-watcher.md)
- Custom Provider Example: `examples/custom-provider`
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.

View File

@@ -1,26 +1,64 @@
# 写给所有中国网友的
对于项目前期的确有很多用户使用上遇到各种各样的奇怪问题,大部分是因为配置或我说明文档不全导致的。
对说明文档我已经尽可能的修补,有些重要的地方我甚至已经写到了打包的配置文件里。
已经写在 README 中的功能,都是**可用**的,经过**验证**的,并且我自己**每天**都在使用的。
可能在某些场景中使用上效果并不是很出色,但那基本上是模型和工具的原因,比如用 Claude Code 的时候,有的模型就无法正确使用工具,比如 Gemini就在 Claude Code 和 Codex 的下使用的相当扭捏,有时能完成大部分工作,但有时候却只说不做。
目前来说 Claude 和 GPT-5 是目前使用各种第三方CLI工具运用的最好的模型我自己也是多个账号做均衡负载使用。
实事求是的说,最初的几个版本我根本就没有中文文档,我至今所有文档也都是使用英文更新让后让 Gemini 翻译成中文的。但是无论如何都不会出现中文文档无法理解的问题。因为所有的中英文文档我都是再三校对,并且发现未及时更改的更新的地方都快速更新掉了。
最后,烦请在发 Issue 之前请认真阅读这篇文档。
另外中文需要交流的用户可以加 QQ 群188637136
或 Telegram 群https://t.me/CLIProxyAPI
# CLI 代理 API
[English](README.md) | 中文
一个为 CLI 提供 OpenAI/Gemini/Claude 兼容 API 接口的代理服务器。这让您可以摆脱终端界面的束缚,将 Gemini 的强大能力以 API 的形式轻松接入到任何您喜爱的客户端或应用中。
一个为 CLI 提供 OpenAI/Gemini/Claude/Codex 兼容 API 接口的代理服务器。
现已支持通过 OAuth 登录接入 OpenAI CodexGPT 系列)和 Claude Code。
您可以使用本地或多账户的CLI方式通过任何与 OpenAI包括Responses/Gemini/Claude 兼容的客户端和SDK进行访问。
现已新增首个中国提供商:[Qwen Code](https://github.com/QwenLM/qwen-code)。
## 功能特性
- 为 CLI 模型提供 OpenAI/Gemini/Claude 兼容的 API 端点
- 支持流式和非流式响应
- 为 CLI 模型提供 OpenAI/Gemini/Claude/Codex 兼容的 API 端点
- 新增 OpenAI CodexGPT 系列支持OAuth 登录)
- 新增 Claude Code 支持OAuth 登录)
- 新增 Qwen Code 支持OAuth 登录)
- 新增 Gemini Web 支持(通过 Cookie 登录)
- 支持流式与非流式响应
- 函数调用/工具支持
- 多模态输入支持(文本和图像
- 多账户支持与负载均衡
- 简单的 CLI 身份验证流程
- 多模态输入(文本、图片
- 多账户支持与轮询负载均衡Gemini、OpenAI、Claude 与 Qwen
- 简单的 CLI 身份验证流程Gemini、OpenAI、Claude 与 Qwen
- 支持 Gemini AIStudio API 密钥
- 支持 Gemini CLI 多账户轮询
- 支持 Claude Code 多账户轮询
- 支持 Qwen Code 多账户轮询
- 支持 OpenAI Codex 多账户轮询
- 通过配置接入上游 OpenAI 兼容提供商(例如 OpenRouter
- 可复用的 Go SDK`docs/sdk-usage.md`
## 安装
### 前置要求
- Go 1.24 或更高版本
- 有权访问 CLI 模型的 Google 账户
- 有权访问 Gemini CLI 模型的 Google 账户(可选)
- 有权访问 OpenAI Codex/GPT 的 OpenAI 账户(可选)
- 有权访问 Claude Code 的 Anthropic 账户(可选)
- 有权访问 Qwen Code 的 Qwen Chat 账户(可选)
### 从源码构建
@@ -37,19 +75,58 @@
## 使用方法
### 图形客户端与官方 WebUI
#### [EasyCLI](https://github.com/router-for-me/EasyCLI)
CLIProxyAPI 的跨平台桌面图形客户端。
#### [Cli-Proxy-API-Management-Center](https://github.com/router-for-me/Cli-Proxy-API-Management-Center)
CLIProxyAPI 的基于 Web 的管理中心。
如果希望自行托管管理页面,可在配置中将 `remote-management.disable-control-panel` 设为 `true`,服务器将停止下载 `management.html`,并让 `/management.html` 返回 404。
### 身份验证
在使用 API 之前,您需要使用 Google 账户进行身份验证:
您可以分别为 Gemini、OpenAI 和 Claude 进行身份验证,三者可同时存在于同一个 `auth-dir` 中并参与负载均衡。
```bash
./cli-proxy-api --login
```
- GeminiGoogle
```bash
./cli-proxy-api --login
```
如果您是现有的 Gemini Code 用户可能需要指定一个项目ID
```bash
./cli-proxy-api --login --project_id <your_project_id>
```
本地 OAuth 回调端口为 `8085`。
如果您是旧版 gemini code 用户,可能需要指定项目 ID
选项:加上 `--no-browser` 可打印登录地址而不自动打开浏览器。本地 OAuth 回调端口为 `8085`。
```bash
./cli-proxy-api --login --project_id <your_project_id>
```
- 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
```
选项:加上 `--no-browser` 可打印登录地址而不自动打开浏览器。本地 OAuth 回调端口为 `1455`。
- ClaudeAnthropicOAuth
```bash
./cli-proxy-api --claude-login
```
选项:加上 `--no-browser` 可打印登录地址而不自动打开浏览器。本地 OAuth 回调端口为 `54545`。
- QwenQwen ChatOAuth
```bash
./cli-proxy-api --qwen-login
```
选项:加上 `--no-browser` 可打印登录地址而不自动打开浏览器。使用 Qwen Chat 的 OAuth 设备登录流程。
### 启动服务器
@@ -90,6 +167,15 @@ POST http://localhost:8317/v1/chat/completions
}
```
说明:
- 使用 "gemini-*" 模型(例如 "gemini-2.5-pro")来调用 Gemini使用 "gpt-*" 模型(例如 "gpt-5")来调用 OpenAI使用 "claude-*" 模型(例如 "claude-3-5-sonnet-20241022")来调用 Claude或者使用 "qwen-*" 模型(例如 "qwen3-coder-plus")来调用 Qwen。代理服务会自动将请求路由到相应的提供商。
#### Claude 消息SSE 兼容)
```
POST http://localhost:8317/v1/messages
```
### 与 OpenAI 库一起使用
您可以通过将基础 URL 设置为本地服务器来将此代理与任何 OpenAI 兼容的库一起使用:
@@ -104,14 +190,32 @@ client = OpenAI(
base_url="http://localhost:8317/v1"
)
response = client.chat.completions.create(
# Gemini 示例
gemini = client.chat.completions.create(
model="gemini-2.5-pro",
messages=[
{"role": "user", "content": "你好,你好吗?"}
]
messages=[{"role": "user", "content": "你好,你好吗?"}]
)
print(response.choices[0].message.content)
# Codex/GPT 示例
gpt = client.chat.completions.create(
model="gpt-5",
messages=[{"role": "user", "content": "用一句话总结这个项目"}]
)
# Claude 示例(使用 messages 端点)
import requests
claude_response = requests.post(
"http://localhost:8317/v1/messages",
json={
"model": "claude-3-5-sonnet-20241022",
"messages": [{"role": "user", "content": "用一句话总结这个项目"}],
"max_tokens": 1000
}
)
print(gemini.choices[0].message.content)
print(gpt.choices[0].message.content)
print(claude_response.json())
```
#### JavaScript/TypeScript
@@ -124,43 +228,101 @@ const openai = new OpenAI({
baseURL: 'http://localhost:8317/v1',
});
const response = await openai.chat.completions.create({
// Gemini
const gemini = await openai.chat.completions.create({
model: 'gemini-2.5-pro',
messages: [
{ role: 'user', content: '你好,你好吗?' }
],
messages: [{ role: 'user', content: '你好,你好吗?' }],
});
console.log(response.choices[0].message.content);
// Codex/GPT
const gpt = await openai.chat.completions.create({
model: 'gpt-5',
messages: [{ role: 'user', content: '用一句话总结这个项目' }],
});
// Claude 示例(使用 messages 端点)
const claudeResponse = await fetch('http://localhost:8317/v1/messages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'claude-3-5-sonnet-20241022',
messages: [{ role: 'user', content: '用一句话总结这个项目' }],
max_tokens: 1000
})
});
console.log(gemini.choices[0].message.content);
console.log(gpt.choices[0].message.content);
console.log(await claudeResponse.json());
```
## 支持的模型
- gemini-2.5-pro
- gemini-2.5-flash
- 并且自动切换到之前的预览版本
- gemini-2.5-flash-lite
- gpt-5
- gpt-5-codex
- claude-opus-4-1-20250805
- claude-opus-4-20250514
- claude-sonnet-4-20250514
- claude-sonnet-4-5-20250929
- claude-3-7-sonnet-20250219
- claude-3-5-haiku-20241022
- qwen3-coder-plus
- qwen3-coder-flash
- Gemini 模型在需要时自动切换到对应的 preview 版本
## 配置
服务器默认使用位于项目根目录的 YAML 配置文件(`config.yaml`)。您可以使用 `--config` 标志指定不同的配置文件路径:
```bash
./cli-proxy --config /path/to/your/config.yaml
./cli-proxy-api --config /path/to/your/config.yaml
```
### 配置选项
| 参数 | 类型 | 默认值 | 描述 |
|---------------------------------------|----------|--------------------|------------------------------------------------------------------------|
| `port` | integer | 8317 | 服务器监听的端口号 |
| `auth-dir` | string | "~/.cli-proxy-api" | 存储身份验证令牌的目录。支持使用 `~` 表示主目录 |
| `proxy-url` | string | "" | 代理 URL支持 socks5/http/https 协议,示例socks5://user:pass@192.168.1.1:1080/ |
| `quota-exceeded` | object | {} | 处理配额超限的配置 |
| `quota-exceeded.switch-project` | boolean | true | 当配额超限时是否自动切换到另一个项目 |
| `quota-exceeded.switch-preview-model` | boolean | true | 当配额超限时是否自动切换到预览模型 |
| `debug` | boolean | false | 启用调试模式以进行详细日志记录 |
| `api-keys` | string[] | [] | 用于验证请求的 API 密钥列表 |
| `generative-language-api-key` | string[] | [] | 生成式语言 API 密钥列表 |
| 参数 | 类型 | 默认值 | 描述 |
|-----------------------------------------|----------|--------------------|---------------------------------------------------------------------|
| `port` | integer | 8317 | 服务器监听的端口号 |
| `auth-dir` | string | "~/.cli-proxy-api" | 存储身份验证令牌的目录。支持使用 `~` 表示主目录。如果你使用Windows建议设置成`C:/cli-proxy-api/`。 |
| `proxy-url` | string | "" | 代理URL支持socks5/http/https协议。例如socks5://user:pass@192.168.1.1:1080/ |
| `request-retry` | integer | 0 | 请求重试次数。如果HTTP响应码为403、408、500、502、503或504将会触发重试。 |
| `remote-management.allow-remote` | boolean | false | 是否允许远程非localhost访问管理接口。为false时仅允许本地访问本地访问同样需要管理密钥。 |
| `remote-management.secret-key` | string | "" | 管理密钥。若配置为明文启动时会自动进行bcrypt加密并写回配置文件。若为空管理接口整体不可用404 |
| `remote-management.disable-control-panel` | boolean | false | 当为 true 时,不再下载 `management.html`,且 `/management.html` 会返回 404从而禁用内置管理界面。 |
| `quota-exceeded` | object | {} | 用于处理配额超限的配置。 |
| `quota-exceeded.switch-project` | boolean | true | 当配额超限时,是否自动切换到另一个项目。 |
| `quota-exceeded.switch-preview-model` | boolean | true | 当配额超限时,是否自动切换到预览模型。 |
| `debug` | boolean | false | 启用调试模式以获取详细日志。 |
| `logging-to-file` | boolean | true | 是否将应用日志写入滚动文件;设为 false 时输出到 stdout/stderr。 |
| `usage-statistics-enabled` | boolean | true | 是否启用内存中的使用统计;设为 false 时直接丢弃所有统计数据。 |
| `api-keys` | string[] | [] | 兼容旧配置的简写,会自动同步到默认 `config-api-key` 提供方。 |
| `generative-language-api-key` | string[] | [] | 生成式语言API密钥列表。 |
| `codex-api-key` | object | {} | Codex API密钥列表。 |
| `codex-api-key.api-key` | string | "" | Codex API密钥。 |
| `codex-api-key.base-url` | string | "" | 自定义的Codex API端点 |
| `codex-api-key.proxy-url` | string | "" | 针对该API密钥的代理URL。会覆盖全局proxy-url设置。支持socks5/http/https协议。 |
| `claude-api-key` | object | {} | Claude API密钥列表。 |
| `claude-api-key.api-key` | string | "" | Claude API密钥。 |
| `claude-api-key.base-url` | string | "" | 自定义的Claude API端点如果您使用第三方的API端点。 |
| `claude-api-key.proxy-url` | string | "" | 针对该API密钥的代理URL。会覆盖全局proxy-url设置。支持socks5/http/https协议。 |
| `openai-compatibility` | object[] | [] | 上游OpenAI兼容提供商的配置名称、基础URL、API密钥、模型。 |
| `openai-compatibility.*.name` | string | "" | 提供商的名称。它将被用于用户代理User Agent和其他地方。 |
| `openai-compatibility.*.base-url` | string | "" | 提供商的基础URL。 |
| `openai-compatibility.*.api-keys` | string[] | [] | (已弃用) 提供商的API密钥。建议改用api-key-entries以获得每密钥代理支持。 |
| `openai-compatibility.*.api-key-entries` | object[] | [] | API密钥条目支持可选的每密钥代理配置。优先于api-keys。 |
| `openai-compatibility.*.api-key-entries.*.api-key` | string | "" | 该条目的API密钥。 |
| `openai-compatibility.*.api-key-entries.*.proxy-url` | string | "" | 针对该API密钥的代理URL。会覆盖全局proxy-url设置。支持socks5/http/https协议。 |
| `openai-compatibility.*.models` | object[] | [] | 实际的模型名称。 |
| `openai-compatibility.*.models.*.name` | string | "" | 提供商支持的模型。 |
| `openai-compatibility.*.models.*.alias` | string | "" | 在API中使用的别名。 |
| `gemini-web` | object | {} | Gemini Web 客户端的特定配置。 |
| `gemini-web.context` | boolean | true | 是否启用会话上下文重用,以实现连续对话。 |
| `gemini-web.code-mode` | boolean | false | 是否启用代码模式,优化代码相关任务的响应。 |
| `gemini-web.max-chars-per-request` | integer | 1,000,000 | 单次请求发送给 Gemini Web 的最大字符数。 |
| `gemini-web.disable-continuation-hint` | boolean | false | 当提示被拆分时,是否禁用连续提示的暗示。 |
### 配置文件示例
@@ -168,24 +330,48 @@ console.log(response.choices[0].message.content);
# 服务器端口
port: 8317
# 身份验证目录(支持 ~ 表示主目录)
# 管理 API 设置
remote-management:
# 是否允许远程非localhost访问管理接口。为false时仅允许本地访问但本地访问同样需要管理密钥
allow-remote: false
# 管理密钥。若配置为明文启动时会自动进行bcrypt加密并写回配置文件。
# 所有管理请求(包括本地)都需要该密钥。
# 若为空,/v0/management 整体处于 404禁用
secret-key: ""
# 当设为 true 时,不下载管理面板文件,/management.html 将直接返回 404。
disable-control-panel: false
# 身份验证目录(支持 ~ 表示主目录。如果你使用Windows建议设置成`C:/cli-proxy-api/`。
auth-dir: "~/.cli-proxy-api"
# 启用调试日志
debug: false
# 代理 URL支持 socks5/http/https 协议示例socks5://user:pass@192.168.1.1:1080/
# 为 true 时将应用日志写入滚动文件而不是 stdout
logging-to-file: true
# 为 false 时禁用内存中的使用统计并直接丢弃所有数据
usage-statistics-enabled: true
# 代理URL。支持socks5/http/https协议。例如socks5://user:pass@192.168.1.1:1080/
proxy-url: ""
# 请求重试次数。如果HTTP响应码为403、408、500、502、503或504将会触发重试。
request-retry: 3
# 配额超限行为
quota-exceeded:
switch-project: true # 当配额超限时是否自动切换到另一个项目
switch-preview-model: true # 当配额超限时是否自动切换到预览模型
# 用于本地身份验证的 API 密钥
api-keys:
- "your-api-key-1"
- "your-api-key-2"
# Gemini Web 客户端配置
gemini-web:
context: true # 启用会话上下文重用
code-mode: false # 启用代码模式
max-chars-per-request: 1000000 # 单次请求最大字符数
# AIStduio Gemini API 的 API 密钥
generative-language-api-key:
@@ -193,24 +379,108 @@ generative-language-api-key:
- "AIzaSy...02"
- "AIzaSy...03"
- "AIzaSy...04"
# Codex API 密钥
codex-api-key:
- api-key: "sk-atSM..."
base-url: "https://www.example.com" # 第三方 Codex API 中转服务端点
proxy-url: "socks5://proxy.example.com:1080" # 可选:针对该密钥的代理设置
# Claude API 密钥
claude-api-key:
- api-key: "sk-atSM..." # 如果使用官方 Claude API,无需设置 base-url
- api-key: "sk-atSM..."
base-url: "https://www.example.com" # 第三方 Claude API 中转服务端点
proxy-url: "socks5://proxy.example.com:1080" # 可选:针对该密钥的代理设置
# OpenAI 兼容提供商
openai-compatibility:
- name: "openrouter" # 提供商的名称;它将被用于用户代理和其它地方。
base-url: "https://openrouter.ai/api/v1" # 提供商的基础URL。
# 新格式:支持每密钥代理配置(推荐):
api-key-entries:
- api-key: "sk-or-v1-...b780"
proxy-url: "socks5://proxy.example.com:1080" # 可选:针对该密钥的代理设置
- api-key: "sk-or-v1-...b781" # 不进行额外代理设置
# 旧格式(仍支持,但无法为每个密钥指定代理):
# api-keys:
# - "sk-or-v1-...b780"
# - "sk-or-v1-...b781"
models: # 提供商支持的模型。
- name: "moonshotai/kimi-k2:free" # 实际的模型名称。
alias: "kimi-k2" # 在API中使用的别名。
```
### OpenAI 兼容上游提供商
通过 `openai-compatibility` 配置上游 OpenAI 兼容提供商(例如 OpenRouter
- name内部识别名
- base-url提供商基础地址
- api-key-entriesAPI密钥条目列表支持可选的每密钥代理配置推荐
- api-keys(已弃用) 简单的API密钥列表不支持代理配置
- models将上游模型 `name` 映射为本地可用 `alias`
支持每密钥代理配置的示例:
```yaml
openai-compatibility:
- name: "openrouter"
base-url: "https://openrouter.ai/api/v1"
api-key-entries:
- api-key: "sk-or-v1-...b780"
proxy-url: "socks5://proxy.example.com:1080"
- api-key: "sk-or-v1-...b781"
models:
- name: "moonshotai/kimi-k2:free"
alias: "kimi-k2"
```
旧格式(仍支持):
```yaml
openai-compatibility:
- name: "openrouter"
base-url: "https://openrouter.ai/api/v1"
api-keys:
- "sk-or-v1-...b780"
- "sk-or-v1-...b781"
models:
- name: "moonshotai/kimi-k2:free"
alias: "kimi-k2"
```
使用方式:在 `/v1/chat/completions` 中将 `model` 设为别名(如 `kimi-k2`),代理将自动路由到对应提供商与模型。
并且对于这些与OpenAI兼容的提供商模型您始终可以通过将CODE_ASSIST_ENDPOINT设置为 http://127.0.0.1:8317 来使用Gemini CLI。
### 身份验证目录
`auth-dir` 参数指定身份验证令牌的存储位置。当您运行登录命令时,应用程序将在此目录中创建包含 Google 账户身份验证令牌的 JSON 文件。多个账户可用于轮询。
### API 密钥
### 请求鉴权提供方
`api-keys` 参数允许您定义可用于验证对代理服务器请求的 API 密钥列表。在向 API 发出请求时,您可以在 `Authorization` 标头中包含其中一个密钥:
通过 `auth.providers` 配置接入请求鉴权。内置的 `config-api-key` 提供方支持内联密钥:
```
Authorization: Bearer your-api-key-1
auth:
providers:
- name: default
type: config-api-key
api-keys:
- your-api-key-1
```
调用时可在 `Authorization` 标头中携带密钥(或继续使用 `X-Goog-Api-Key`、`X-Api-Key`、查询参数 `key`)。为了兼容旧版本,顶层的 `api-keys` 字段仍然可用,并会自动同步到默认的 `config-api-key` 提供方。
### 官方生成式语言 API
`generative-language-api-key` 参数允许您定义可用于验证对官方 AIStudio Gemini API 请求的 API 密钥列表。
## 热更新
服务会监听配置文件与 `auth-dir` 目录的变化并自动重新加载客户端与配置。您可以在运行中新增/移除 Gemini/OpenAI 的令牌 JSON 文件,无需重启服务。
## Gemini CLI 多账户负载均衡
启动 CLI 代理 API 服务器,然后将 `CODE_ASSIST_ENDPOINT` 环境变量设置为 CLI 代理 API 服务器的 URL。
@@ -222,23 +492,195 @@ export CODE_ASSIST_ENDPOINT="http://127.0.0.1:8317"
服务器将中继 `loadCodeAssist`、`onboardUser` 和 `countTokens` 请求。并自动在多个账户之间轮询文本生成请求。
> [!NOTE]
> 此功能仅允许本地访问,因为找不到一个可以验证请求的方法。
> 此功能仅允许本地访问,因为找不到一个可以验证请求的方法。
> 所以只能强制只有 `127.0.0.1` 可以访问。
## Claude Code 的使用方法
启动 CLI Proxy API 服务器, 设置如下系统环境变量 `ANTHROPIC_BASE_URL`, `ANTHROPIC_AUTH_TOKEN`, `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`
使用 Gemini 模型:
```bash
export ANTHROPIC_BASE_URL=http://127.0.0.1:8317
export ANTHROPIC_AUTH_TOKEN=sk-dummy
export ANTHROPIC_MODEL=gemini-2.5-pro
export ANTHROPIC_SMALL_FAST_MODEL=gemini-2.5-flash
```
使用 OpenAI GPT 5 模型:
```bash
export ANTHROPIC_BASE_URL=http://127.0.0.1:8317
export ANTHROPIC_AUTH_TOKEN=sk-dummy
export ANTHROPIC_MODEL=gpt-5
export ANTHROPIC_SMALL_FAST_MODEL=gpt-5-minimal
```
使用 OpenAI GPT 5 Codex 模型:
```bash
export ANTHROPIC_BASE_URL=http://127.0.0.1:8317
export ANTHROPIC_AUTH_TOKEN=sk-dummy
export ANTHROPIC_MODEL=gpt-5-codex
export ANTHROPIC_SMALL_FAST_MODEL=gpt-5-codex-low
```
使用 Claude 模型:
```bash
export ANTHROPIC_BASE_URL=http://127.0.0.1:8317
export ANTHROPIC_AUTH_TOKEN=sk-dummy
export ANTHROPIC_MODEL=claude-sonnet-4-20250514
export ANTHROPIC_SMALL_FAST_MODEL=claude-3-5-haiku-20241022
```
使用 Qwen 模型:
```bash
export ANTHROPIC_BASE_URL=http://127.0.0.1:8317
export ANTHROPIC_AUTH_TOKEN=sk-dummy
export ANTHROPIC_MODEL=qwen3-coder-plus
export ANTHROPIC_SMALL_FAST_MODEL=qwen3-coder-flash
```
## Codex 多账户负载均衡
启动 CLI Proxy API 服务器, 修改 `~/.codex/config.toml` 和 `~/.codex/auth.json` 文件。
config.toml:
```toml
model_provider = "cliproxyapi"
model = "gpt-5-codex" # 或者是gpt-5你也可以使用任何我们支持的模型
model_reasoning_effort = "high"
[model_providers.cliproxyapi]
name = "cliproxyapi"
base_url = "http://127.0.0.1:8317/v1"
wire_api = "responses"
```
auth.json:
```json
{
"OPENAI_API_KEY": "sk-dummy"
}
```
## 使用 Docker 运行
运行以下命令进行登录:
运行以下命令进行登录Gemini OAuth端口 8085
```bash
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
docker run --rm -p 1455:1455 -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 --codex-login
```
运行以下命令进行登录Claude OAuth端口 54545
```bash
docker run --rm -p 54545:54545 -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 --claude-login
```
运行以下命令进行登录Qwen OAuth
```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 --qwen-login
```
运行以下命令启动服务器:
```bash
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
```
## 使用 Docker Compose 运行
1. 克隆仓库并进入目录:
```bash
git clone https://github.com/luispater/CLIProxyAPI.git
cd CLIProxyAPI
```
2. 准备配置文件:
通过复制示例文件来创建 `config.yaml` 文件,并根据您的需求进行自定义。
```bash
cp config.example.yaml config.yaml
```
*Windows 用户请注意:您可以在 CMD 或 PowerShell 中使用 `copy config.example.yaml config.yaml`。)*
3. 启动服务:
- **适用于大多数用户(推荐):**
运行以下命令,使用 Docker Hub 上的预构建镜像启动服务。服务将在后台运行。
```bash
docker compose up -d
```
- **适用于进阶用户:**
如果您修改了源代码并需要构建新镜像,请使用交互式辅助脚本:
- 对于 Windows (PowerShell):
```powershell
.\docker-build.ps1
```
- 对于 Linux/macOS:
```bash
bash docker-build.sh
```
脚本将提示您选择运行方式:
- **选项 1使用预构建的镜像运行 (推荐)**:从镜像仓库拉取最新的官方镜像并启动容器。这是最简单的开始方式。
- **选项 2从源码构建并运行 (适用于开发者)**:从本地源代码构建镜像,将其标记为 `cli-proxy-api:local`,然后启动容器。如果您需要修改源代码,此选项很有用。
4. 要在容器内运行登录命令进行身份验证:
- **Gemini**:
```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
```
- **Claude**:
```bash
docker compose exec cli-proxy-api /CLIProxyAPI/CLIProxyAPI -no-browser --claude-login
```
- **Qwen**:
```bash
docker compose exec cli-proxy-api /CLIProxyAPI/CLIProxyAPI -no-browser --qwen-login
```
5. 查看服务器日志:
```bash
docker compose logs -f
```
6. 停止应用程序:
```bash
docker compose down
```
## 管理 API 文档
请参见 [MANAGEMENT_API_CN.md](MANAGEMENT_API_CN.md)
## SDK 文档
- 使用文档:[docs/sdk-usage_CN.md](docs/sdk-usage_CN.md)
- 高级(执行器与翻译器):[docs/sdk-advanced_CN.md](docs/sdk-advanced_CN.md)
- 认证: [docs/sdk-access_CN.md](docs/sdk-access_CN.md)
- 凭据加载/更新: [docs/sdk-watcher_CN.md](docs/sdk-watcher_CN.md)
- 自定义 Provider 示例:`examples/custom-provider`
## 贡献
欢迎贡献!请随时提交 Pull Request。
@@ -251,4 +693,4 @@ docker run --rm -p 8317:8317 -v /path/to/your/config.yaml:/CLIProxyAPI/config.ya
## 许可证
此项目根据 MIT 许可证授权 - 有关详细信息,请参阅 [LICENSE](LICENSE) 文件。
此项目根据 MIT 许可证授权 - 有关详细信息,请参阅 [LICENSE](LICENSE) 文件。

0
auths/.gitkeep Normal file
View File

View File

@@ -1,107 +1,162 @@
// Package main provides the entry point for the CLI Proxy API server.
// This server acts as a proxy that provides OpenAI/Gemini/Claude compatible API interfaces
// for CLI models, allowing CLI models to be used with tools and libraries designed for standard AI APIs.
package main
import (
"bytes"
"flag"
"fmt"
"github.com/luispater/CLIProxyAPI/internal/cmd"
"github.com/luispater/CLIProxyAPI/internal/config"
log "github.com/sirupsen/logrus"
"os"
"path"
"strings"
"path/filepath"
configaccess "github.com/router-for-me/CLIProxyAPI/v6/internal/access/config_access"
"github.com/router-for-me/CLIProxyAPI/v6/internal/cmd"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator"
"github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
log "github.com/sirupsen/logrus"
)
// LogFormatter defines a custom log format for logrus.
type LogFormatter struct {
}
var (
Version = "dev"
Commit = "none"
BuildDate = "unknown"
)
// Format renders a single log entry.
func (m *LogFormatter) Format(entry *log.Entry) ([]byte, error) {
var b *bytes.Buffer
if entry.Buffer != nil {
b = entry.Buffer
} else {
b = &bytes.Buffer{}
}
timestamp := entry.Time.Format("2006-01-02 15:04:05")
var newLog string
// Customize the log format to include timestamp, level, caller file/line, and message.
newLog = fmt.Sprintf("[%s] [%s] [%s:%d] %s\n", timestamp, entry.Level, path.Base(entry.Caller.File), entry.Caller.Line, entry.Message)
b.WriteString(newLog)
return b.Bytes(), nil
}
// init initializes the logger configuration.
// init initializes the shared logger setup.
func init() {
// Set logger output to standard output.
log.SetOutput(os.Stdout)
// Enable reporting the caller function's file and line number.
log.SetReportCaller(true)
// Set the custom log formatter.
log.SetFormatter(&LogFormatter{})
logging.SetupBaseLogger()
}
// main is the entry point of the application.
// It parses command-line flags, loads configuration, and starts the appropriate
// service based on the provided flags (login, codex-login, or server mode).
func main() {
fmt.Printf("CLIProxyAPI Version: %s, Commit: %s, BuiltAt: %s\n", Version, Commit, BuildDate)
// Command-line flags to control the application's behavior.
var login bool
var codexLogin bool
var claudeLogin bool
var qwenLogin bool
var geminiWebAuth bool
var noBrowser bool
var projectID string
var configPath string
var password string
// Define command-line flags.
// Define command-line flags for different operation modes.
flag.BoolVar(&login, "login", false, "Login Google Account")
flag.StringVar(&projectID, "project_id", "", "Project ID")
flag.BoolVar(&codexLogin, "codex-login", false, "Login to Codex using OAuth")
flag.BoolVar(&claudeLogin, "claude-login", false, "Login to Claude using OAuth")
flag.BoolVar(&qwenLogin, "qwen-login", false, "Login to Qwen using OAuth")
flag.BoolVar(&geminiWebAuth, "gemini-web-auth", false, "Auth Gemini Web using cookies")
flag.BoolVar(&noBrowser, "no-browser", false, "Don't open browser automatically for OAuth")
flag.StringVar(&projectID, "project_id", "", "Project ID (Gemini only, not required)")
flag.StringVar(&configPath, "config", "", "Configure File Path")
flag.StringVar(&password, "password", "", "")
flag.CommandLine.Usage = func() {
out := flag.CommandLine.Output()
_, _ = fmt.Fprintf(out, "Usage of %s\n", os.Args[0])
flag.CommandLine.VisitAll(func(f *flag.Flag) {
if f.Name == "password" {
return
}
s := fmt.Sprintf(" -%s", f.Name)
name, unquoteUsage := flag.UnquoteUsage(f)
if name != "" {
s += " " + name
}
if len(s) <= 4 {
s += " "
} else {
s += "\n "
}
if unquoteUsage != "" {
s += unquoteUsage
}
if f.DefValue != "" && f.DefValue != "false" && f.DefValue != "0" {
s += fmt.Sprintf(" (default %s)", f.DefValue)
}
_, _ = fmt.Fprint(out, s+"\n")
})
}
// Parse the command-line flags.
flag.Parse()
// Core application variables.
var err error
var cfg *config.Config
var wd string
// Load configuration from the specified path or the default path.
// 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.
var configFilePath string
if configPath != "" {
configFilePath = configPath
cfg, err = config.LoadConfig(configPath)
} else {
wd, err = os.Getwd()
if err != nil {
log.Fatalf("failed to get working directory: %v", err)
}
cfg, err = config.LoadConfig(path.Join(wd, "config.yaml"))
configFilePath = filepath.Join(wd, "config.yaml")
cfg, err = config.LoadConfig(configFilePath)
}
if err != nil {
log.Fatalf("failed to load config: %v", err)
}
usage.SetStatisticsEnabled(cfg.UsageStatisticsEnabled)
if err = logging.ConfigureLogOutput(cfg.LoggingToFile); err != nil {
log.Fatalf("failed to configure log output: %v", err)
}
log.Infof("CLIProxyAPI Version: %s, Commit: %s, BuiltAt: %s", Version, Commit, BuildDate)
// Set the log level based on the configuration.
if cfg.Debug {
log.SetLevel(log.DebugLevel)
util.SetLogLevel(cfg)
if resolvedAuthDir, errResolveAuthDir := util.ResolveAuthDir(cfg.AuthDir); errResolveAuthDir != nil {
log.Fatalf("failed to resolve auth directory: %v", errResolveAuthDir)
} else {
log.SetLevel(log.InfoLevel)
cfg.AuthDir = resolvedAuthDir
}
// Expand the tilde (~) in the auth directory path to the user's home directory.
if strings.HasPrefix(cfg.AuthDir, "~") {
home, errUserHomeDir := os.UserHomeDir()
if errUserHomeDir != nil {
log.Fatalf("failed to get home directory: %v", errUserHomeDir)
}
parts := strings.Split(cfg.AuthDir, string(os.PathSeparator))
if len(parts) > 1 {
parts[0] = home
cfg.AuthDir = path.Join(parts...)
} else {
cfg.AuthDir = home
}
// Create login options to be used in authentication flows.
options := &cmd.LoginOptions{
NoBrowser: noBrowser,
}
// Either perform login or start the service based on the 'login' flag.
// Register the shared token store once so all components use the same persistence backend.
sdkAuth.RegisterTokenStore(sdkAuth.NewFileTokenStore())
// Register built-in access providers before constructing services.
configaccess.Register()
// Handle different command modes based on the provided flags.
if login {
cmd.DoLogin(cfg, projectID)
// Handle Google/Gemini login
cmd.DoLogin(cfg, projectID, options)
} else if codexLogin {
// Handle Codex login
cmd.DoCodexLogin(cfg, options)
} else if claudeLogin {
// Handle Claude login
cmd.DoClaudeLogin(cfg, options)
} else if qwenLogin {
cmd.DoQwenLogin(cfg, options)
} else if geminiWebAuth {
cmd.DoGeminiWebAuth(cfg)
} else {
cmd.StartService(cfg)
// Start the main proxy service
cmd.StartService(cfg, configFilePath, password)
}
}

96
config.example.yaml Normal file
View File

@@ -0,0 +1,96 @@
# Server port
port: 8317
# Management API settings
remote-management:
# Whether to allow remote (non-localhost) management access.
# When false, only localhost can access management endpoints (a key is still required).
allow-remote: false
# Management key. If a plaintext value is provided here, it will be hashed on startup.
# All management requests (even from localhost) require this key.
# Leave empty to disable the Management API entirely (404 for all /v0/management routes).
secret-key: ""
# Authentication directory (supports ~ for home directory)
auth-dir: "~/.cli-proxy-api"
# API keys for authentication
api-keys:
- "your-api-key-1"
- "your-api-key-2"
# Enable debug logging
debug: false
# When true, write application logs to rotating files instead of stdout
logging-to-file: false
# When false, disable in-memory usage statistics aggregation
usage-statistics-enabled: false
# Proxy URL. Supports socks5/http/https protocols. Example: socks5://user:pass@192.168.1.1:1080/
proxy-url: ""
# Number of times to retry a request. Retries will occur if the HTTP response code is 403, 408, 500, 502, 503, or 504.
request-retry: 3
# Quota exceeded behavior
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
# API keys for official Generative Language API
#generative-language-api-key:
# - "AIzaSy...01"
# - "AIzaSy...02"
# - "AIzaSy...03"
# - "AIzaSy...04"
# Codex API keys
#codex-api-key:
# - api-key: "sk-atSM..."
# base-url: "https://www.example.com" # use the custom codex API endpoint
# proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
# Claude API keys
#claude-api-key:
# - api-key: "sk-atSM..." # use the official claude API key, no need to set the base url
# - api-key: "sk-atSM..."
# base-url: "https://www.example.com" # use the custom claude API endpoint
# proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
# OpenAI compatibility providers
#openai-compatibility:
# - name: "openrouter" # The name of the provider; it will be used in the user agent and other places.
# base-url: "https://openrouter.ai/api/v1" # The base URL of the provider.
# # New format with per-key proxy support (recommended):
# api-key-entries:
# - api-key: "sk-or-v1-...b780"
# proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
# - api-key: "sk-or-v1-...b781" # without proxy-url
# # Legacy format (still supported, but cannot specify proxy per key):
# # api-keys:
# # - "sk-or-v1-...b780"
# # - "sk-or-v1-...b781"
# models: # The models supported by the provider.
# - name: "moonshotai/kimi-k2:free" # The actual model name.
# alias: "kimi-k2" # The alias used in the API.
# Gemini Web settings
#gemini-web:
# # Conversation reuse: set to true to enable (default), false to disable.
# context: true
# # Maximum characters per single request to Gemini Web. Requests exceeding this
# # size split into chunks. Only the last chunk carries files and yields the final answer.
# max-chars-per-request: 1000000
# # Disable the short continuation hint appended to intermediate chunks
# # when splitting long prompts. Default is false (hint enabled by default).
# disable-continuation-hint: false
# # Code mode:
# # - true: enable XML wrapping hint and attach the coding-partner Gem.
# # Thought merging (<think> into visible content) applies to STREAMING only;
# # non-stream responses keep reasoning/thought parts separate for clients
# # that expect explicit reasoning fields.
# # - false: disable XML hint and keep <think> separate
# code-mode: false

View File

@@ -1,15 +0,0 @@
port: 8317
auth-dir: "~/.cli-proxy-api"
debug: true
proxy-url: ""
quota-exceeded:
switch-project: true
switch-preview-model: true
api-keys:
- "12345"
- "23456"
generative-language-api-key:
- "AIzaSy...01"
- "AIzaSy...02"
- "AIzaSy...03"
- "AIzaSy...04"

53
docker-build.ps1 Normal file
View File

@@ -0,0 +1,53 @@
# build.ps1 - Windows PowerShell Build Script
#
# This script automates the process of building and running the Docker container
# with version information dynamically injected at build time.
# Stop script execution on any error
$ErrorActionPreference = "Stop"
# --- Step 1: Choose Environment ---
Write-Host "Please select an option:"
Write-Host "1) Run using Pre-built Image (Recommended)"
Write-Host "2) Build from Source and Run (For Developers)"
$choice = Read-Host -Prompt "Enter choice [1-2]"
# --- Step 2: Execute based on choice ---
switch ($choice) {
"1" {
Write-Host "--- Running with Pre-built Image ---"
docker compose up -d --remove-orphans --no-build
Write-Host "Services are starting from remote image."
Write-Host "Run 'docker compose logs -f' to see the logs."
}
"2" {
Write-Host "--- Building from Source and Running ---"
# Get Version Information
$VERSION = (git describe --tags --always --dirty)
$COMMIT = (git rev-parse --short HEAD)
$BUILD_DATE = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ssZ")
Write-Host "Building with the following info:"
Write-Host " Version: $VERSION"
Write-Host " Commit: $COMMIT"
Write-Host " Build Date: $BUILD_DATE"
Write-Host "----------------------------------------"
# Build and start the services with a local-only image tag
$env:CLI_PROXY_IMAGE = "cli-proxy-api:local"
Write-Host "Building the Docker image..."
docker compose build --build-arg VERSION=$VERSION --build-arg COMMIT=$COMMIT --build-arg BUILD_DATE=$BUILD_DATE
Write-Host "Starting the services..."
docker compose up -d --remove-orphans --pull never
Write-Host "Build complete. Services are starting."
Write-Host "Run 'docker compose logs -f' to see the logs."
}
default {
Write-Host "Invalid choice. Please enter 1 or 2."
exit 1
}
}

58
docker-build.sh Normal file
View File

@@ -0,0 +1,58 @@
#!/usr/bin/env bash
#
# build.sh - Linux/macOS Build Script
#
# This script automates the process of building and running the Docker container
# with version information dynamically injected at build time.
# Exit immediately if a command exits with a non-zero status.
set -euo pipefail
# --- Step 1: Choose Environment ---
echo "Please select an option:"
echo "1) Run using Pre-built Image (Recommended)"
echo "2) Build from Source and Run (For Developers)"
read -r -p "Enter choice [1-2]: " choice
# --- Step 2: Execute based on choice ---
case "$choice" in
1)
echo "--- Running with Pre-built Image ---"
docker compose up -d --remove-orphans --no-build
echo "Services are starting from remote image."
echo "Run 'docker compose logs -f' to see the logs."
;;
2)
echo "--- Building from Source and Running ---"
# Get Version Information
VERSION="$(git describe --tags --always --dirty)"
COMMIT="$(git rev-parse --short HEAD)"
BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "Building with the following info:"
echo " Version: ${VERSION}"
echo " Commit: ${COMMIT}"
echo " Build Date: ${BUILD_DATE}"
echo "----------------------------------------"
# Build and start the services with a local-only image tag
export CLI_PROXY_IMAGE="cli-proxy-api:local"
echo "Building the Docker image..."
docker compose build \
--build-arg VERSION="${VERSION}" \
--build-arg COMMIT="${COMMIT}" \
--build-arg BUILD_DATE="${BUILD_DATE}"
echo "Starting the services..."
docker compose up -d --remove-orphans --pull never
echo "Build complete. Services are starting."
echo "Run 'docker compose logs -f' to see the logs."
;;
*)
echo "Invalid choice. Please enter 1 or 2."
exit 1
;;
esac

23
docker-compose.yml Normal file
View File

@@ -0,0 +1,23 @@
services:
cli-proxy-api:
image: ${CLI_PROXY_IMAGE:-eceasy/cli-proxy-api:latest}
pull_policy: always
build:
context: .
dockerfile: Dockerfile
args:
VERSION: ${VERSION:-dev}
COMMIT: ${COMMIT:-none}
BUILD_DATE: ${BUILD_DATE:-unknown}
container_name: cli-proxy-api
ports:
- "8317:8317"
- "8085:8085"
- "1455:1455"
- "54545:54545"
volumes:
- ./config.yaml:/CLIProxyAPI/config.yaml
- ./auths:/root/.cli-proxy-api
- ./logs:/CLIProxyAPI/logs
- ./conv:/CLIProxyAPI/conv
restart: unless-stopped

176
docs/sdk-access.md Normal file
View File

@@ -0,0 +1,176 @@
# @sdk/access SDK Reference
The `github.com/router-for-me/CLIProxyAPI/v6/sdk/access` package centralizes inbound request authentication for the proxy. It offers a lightweight manager that chains credential providers, so servers can reuse the same access control logic inside or outside the CLI runtime.
## Importing
```go
import (
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
)
```
Add the module with `go get github.com/router-for-me/CLIProxyAPI/v6/sdk/access`.
## Manager Lifecycle
```go
manager := sdkaccess.NewManager()
providers, err := sdkaccess.BuildProviders(cfg)
if err != nil {
return err
}
manager.SetProviders(providers)
```
* `NewManager` constructs an empty manager.
* `SetProviders` replaces the provider slice using a defensive copy.
* `Providers` retrieves a snapshot that can be iterated safely from other goroutines.
* `BuildProviders` translates `config.Config` access declarations into runnable providers. When the config omits explicit providers but defines inline API keys, the helper auto-installs the built-in `config-api-key` provider.
## Authenticating Requests
```go
result, err := manager.Authenticate(ctx, req)
switch {
case err == nil:
// Authentication succeeded; result describes the provider and principal.
case errors.Is(err, sdkaccess.ErrNoCredentials):
// No recognizable credentials were supplied.
case errors.Is(err, sdkaccess.ErrInvalidCredential):
// Supplied credentials were present but rejected.
default:
// Transport-level failure was returned by a provider.
}
```
`Manager.Authenticate` walks the configured providers in order. It returns on the first success, skips providers that surface `ErrNotHandled`, and tracks whether any provider reported `ErrNoCredentials` or `ErrInvalidCredential` for downstream error reporting.
If the manager itself is `nil` or no providers are registered, the call returns `nil, nil`, allowing callers to treat access control as disabled without branching on errors.
Each `Result` includes the provider identifier, the resolved principal, and optional metadata (for example, which header carried the credential).
## Configuration Layout
The manager expects access providers under the `auth.providers` key inside `config.yaml`:
```yaml
auth:
providers:
- name: inline-api
type: config-api-key
api-keys:
- sk-test-123
- sk-prod-456
```
Fields map directly to `config.AccessProvider`: `name` labels the provider, `type` selects the registered factory, `sdk` can name an external module, `api-keys` seeds inline credentials, and `config` passes provider-specific options.
### Loading providers from external SDK modules
To consume a provider shipped in another Go module, point the `sdk` field at the module path and import it for its registration side effect:
```yaml
auth:
providers:
- name: partner-auth
type: partner-token
sdk: github.com/acme/xplatform/sdk/access/providers/partner
config:
region: us-west-2
audience: cli-proxy
```
```go
import (
_ "github.com/acme/xplatform/sdk/access/providers/partner" // registers partner-token
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
)
```
The blank identifier import ensures `init` runs so `sdkaccess.RegisterProvider` executes before `BuildProviders` is called.
## Built-in Providers
The SDK ships with one provider out of the box:
- `config-api-key`: Validates API keys declared inline or under top-level `api-keys`. It accepts the key from `Authorization: Bearer`, `X-Goog-Api-Key`, `X-Api-Key`, or the `?key=` query string and reports `ErrInvalidCredential` when no match is found.
Additional providers can be delivered by third-party packages. When a provider package is imported, it registers itself with `sdkaccess.RegisterProvider`.
### Metadata and auditing
`Result.Metadata` carries provider-specific context. The built-in `config-api-key` provider, for example, stores the credential source (`authorization`, `x-goog-api-key`, `x-api-key`, or `query-key`). Populate this map in custom providers to enrich logs and downstream auditing.
## Writing Custom Providers
```go
type customProvider struct{}
func (p *customProvider) Identifier() string { return "my-provider" }
func (p *customProvider) Authenticate(ctx context.Context, r *http.Request) (*sdkaccess.Result, error) {
token := r.Header.Get("X-Custom")
if token == "" {
return nil, sdkaccess.ErrNoCredentials
}
if token != "expected" {
return nil, sdkaccess.ErrInvalidCredential
}
return &sdkaccess.Result{
Provider: p.Identifier(),
Principal: "service-user",
Metadata: map[string]string{"source": "x-custom"},
}, nil
}
func init() {
sdkaccess.RegisterProvider("custom", func(cfg *config.AccessProvider, root *config.Config) (sdkaccess.Provider, error) {
return &customProvider{}, nil
})
}
```
A provider must implement `Identifier()` and `Authenticate()`. To expose it to configuration, call `RegisterProvider` inside `init`. Provider factories receive the specific `AccessProvider` block plus the full root configuration for contextual needs.
## Error Semantics
- `ErrNoCredentials`: no credentials were present or recognized by any provider.
- `ErrInvalidCredential`: at least one provider processed the credentials but rejected them.
- `ErrNotHandled`: instructs the manager to fall through to the next provider without affecting aggregate error reporting.
Return custom errors to surface transport failures; they propagate immediately to the caller instead of being masked.
## Integration with cliproxy Service
`sdk/cliproxy` wires `@sdk/access` automatically when you build a CLI service via `cliproxy.NewBuilder`. Supplying a preconfigured manager allows you to extend or override the default providers:
```go
coreCfg, _ := config.LoadConfig("config.yaml")
providers, _ := sdkaccess.BuildProviders(coreCfg)
manager := sdkaccess.NewManager()
manager.SetProviders(providers)
svc, _ := cliproxy.NewBuilder().
WithConfig(coreCfg).
WithAccessManager(manager).
Build()
```
The service reuses the manager for every inbound request, ensuring consistent authentication across embedded deployments and the canonical CLI binary.
### Hot reloading providers
When configuration changes, rebuild providers and swap them into the manager:
```go
providers, err := sdkaccess.BuildProviders(newCfg)
if err != nil {
log.Errorf("reload auth providers failed: %v", err)
return
}
accessManager.SetProviders(providers)
```
This mirrors the behaviour in `cliproxy.Service.refreshAccessProviders` and `api.Server.applyAccessConfig`, enabling runtime updates without restarting the process.

176
docs/sdk-access_CN.md Normal file
View File

@@ -0,0 +1,176 @@
# @sdk/access 开发指引
`github.com/router-for-me/CLIProxyAPI/v6/sdk/access` 包负责代理的入站访问认证。它提供一个轻量的管理器,用于按顺序链接多种凭证校验实现,让服务器在 CLI 运行时内外都能复用相同的访问控制逻辑。
## 引用方式
```go
import (
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
)
```
通过 `go get github.com/router-for-me/CLIProxyAPI/v6/sdk/access` 添加依赖。
## 管理器生命周期
```go
manager := sdkaccess.NewManager()
providers, err := sdkaccess.BuildProviders(cfg)
if err != nil {
return err
}
manager.SetProviders(providers)
```
- `NewManager` 创建空管理器。
- `SetProviders` 替换提供者切片并做防御性拷贝。
- `Providers` 返回适合并发读取的快照。
- `BuildProviders``config.Config` 中的访问配置转换成可运行的提供者。当配置没有显式声明但包含顶层 `api-keys` 时,会自动挂载内建的 `config-api-key` 提供者。
## 认证请求
```go
result, err := manager.Authenticate(ctx, req)
switch {
case err == nil:
// Authentication succeeded; result carries provider and principal.
case errors.Is(err, sdkaccess.ErrNoCredentials):
// No recognizable credentials were supplied.
case errors.Is(err, sdkaccess.ErrInvalidCredential):
// Credentials were present but rejected.
default:
// Provider surfaced a transport-level failure.
}
```
`Manager.Authenticate` 按配置顺序遍历提供者。遇到成功立即返回,`ErrNotHandled` 会继续尝试下一个;若发现 `ErrNoCredentials``ErrInvalidCredential`,会在遍历结束后汇总给调用方。
若管理器本身为 `nil` 或尚未注册提供者,调用会返回 `nil, nil`,让调用方无需针对错误做额外分支即可关闭访问控制。
`Result` 提供认证提供者标识、解析出的主体以及可选元数据(例如凭证来源)。
## 配置结构
`config.yaml``auth.providers` 下定义访问提供者:
```yaml
auth:
providers:
- name: inline-api
type: config-api-key
api-keys:
- sk-test-123
- sk-prod-456
```
条目映射到 `config.AccessProvider``name` 指定实例名,`type` 选择注册的工厂,`sdk` 可引用第三方模块,`api-keys` 提供内联凭证,`config` 用于传递特定选项。
### 引入外部 SDK 提供者
若要消费其它 Go 模块输出的访问提供者,可在配置里填写 `sdk` 字段并在代码中引入该包,利用其 `init` 注册过程:
```yaml
auth:
providers:
- name: partner-auth
type: partner-token
sdk: github.com/acme/xplatform/sdk/access/providers/partner
config:
region: us-west-2
audience: cli-proxy
```
```go
import (
_ "github.com/acme/xplatform/sdk/access/providers/partner" // registers partner-token
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
)
```
通过空白标识符导入即可确保 `init` 调用,先于 `BuildProviders` 完成 `sdkaccess.RegisterProvider`
## 内建提供者
当前 SDK 默认内置:
- `config-api-key`:校验配置中的 API Key。它从 `Authorization: Bearer``X-Goog-Api-Key``X-Api-Key` 以及查询参数 `?key=` 提取凭证,不匹配时抛出 `ErrInvalidCredential`
导入第三方包即可通过 `sdkaccess.RegisterProvider` 注册更多类型。
### 元数据与审计
`Result.Metadata` 用于携带提供者特定的上下文信息。内建的 `config-api-key` 会记录凭证来源(`authorization``x-goog-api-key``x-api-key``query-key`)。自定义提供者同样可以填充该 Map以便丰富日志与审计场景。
## 编写自定义提供者
```go
type customProvider struct{}
func (p *customProvider) Identifier() string { return "my-provider" }
func (p *customProvider) Authenticate(ctx context.Context, r *http.Request) (*sdkaccess.Result, error) {
token := r.Header.Get("X-Custom")
if token == "" {
return nil, sdkaccess.ErrNoCredentials
}
if token != "expected" {
return nil, sdkaccess.ErrInvalidCredential
}
return &sdkaccess.Result{
Provider: p.Identifier(),
Principal: "service-user",
Metadata: map[string]string{"source": "x-custom"},
}, nil
}
func init() {
sdkaccess.RegisterProvider("custom", func(cfg *config.AccessProvider, root *config.Config) (sdkaccess.Provider, error) {
return &customProvider{}, nil
})
}
```
自定义提供者需要实现 `Identifier()``Authenticate()`。在 `init` 中调用 `RegisterProvider` 暴露给配置层,工厂函数既能读取当前条目,也能访问完整根配置。
## 错误语义
- `ErrNoCredentials`:任何提供者都未识别到凭证。
- `ErrInvalidCredential`:至少一个提供者处理了凭证但判定无效。
- `ErrNotHandled`:告诉管理器跳到下一个提供者,不影响最终错误统计。
自定义错误(例如网络异常)会马上冒泡返回。
## 与 cliproxy 集成
使用 `sdk/cliproxy` 构建服务时会自动接入 `@sdk/access`。如果需要扩展内置行为,可传入自定义管理器:
```go
coreCfg, _ := config.LoadConfig("config.yaml")
providers, _ := sdkaccess.BuildProviders(coreCfg)
manager := sdkaccess.NewManager()
manager.SetProviders(providers)
svc, _ := cliproxy.NewBuilder().
WithConfig(coreCfg).
WithAccessManager(manager).
Build()
```
服务会复用该管理器处理每一个入站请求,实现与 CLI 二进制一致的访问控制体验。
### 动态热更新提供者
当配置发生变化时,可以重新构建提供者并替换当前列表:
```go
providers, err := sdkaccess.BuildProviders(newCfg)
if err != nil {
log.Errorf("reload auth providers failed: %v", err)
return
}
accessManager.SetProviders(providers)
```
这一流程与 `cliproxy.Service.refreshAccessProviders``api.Server.applyAccessConfig` 保持一致,避免为更新访问策略而重启进程。

138
docs/sdk-advanced.md Normal file
View File

@@ -0,0 +1,138 @@
# SDK Advanced: Executors & Translators
This guide explains how to extend the embedded proxy with custom providers and schemas using the SDK. You will:
- Implement a provider executor that talks to your upstream API
- Register request/response translators for schema conversion
- Register models so they appear in `/v1/models`
The examples use Go 1.24+ and the v6 module path.
## Concepts
- Provider executor: a runtime component implementing `auth.ProviderExecutor` that performs outbound calls for a given provider key (e.g., `gemini`, `claude`, `codex`). Executors can also implement `RequestPreparer` to inject credentials on raw HTTP requests.
- Translator registry: schema conversion functions routed by `sdk/translator`. The builtin handlers translate between OpenAI/Gemini/Claude/Codex formats; you can register new ones.
- Model registry: publishes the list of available models per client/provider to power `/v1/models` and routing hints.
## 1) Implement a Provider Executor
Create a type that satisfies `auth.ProviderExecutor`.
```go
package myprov
import (
"context"
"net/http"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
clipexec "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
)
type Executor struct{}
func (Executor) Identifier() string { return "myprov" }
// Optional: mutate outbound HTTP requests with credentials
func (Executor) PrepareRequest(req *http.Request, a *coreauth.Auth) error {
// Example: req.Header.Set("Authorization", "Bearer "+a.APIKey)
return nil
}
func (Executor) Execute(ctx context.Context, a *coreauth.Auth, req clipexec.Request, opts clipexec.Options) (clipexec.Response, error) {
// Build HTTP request based on req.Payload (already translated into provider format)
// Use perauth transport if provided: transport := a.RoundTripper // via RoundTripperProvider
// Perform call and return provider JSON payload
return clipexec.Response{Payload: []byte(`{"ok":true}`)}, nil
}
func (Executor) ExecuteStream(ctx context.Context, a *coreauth.Auth, req clipexec.Request, opts clipexec.Options) (<-chan clipexec.StreamChunk, error) {
ch := make(chan clipexec.StreamChunk, 1)
go func() { defer close(ch); ch <- clipexec.StreamChunk{Payload: []byte("data: {\"done\":true}\n\n")} }()
return ch, nil
}
func (Executor) Refresh(ctx context.Context, a *coreauth.Auth) (*coreauth.Auth, error) {
// Optionally refresh tokens and return updated auth
return a, nil
}
```
Register the executor with the core manager before starting the service:
```go
core := coreauth.NewManager(coreauth.NewFileStore(cfg.AuthDir), nil, nil)
core.RegisterExecutor(myprov.Executor{})
svc, _ := cliproxy.NewBuilder().WithConfig(cfg).WithConfigPath(cfgPath).WithCoreAuthManager(core).Build()
```
If your auth entries use provider `"myprov"`, the manager routes requests to your executor.
## 2) Register Translators
The handlers accept OpenAI/Gemini/Claude/Codex inputs. To support a new provider format, register translation functions in `sdk/translator`s default registry.
Direction matters:
- Request: register from inbound schema to provider schema
- Response: register from provider schema back to inbound schema
Example: Convert OpenAI Chat → MyProv Chat and back.
```go
package myprov
import (
"context"
sdktr "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
)
const (
FOpenAI = sdktr.Format("openai.chat")
FMyProv = sdktr.Format("myprov.chat")
)
func init() {
sdktr.Register(FOpenAI, FMyProv,
// Request transform (model, rawJSON, stream)
func(model string, raw []byte, stream bool) []byte { return convertOpenAIToMyProv(model, raw, stream) },
// Response transform (stream & nonstream)
sdktr.ResponseTransform{
Stream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) []string {
return convertStreamMyProvToOpenAI(model, originalReq, translatedReq, raw)
},
NonStream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) string {
return convertMyProvToOpenAI(model, originalReq, translatedReq, raw)
},
},
)
}
```
When the OpenAI handler receives a request that should route to `myprov`, the pipeline uses the registered transforms automatically.
## 3) Register Models
Expose models under `/v1/models` by registering them in the global model registry using the auth ID (client ID) and provider name.
```go
models := []*cliproxy.ModelInfo{
{ ID: "myprov-pro-1", Object: "model", Type: "myprov", DisplayName: "MyProv Pro 1" },
}
cliproxy.GlobalModelRegistry().RegisterClient(authID, "myprov", models)
```
The embedded server calls this automatically for builtin providers; for custom providers, register during startup (e.g., after loading auths) or upon auth registration hooks.
## Credentials & Transports
- Use `Manager.SetRoundTripperProvider` to inject perauth `*http.Transport` (e.g., proxy):
```go
core.SetRoundTripperProvider(myProvider) // returns transport per auth
```
- For raw HTTP flows, implement `PrepareRequest` and/or call `Manager.InjectCredentials(req, authID)` to set headers.
## Testing Tips
- Enable request logging: Management API GET/PUT `/v0/management/request-log`
- Toggle debug logs: Management API GET/PUT `/v0/management/debug`
- Hot reload changes in `config.yaml` and `auths/` are picked up automatically by the watcher

131
docs/sdk-advanced_CN.md Normal file
View File

@@ -0,0 +1,131 @@
# SDK 高级指南:执行器与翻译器
本文介绍如何使用 SDK 扩展内嵌代理:
- 实现自定义 Provider 执行器以调用你的上游 API
- 注册请求/响应翻译器进行协议转换
- 注册模型以出现在 `/v1/models`
示例基于 Go 1.24+ 与 v6 模块路径。
## 概念
- Provider 执行器:实现 `auth.ProviderExecutor` 的运行时组件,负责某个 provider key`gemini``claude``codex`)的真正出站调用。若实现 `RequestPreparer` 接口,可在原始 HTTP 请求上注入凭据。
- 翻译器注册表:由 `sdk/translator` 驱动的协议转换函数。内置了 OpenAI/Gemini/Claude/Codex 的互转;你也可以注册新的格式转换。
- 模型注册表:对外发布可用模型列表,供 `/v1/models` 与路由参考。
## 1) 实现 Provider 执行器
创建类型满足 `auth.ProviderExecutor` 接口。
```go
package myprov
import (
"context"
"net/http"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
clipexec "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
)
type Executor struct{}
func (Executor) Identifier() string { return "myprov" }
// 可选:在原始 HTTP 请求上注入凭据
func (Executor) PrepareRequest(req *http.Request, a *coreauth.Auth) error {
// 例如req.Header.Set("Authorization", "Bearer "+a.Attributes["api_key"])
return nil
}
func (Executor) Execute(ctx context.Context, a *coreauth.Auth, req clipexec.Request, opts clipexec.Options) (clipexec.Response, error) {
// 基于 req.Payload 构造上游请求,返回上游 JSON 负载
return clipexec.Response{Payload: []byte(`{"ok":true}`)}, nil
}
func (Executor) ExecuteStream(ctx context.Context, a *coreauth.Auth, req clipexec.Request, opts clipexec.Options) (<-chan clipexec.StreamChunk, error) {
ch := make(chan clipexec.StreamChunk, 1)
go func() { defer close(ch); ch <- clipexec.StreamChunk{Payload: []byte("data: {\\"done\\":true}\\n\\n")} }()
return ch, nil
}
func (Executor) Refresh(ctx context.Context, a *coreauth.Auth) (*coreauth.Auth, error) { return a, nil }
```
在启动服务前将执行器注册到核心管理器:
```go
core := coreauth.NewManager(coreauth.NewFileStore(cfg.AuthDir), nil, nil)
core.RegisterExecutor(myprov.Executor{})
svc, _ := cliproxy.NewBuilder().WithConfig(cfg).WithConfigPath(cfgPath).WithCoreAuthManager(core).Build()
```
当凭据的 `Provider``"myprov"` 时,管理器会将请求路由到你的执行器。
## 2) 注册翻译器
内置处理器接受 OpenAI/Gemini/Claude/Codex 的入站格式。要支持新的 provider 协议,需要在 `sdk/translator` 的默认注册表中注册转换函数。
方向很重要:
- 请求从“入站格式”转换为“provider 格式”
- 响应从“provider 格式”转换回“入站格式”
示例OpenAI Chat → MyProv Chat 及其反向。
```go
package myprov
import (
"context"
sdktr "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
)
const (
FOpenAI = sdktr.Format("openai.chat")
FMyProv = sdktr.Format("myprov.chat")
)
func init() {
sdktr.Register(FOpenAI, FMyProv,
func(model string, raw []byte, stream bool) []byte { return convertOpenAIToMyProv(model, raw, stream) },
sdktr.ResponseTransform{
Stream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) []string {
return convertStreamMyProvToOpenAI(model, originalReq, translatedReq, raw)
},
NonStream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) string {
return convertMyProvToOpenAI(model, originalReq, translatedReq, raw)
},
},
)
}
```
当 OpenAI 处理器接到需要路由到 `myprov` 的请求时,流水线会自动应用已注册的转换。
## 3) 注册模型
通过全局模型注册表将模型暴露到 `/v1/models`
```go
models := []*cliproxy.ModelInfo{
{ ID: "myprov-pro-1", Object: "model", Type: "myprov", DisplayName: "MyProv Pro 1" },
}
cliproxy.GlobalModelRegistry().RegisterClient(authID, "myprov", models)
```
内置 Provider 会自动注册;自定义 Provider 建议在启动时(例如加载到 Auth 后)或在 Auth 注册钩子中调用。
## 凭据与传输
- 使用 `Manager.SetRoundTripperProvider` 注入按账户的 `*http.Transport`(例如代理):
```go
core.SetRoundTripperProvider(myProvider) // 按账户返回 transport
```
- 对于原始 HTTP 请求,若实现了 `PrepareRequest`,或通过 `Manager.InjectCredentials(req, authID)` 进行头部注入。
## 测试建议
- 启用请求日志:管理 API GET/PUT `/v0/management/request-log`
- 切换调试日志:管理 API GET/PUT `/v0/management/debug`
- 热更新:`config.yaml` 与 `auths/` 变化会自动被侦测并应用

163
docs/sdk-usage.md Normal file
View File

@@ -0,0 +1,163 @@
# CLI Proxy SDK Guide
The `sdk/cliproxy` module exposes the proxy as a reusable Go library so external programs can embed the routing, authentication, hotreload, and translation layers without depending on the CLI binary.
## Install & Import
```bash
go get github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy
```
```go
import (
"context"
"errors"
"time"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy"
)
```
Note the `/v6` module path.
## Minimal Embed
```go
cfg, err := config.LoadConfig("config.yaml")
if err != nil { panic(err) }
svc, err := cliproxy.NewBuilder().
WithConfig(cfg).
WithConfigPath("config.yaml"). // absolute or working-dir relative
Build()
if err != nil { panic(err) }
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := svc.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
panic(err)
}
```
The service manages config/auth watching, background token refresh, and graceful shutdown. Cancel the context to stop it.
## Server Options (middleware, routes, logs)
The server accepts options via `WithServerOptions`:
```go
svc, _ := cliproxy.NewBuilder().
WithConfig(cfg).
WithConfigPath("config.yaml").
WithServerOptions(
// Add global middleware
cliproxy.WithMiddleware(func(c *gin.Context) { c.Header("X-Embed", "1"); c.Next() }),
// Tweak gin engine early (CORS, trusted proxies, etc.)
cliproxy.WithEngineConfigurator(func(e *gin.Engine) { e.ForwardedByClientIP = true }),
// Add your own routes after defaults
cliproxy.WithRouterConfigurator(func(e *gin.Engine, _ *handlers.BaseAPIHandler, _ *config.Config) {
e.GET("/healthz", func(c *gin.Context) { c.String(200, "ok") })
}),
// Override request log writer/dir
cliproxy.WithRequestLoggerFactory(func(cfg *config.Config, cfgPath string) logging.RequestLogger {
return logging.NewFileRequestLogger(true, "logs", filepath.Dir(cfgPath))
}),
).
Build()
```
These options mirror the internals used by the CLI server.
## Management API (when embedded)
- Management endpoints are mounted only when `remote-management.secret-key` is set in `config.yaml`.
- Remote access additionally requires `remote-management.allow-remote: true`.
- See MANAGEMENT_API.md for endpoints. Your embedded server exposes them under `/v0/management` on the configured port.
## Using the Core Auth Manager
The service uses a core `auth.Manager` for selection, execution, and autorefresh. When embedding, you can provide your own manager to customize transports or hooks:
```go
core := coreauth.NewManager(coreauth.NewFileStore(cfg.AuthDir), nil, nil)
core.SetRoundTripperProvider(myRTProvider) // perauth *http.Transport
svc, _ := cliproxy.NewBuilder().
WithConfig(cfg).
WithConfigPath("config.yaml").
WithCoreAuthManager(core).
Build()
```
Implement a custom perauth transport:
```go
type myRTProvider struct{}
func (myRTProvider) RoundTripperFor(a *coreauth.Auth) http.RoundTripper {
if a == nil || a.ProxyURL == "" { return nil }
u, _ := url.Parse(a.ProxyURL)
return &http.Transport{ Proxy: http.ProxyURL(u) }
}
```
Programmatic execution is available on the manager:
```go
// Nonstreaming
resp, err := core.Execute(ctx, []string{"gemini"}, req, opts)
// Streaming
chunks, err := core.ExecuteStream(ctx, []string{"gemini"}, req, opts)
for ch := range chunks { /* ... */ }
```
Note: Builtin provider executors are wired automatically when you run the `Service`. If you want to use `Manager` standalone without the HTTP server, you must register your own executors that implement `auth.ProviderExecutor`.
## Custom Client Sources
Replace the default loaders if your creds live outside the local filesystem:
```go
type memoryTokenProvider struct{}
func (p *memoryTokenProvider) Load(ctx context.Context, cfg *config.Config) (*cliproxy.TokenClientResult, error) {
// Populate from memory/remote store and return counts
return &cliproxy.TokenClientResult{}, nil
}
svc, _ := cliproxy.NewBuilder().
WithConfig(cfg).
WithConfigPath("config.yaml").
WithTokenClientProvider(&memoryTokenProvider{}).
WithAPIKeyClientProvider(cliproxy.NewAPIKeyClientProvider()).
Build()
```
## Hooks
Observe lifecycle without patching internals:
```go
hooks := cliproxy.Hooks{
OnBeforeStart: func(cfg *config.Config) { log.Infof("starting on :%d", cfg.Port) },
OnAfterStart: func(s *cliproxy.Service) { log.Info("ready") },
}
svc, _ := cliproxy.NewBuilder().WithConfig(cfg).WithConfigPath("config.yaml").WithHooks(hooks).Build()
```
## Shutdown
`Run` defers `Shutdown`, so cancelling the parent context is enough. To stop manually:
```go
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = svc.Shutdown(ctx)
```
## Notes
- Hot reload: changes to `config.yaml` and `auths/` are picked up automatically.
- Request logging can be toggled at runtime via the Management API.
- Gemini Web features (`gemini-web.*`) are honored in the embedded server.

164
docs/sdk-usage_CN.md Normal file
View File

@@ -0,0 +1,164 @@
# CLI Proxy SDK 使用指南
`sdk/cliproxy` 模块将代理能力以 Go 库的形式对外暴露,方便在其它服务中内嵌路由、鉴权、热更新与翻译层,而无需依赖可执行的 CLI 程序。
## 安装与导入
```bash
go get github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy
```
```go
import (
"context"
"errors"
"time"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy"
)
```
注意模块路径包含 `/v6`
## 最小可用示例
```go
cfg, err := config.LoadConfig("config.yaml")
if err != nil { panic(err) }
svc, err := cliproxy.NewBuilder().
WithConfig(cfg).
WithConfigPath("config.yaml"). // 绝对路径或工作目录相对路径
Build()
if err != nil { panic(err) }
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := svc.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
panic(err)
}
```
服务内部会管理配置与认证文件的监听、后台令牌刷新与优雅关闭。取消上下文即可停止服务。
## 服务器可选项(中间件、路由、日志)
通过 `WithServerOptions` 自定义:
```go
svc, _ := cliproxy.NewBuilder().
WithConfig(cfg).
WithConfigPath("config.yaml").
WithServerOptions(
// 追加全局中间件
cliproxy.WithMiddleware(func(c *gin.Context) { c.Header("X-Embed", "1"); c.Next() }),
// 提前调整 gin 引擎(如 CORS、trusted proxies
cliproxy.WithEngineConfigurator(func(e *gin.Engine) { e.ForwardedByClientIP = true }),
// 在默认路由之后追加自定义路由
cliproxy.WithRouterConfigurator(func(e *gin.Engine, _ *handlers.BaseAPIHandler, _ *config.Config) {
e.GET("/healthz", func(c *gin.Context) { c.String(200, "ok") })
}),
// 覆盖请求日志的创建(启用/目录)
cliproxy.WithRequestLoggerFactory(func(cfg *config.Config, cfgPath string) logging.RequestLogger {
return logging.NewFileRequestLogger(true, "logs", filepath.Dir(cfgPath))
}),
).
Build()
```
这些选项与 CLI 服务器内部用法保持一致。
## 管理 API内嵌时
- 仅当 `config.yaml` 中设置了 `remote-management.secret-key` 时才会挂载管理端点。
- 远程访问还需要 `remote-management.allow-remote: true`
- 具体端点见 MANAGEMENT_API_CN.md。内嵌服务器会在配置端口下暴露 `/v0/management`
## 使用核心鉴权管理器
服务内部使用核心 `auth.Manager` 负责选择、执行、自动刷新。内嵌时可自定义其传输或钩子:
```go
core := coreauth.NewManager(coreauth.NewFileStore(cfg.AuthDir), nil, nil)
core.SetRoundTripperProvider(myRTProvider) // 按账户返回 *http.Transport
svc, _ := cliproxy.NewBuilder().
WithConfig(cfg).
WithConfigPath("config.yaml").
WithCoreAuthManager(core).
Build()
```
实现每个账户的自定义传输:
```go
type myRTProvider struct{}
func (myRTProvider) RoundTripperFor(a *coreauth.Auth) http.RoundTripper {
if a == nil || a.ProxyURL == "" { return nil }
u, _ := url.Parse(a.ProxyURL)
return &http.Transport{ Proxy: http.ProxyURL(u) }
}
```
管理器提供编程式执行接口:
```go
// 非流式
resp, err := core.Execute(ctx, []string{"gemini"}, req, opts)
// 流式
chunks, err := core.ExecuteStream(ctx, []string{"gemini"}, req, opts)
for ch := range chunks { /* ... */ }
```
说明:运行 `Service` 时会自动注册内置的提供商执行器;若仅单独使用 `Manager` 而不启动 HTTP 服务器,则需要自行实现并注册满足 `auth.ProviderExecutor` 的执行器。
## 自定义凭据来源
当凭据不在本地文件系统时,替换默认加载器:
```go
type memoryTokenProvider struct{}
func (p *memoryTokenProvider) Load(ctx context.Context, cfg *config.Config) (*cliproxy.TokenClientResult, error) {
// 从内存/远端加载并返回数量统计
return &cliproxy.TokenClientResult{}, nil
}
svc, _ := cliproxy.NewBuilder().
WithConfig(cfg).
WithConfigPath("config.yaml").
WithTokenClientProvider(&memoryTokenProvider{}).
WithAPIKeyClientProvider(cliproxy.NewAPIKeyClientProvider()).
Build()
```
## 启动钩子
无需修改内部代码即可观察生命周期:
```go
hooks := cliproxy.Hooks{
OnBeforeStart: func(cfg *config.Config) { log.Infof("starting on :%d", cfg.Port) },
OnAfterStart: func(s *cliproxy.Service) { log.Info("ready") },
}
svc, _ := cliproxy.NewBuilder().WithConfig(cfg).WithConfigPath("config.yaml").WithHooks(hooks).Build()
```
## 关闭
`Run` 内部会延迟调用 `Shutdown`,因此只需取消父上下文即可。若需手动停止:
```go
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = svc.Shutdown(ctx)
```
## 说明
- 热更新:`config.yaml``auths/` 变化会被自动侦测并应用。
- 请求日志可通过管理 API 在运行时开关。
- `gemini-web.*` 相关配置在内嵌服务器中会被遵循。

32
docs/sdk-watcher.md Normal file
View File

@@ -0,0 +1,32 @@
# SDK Watcher Integration
The SDK service exposes a watcher integration that surfaces granular auth updates without forcing a full reload. This document explains the queue contract, how the service consumes updates, and how high-frequency change bursts are handled.
## Update Queue Contract
- `watcher.AuthUpdate` represents a single credential change. `Action` may be `add`, `modify`, or `delete`, and `ID` carries the credential identifier. For `add`/`modify` the `Auth` payload contains a fully populated clone of the credential; `delete` may omit `Auth`.
- `WatcherWrapper.SetAuthUpdateQueue(chan<- watcher.AuthUpdate)` wires the queue produced by the SDK service into the watcher. The queue must be created before the watcher starts.
- The service builds the queue via `ensureAuthUpdateQueue`, using a buffered channel (`capacity=256`) and a dedicated consumer goroutine (`consumeAuthUpdates`). The consumer drains bursts by looping through the backlog before reacquiring the select loop.
## Watcher Behaviour
- `internal/watcher/watcher.go` keeps a shadow snapshot of auth state (`currentAuths`). Each filesystem or configuration event triggers a recomputation and a diff against the previous snapshot to produce minimal `AuthUpdate` entries that mirror adds, edits, and removals.
- Updates are coalesced per credential identifier. If multiple changes occur before dispatch (e.g., write followed by delete), only the final action is sent downstream.
- The watcher runs an internal dispatch loop that buffers pending updates in memory and forwards them asynchronously to the queue. Producers never block on channel capacity; they just enqueue into the in-memory buffer and signal the dispatcher. Dispatch cancellation happens when the watcher stops, guaranteeing goroutines exit cleanly.
## High-Frequency Change Handling
- The dispatch loop and service consumer run independently, preventing filesystem watchers from blocking even when many updates arrive at once.
- Back-pressure is absorbed in two places:
- The dispatch buffer (map + order slice) coalesces repeated updates for the same credential until the consumer catches up.
- The service channel capacity (256) combined with the consumer drain loop ensures several bursts can be processed without oscillation.
- If the queue is saturated for an extended period, updates continue to be merged, so the latest state is eventually applied without replaying redundant intermediate states.
## Usage Checklist
1. Instantiate the SDK service (builder or manual construction).
2. Call `ensureAuthUpdateQueue` before starting the watcher to allocate the shared channel.
3. When the `WatcherWrapper` is created, call `SetAuthUpdateQueue` with the service queue, then start the watcher.
4. Provide a reload callback that handles configuration updates; auth deltas will arrive via the queue and are applied by the service automatically through `handleAuthUpdate`.
Following this flow keeps auth changes responsive while avoiding full reloads for every edit.

32
docs/sdk-watcher_CN.md Normal file
View File

@@ -0,0 +1,32 @@
# SDK Watcher集成说明
本文档介绍SDK服务与文件监控器之间的增量更新队列包括接口契约、高频变更下的处理策略以及接入步骤。
## 更新队列契约
- `watcher.AuthUpdate`描述单条凭据变更,`Action`可能为`add``modify``delete``ID`是凭据标识。对于`add`/`modify`会携带完整的`Auth`克隆,`delete`可以省略`Auth`
- `WatcherWrapper.SetAuthUpdateQueue(chan<- watcher.AuthUpdate)`用于将服务侧创建的队列注入watcher必须在watcher启动前完成。
- 服务通过`ensureAuthUpdateQueue`创建容量为256的缓冲通道并在`consumeAuthUpdates`中使用专职goroutine消费消费侧会主动“抽干”积压事件降低切换开销。
## Watcher行为
- `internal/watcher/watcher.go`维护`currentAuths`快照,文件或配置事件触发后会重建快照并与旧快照对比,生成最小化的`AuthUpdate`列表。
- 以凭据ID为维度对更新进行合并同一凭据在短时间内的多次变更只会保留最新状态例如先写后删只会下发`delete`)。
- watcher内部运行异步分发循环生产者只向内存缓冲追加事件并唤醒分发协程即使通道暂时写满也不会阻塞文件事件线程。watcher停止时会取消分发循环确保协程正常退出。
## 高频变更处理
- 分发循环与服务消费协程相互独立因此即便短时间内出现大量变更也不会阻塞watcher事件处理。
- 背压通过两级缓冲吸收:
- 分发缓冲map + 顺序切片)会合并同一凭据的重复事件,直到消费者完成处理。
- 服务端通道的256容量加上消费侧的“抽干”逻辑可平稳处理多个突发批次。
- 当通道长时间处于高压状态时,缓冲仍持续合并事件,从而在消费者恢复后一次性应用最新状态,避免重复处理无意义的中间状态。
## 接入步骤
1. 实例化SDK Service构建器或手工创建
2. 在启动watcher之前调用`ensureAuthUpdateQueue`创建共享通道。
3. watcher通过工厂函数创建后立刻调用`SetAuthUpdateQueue`注入通道然后再启动watcher。
4. Reload回调专注于配置更新认证增量会通过队列送达并由`handleAuthUpdate`自动应用。
遵循上述流程即可在避免全量重载的同时保持凭据变更的实时性。

View File

@@ -0,0 +1,203 @@
// Package main demonstrates how to create a custom AI provider executor
// and integrate it with the CLI Proxy API server. This example shows how to:
// - Create a custom executor that implements the Executor interface
// - Register custom translators for request/response transformation
// - Integrate the custom provider with the SDK server
// - Register custom models in the model registry
//
// This example uses a simple echo service (httpbin.org) as the upstream API
// for demonstration purposes. In a real implementation, you would replace
// this with your actual AI service provider.
package main
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v6/internal/api"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
clipexec "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
sdktr "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
)
const (
// providerKey is the identifier for our custom provider.
providerKey = "myprov"
// fOpenAI represents the OpenAI chat format.
fOpenAI = sdktr.Format("openai.chat")
// fMyProv represents our custom provider's chat format.
fMyProv = sdktr.Format("myprov.chat")
)
// init registers trivial translators for demonstration purposes.
// In a real implementation, you would implement proper request/response
// transformation logic between OpenAI format and your provider's format.
func init() {
sdktr.Register(fOpenAI, fMyProv,
func(model string, raw []byte, stream bool) []byte { return raw },
sdktr.ResponseTransform{
Stream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) []string {
return []string{string(raw)}
},
NonStream: func(ctx context.Context, model string, originalReq, translatedReq, raw []byte, param *any) string {
return string(raw)
},
},
)
}
// MyExecutor is a minimal provider implementation for demonstration purposes.
// It implements the Executor interface to handle requests to a custom AI provider.
type MyExecutor struct{}
// Identifier returns the unique identifier for this executor.
func (MyExecutor) Identifier() string { return providerKey }
// PrepareRequest optionally injects credentials to raw HTTP requests.
// This method is called before each request to allow the executor to modify
// the HTTP request with authentication headers or other necessary modifications.
//
// Parameters:
// - req: The HTTP request to prepare
// - a: The authentication information
//
// Returns:
// - error: An error if request preparation fails
func (MyExecutor) PrepareRequest(req *http.Request, a *coreauth.Auth) error {
if req == nil || a == nil {
return nil
}
if a.Attributes != nil {
if ak := strings.TrimSpace(a.Attributes["api_key"]); ak != "" {
req.Header.Set("Authorization", "Bearer "+ak)
}
}
return nil
}
func buildHTTPClient(a *coreauth.Auth) *http.Client {
if a == nil || strings.TrimSpace(a.ProxyURL) == "" {
return http.DefaultClient
}
u, err := url.Parse(a.ProxyURL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
return http.DefaultClient
}
return &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(u)}}
}
func upstreamEndpoint(a *coreauth.Auth) string {
if a != nil && a.Attributes != nil {
if ep := strings.TrimSpace(a.Attributes["endpoint"]); ep != "" {
return ep
}
}
// Demo echo endpoint; replace with your upstream.
return "https://httpbin.org/post"
}
func (MyExecutor) Execute(ctx context.Context, a *coreauth.Auth, req clipexec.Request, opts clipexec.Options) (clipexec.Response, error) {
client := buildHTTPClient(a)
endpoint := upstreamEndpoint(a)
httpReq, errNew := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(req.Payload))
if errNew != nil {
return clipexec.Response{}, errNew
}
httpReq.Header.Set("Content-Type", "application/json")
// Inject credentials via PrepareRequest hook.
_ = (MyExecutor{}).PrepareRequest(httpReq, a)
resp, errDo := client.Do(httpReq)
if errDo != nil {
return clipexec.Response{}, errDo
}
defer func() {
if errClose := resp.Body.Close(); errClose != nil {
// Best-effort close; log if needed in real projects.
}
}()
body, _ := io.ReadAll(resp.Body)
return clipexec.Response{Payload: body}, nil
}
func (MyExecutor) ExecuteStream(ctx context.Context, a *coreauth.Auth, req clipexec.Request, opts clipexec.Options) (<-chan clipexec.StreamChunk, error) {
ch := make(chan clipexec.StreamChunk, 1)
go func() {
defer close(ch)
ch <- clipexec.StreamChunk{Payload: []byte("data: {\"ok\":true}\n\n")}
}()
return ch, nil
}
func (MyExecutor) Refresh(ctx context.Context, a *coreauth.Auth) (*coreauth.Auth, error) {
return a, nil
}
func main() {
cfg, err := config.LoadConfig("config.yaml")
if err != nil {
panic(err)
}
tokenStore := sdkAuth.GetTokenStore()
if dirSetter, ok := tokenStore.(interface{ SetBaseDir(string) }); ok {
dirSetter.SetBaseDir(cfg.AuthDir)
}
core := coreauth.NewManager(tokenStore, nil, nil)
core.RegisterExecutor(MyExecutor{})
hooks := cliproxy.Hooks{
OnAfterStart: func(s *cliproxy.Service) {
// Register demo models for the custom provider so they appear in /v1/models.
models := []*cliproxy.ModelInfo{{ID: "myprov-pro-1", Object: "model", Type: providerKey, DisplayName: "MyProv Pro 1"}}
for _, a := range core.List() {
if strings.EqualFold(a.Provider, providerKey) {
cliproxy.GlobalModelRegistry().RegisterClient(a.ID, providerKey, models)
}
}
},
}
svc, err := cliproxy.NewBuilder().
WithConfig(cfg).
WithConfigPath("config.yaml").
WithCoreAuthManager(core).
WithServerOptions(
// Optional: add a simple middleware + custom request logger
api.WithMiddleware(func(c *gin.Context) { c.Header("X-Example", "custom-provider"); c.Next() }),
api.WithRequestLoggerFactory(func(cfg *config.Config, cfgPath string) logging.RequestLogger {
return logging.NewFileRequestLogger(true, "logs", filepath.Dir(cfgPath))
}),
).
WithHooks(hooks).
Build()
if err != nil {
panic(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := svc.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
panic(err)
}
_ = os.Stderr // keep os import used (demo only)
_ = time.Second
}

11
go.mod
View File

@@ -1,13 +1,18 @@
module github.com/luispater/CLIProxyAPI
module github.com/router-for-me/CLIProxyAPI/v6
go 1.24
require (
github.com/fsnotify/fsnotify v1.9.0
github.com/gin-gonic/gin v1.10.1
github.com/google/uuid v1.6.0
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/oauth2 v0.30.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -25,6 +30,7 @@ require (
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.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/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
@@ -36,9 +42,8 @@ require (
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/crypto v0.36.0 // indirect
golang.org/x/net v0.37.1-0.20250305215238-2914f4677317 // indirect
golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.23.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
)

10
go.sum
View File

@@ -11,6 +11,8 @@ github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQ
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/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=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
@@ -30,8 +32,12 @@ github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MG
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/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.17.3 h1:qkRjuerhUU1EmXLYGkSH6EZL+vPSxIrYjLNAK4slzwA=
github.com/klauspost/compress v1.17.3/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
@@ -78,6 +84,8 @@ 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=
@@ -100,6 +108,8 @@ google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFW
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/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.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

@@ -0,0 +1,109 @@
package configaccess
import (
"context"
"net/http"
"strings"
"sync"
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
)
var registerOnce sync.Once
// Register ensures the config-access provider is available to the access manager.
func Register() {
registerOnce.Do(func() {
sdkaccess.RegisterProvider(sdkconfig.AccessProviderTypeConfigAPIKey, newProvider)
})
}
type provider struct {
name string
keys map[string]struct{}
}
func newProvider(cfg *sdkconfig.AccessProvider, _ *sdkconfig.SDKConfig) (sdkaccess.Provider, error) {
name := cfg.Name
if name == "" {
name = sdkconfig.DefaultAccessProviderName
}
keys := make(map[string]struct{}, len(cfg.APIKeys))
for _, key := range cfg.APIKeys {
if key == "" {
continue
}
keys[key] = struct{}{}
}
return &provider{name: name, keys: keys}, nil
}
func (p *provider) Identifier() string {
if p == nil || p.name == "" {
return sdkconfig.DefaultAccessProviderName
}
return p.name
}
func (p *provider) Authenticate(_ context.Context, r *http.Request) (*sdkaccess.Result, error) {
if p == nil {
return nil, sdkaccess.ErrNotHandled
}
if len(p.keys) == 0 {
return nil, sdkaccess.ErrNotHandled
}
authHeader := r.Header.Get("Authorization")
authHeaderGoogle := r.Header.Get("X-Goog-Api-Key")
authHeaderAnthropic := r.Header.Get("X-Api-Key")
queryKey := ""
if r.URL != nil {
queryKey = r.URL.Query().Get("key")
}
if authHeader == "" && authHeaderGoogle == "" && authHeaderAnthropic == "" && queryKey == "" {
return nil, sdkaccess.ErrNoCredentials
}
apiKey := extractBearerToken(authHeader)
candidates := []struct {
value string
source string
}{
{apiKey, "authorization"},
{authHeaderGoogle, "x-goog-api-key"},
{authHeaderAnthropic, "x-api-key"},
{queryKey, "query-key"},
}
for _, candidate := range candidates {
if candidate.value == "" {
continue
}
if _, ok := p.keys[candidate.value]; ok {
return &sdkaccess.Result{
Provider: p.Identifier(),
Principal: candidate.value,
Metadata: map[string]string{
"source": candidate.source,
},
}, nil
}
}
return nil, sdkaccess.ErrInvalidCredential
}
func extractBearerToken(header string) string {
if header == "" {
return ""
}
parts := strings.SplitN(header, " ", 2)
if len(parts) != 2 {
return header
}
if strings.ToLower(parts[0]) != "bearer" {
return header
}
return strings.TrimSpace(parts[1])
}

View File

@@ -0,0 +1,269 @@
package access
import (
"fmt"
"reflect"
"sort"
"strings"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
sdkConfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
log "github.com/sirupsen/logrus"
)
// ReconcileProviders builds the desired provider list by reusing existing providers when possible
// and creating or removing providers only when their configuration changed. It returns the final
// ordered provider slice along with the identifiers of providers that were added, updated, or
// removed compared to the previous configuration.
func ReconcileProviders(oldCfg, newCfg *config.Config, existing []sdkaccess.Provider) (result []sdkaccess.Provider, added, updated, removed []string, err error) {
if newCfg == nil {
return nil, nil, nil, nil, nil
}
existingMap := make(map[string]sdkaccess.Provider, len(existing))
for _, provider := range existing {
if provider == nil {
continue
}
existingMap[provider.Identifier()] = provider
}
oldCfgMap := accessProviderMap(oldCfg)
newEntries := collectProviderEntries(newCfg)
result = make([]sdkaccess.Provider, 0, len(newEntries))
finalIDs := make(map[string]struct{}, len(newEntries))
isInlineProvider := func(id string) bool {
return strings.EqualFold(id, sdkConfig.DefaultAccessProviderName)
}
appendChange := func(list *[]string, id string) {
if isInlineProvider(id) {
return
}
*list = append(*list, id)
}
for _, providerCfg := range newEntries {
key := providerIdentifier(providerCfg)
if key == "" {
continue
}
if oldCfgProvider, ok := oldCfgMap[key]; ok {
isAliased := oldCfgProvider == providerCfg
if !isAliased && providerConfigEqual(oldCfgProvider, providerCfg) {
if existingProvider, okExisting := existingMap[key]; okExisting {
result = append(result, existingProvider)
finalIDs[key] = struct{}{}
continue
}
}
}
provider, buildErr := sdkaccess.BuildProvider(providerCfg, &newCfg.SDKConfig)
if buildErr != nil {
return nil, nil, nil, nil, buildErr
}
if _, ok := oldCfgMap[key]; ok {
if _, existed := existingMap[key]; existed {
appendChange(&updated, key)
} else {
appendChange(&added, key)
}
} else {
appendChange(&added, key)
}
result = append(result, provider)
finalIDs[key] = struct{}{}
}
if len(result) == 0 {
if inline := sdkConfig.MakeInlineAPIKeyProvider(newCfg.APIKeys); inline != nil {
key := providerIdentifier(inline)
if key != "" {
if oldCfgProvider, ok := oldCfgMap[key]; ok {
if providerConfigEqual(oldCfgProvider, inline) {
if existingProvider, okExisting := existingMap[key]; okExisting {
result = append(result, existingProvider)
finalIDs[key] = struct{}{}
goto inlineDone
}
}
}
provider, buildErr := sdkaccess.BuildProvider(inline, &newCfg.SDKConfig)
if buildErr != nil {
return nil, nil, nil, nil, buildErr
}
if _, existed := existingMap[key]; existed {
appendChange(&updated, key)
} else if _, hadOld := oldCfgMap[key]; hadOld {
appendChange(&updated, key)
} else {
appendChange(&added, key)
}
result = append(result, provider)
finalIDs[key] = struct{}{}
}
}
inlineDone:
}
removedSet := make(map[string]struct{})
for id := range existingMap {
if _, ok := finalIDs[id]; !ok {
if isInlineProvider(id) {
continue
}
removedSet[id] = struct{}{}
}
}
removed = make([]string, 0, len(removedSet))
for id := range removedSet {
removed = append(removed, id)
}
sort.Strings(added)
sort.Strings(updated)
sort.Strings(removed)
return result, added, updated, removed, nil
}
// ApplyAccessProviders reconciles the configured access providers against the
// currently registered providers and updates the manager. It logs a concise
// summary of the detected changes and returns whether any provider changed.
func ApplyAccessProviders(manager *sdkaccess.Manager, oldCfg, newCfg *config.Config) (bool, error) {
if manager == nil || newCfg == nil {
return false, nil
}
existing := manager.Providers()
providers, added, updated, removed, err := ReconcileProviders(oldCfg, newCfg, existing)
if err != nil {
log.Errorf("failed to reconcile request auth providers: %v", err)
return false, fmt.Errorf("reconciling access providers: %w", err)
}
manager.SetProviders(providers)
if len(added)+len(updated)+len(removed) > 0 {
log.Debugf("auth providers reconciled (added=%d updated=%d removed=%d)", len(added), len(updated), len(removed))
log.Debugf("auth providers changes details - added=%v updated=%v removed=%v", added, updated, removed)
return true, nil
}
log.Debug("auth providers unchanged after config update")
return false, nil
}
func accessProviderMap(cfg *config.Config) map[string]*sdkConfig.AccessProvider {
result := make(map[string]*sdkConfig.AccessProvider)
if cfg == nil {
return result
}
for i := range cfg.Access.Providers {
providerCfg := &cfg.Access.Providers[i]
if providerCfg.Type == "" {
continue
}
key := providerIdentifier(providerCfg)
if key == "" {
continue
}
result[key] = providerCfg
}
if len(result) == 0 && len(cfg.APIKeys) > 0 {
if provider := sdkConfig.MakeInlineAPIKeyProvider(cfg.APIKeys); provider != nil {
if key := providerIdentifier(provider); key != "" {
result[key] = provider
}
}
}
return result
}
func collectProviderEntries(cfg *config.Config) []*sdkConfig.AccessProvider {
entries := make([]*sdkConfig.AccessProvider, 0, len(cfg.Access.Providers))
for i := range cfg.Access.Providers {
providerCfg := &cfg.Access.Providers[i]
if providerCfg.Type == "" {
continue
}
if key := providerIdentifier(providerCfg); key != "" {
entries = append(entries, providerCfg)
}
}
if len(entries) == 0 && len(cfg.APIKeys) > 0 {
if inline := sdkConfig.MakeInlineAPIKeyProvider(cfg.APIKeys); inline != nil {
entries = append(entries, inline)
}
}
return entries
}
func providerIdentifier(provider *sdkConfig.AccessProvider) string {
if provider == nil {
return ""
}
if name := strings.TrimSpace(provider.Name); name != "" {
return name
}
typ := strings.TrimSpace(provider.Type)
if typ == "" {
return ""
}
if strings.EqualFold(typ, sdkConfig.AccessProviderTypeConfigAPIKey) {
return sdkConfig.DefaultAccessProviderName
}
return typ
}
func providerConfigEqual(a, b *sdkConfig.AccessProvider) bool {
if a == nil || b == nil {
return a == nil && b == nil
}
if !strings.EqualFold(strings.TrimSpace(a.Type), strings.TrimSpace(b.Type)) {
return false
}
if strings.TrimSpace(a.SDK) != strings.TrimSpace(b.SDK) {
return false
}
if !stringSetEqual(a.APIKeys, b.APIKeys) {
return false
}
if len(a.Config) != len(b.Config) {
return false
}
if len(a.Config) > 0 && !reflect.DeepEqual(a.Config, b.Config) {
return false
}
return true
}
func stringSetEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
if len(a) == 0 {
return true
}
seen := make(map[string]int, len(a))
for _, val := range a {
seen[val]++
}
for _, val := range b {
count := seen[val]
if count == 0 {
return false
}
if count == 1 {
delete(seen, val)
} else {
seen[val] = count - 1
}
}
return len(seen) == 0
}

View File

@@ -1,188 +0,0 @@
package api
import (
"context"
"fmt"
"github.com/gin-gonic/gin"
"github.com/luispater/CLIProxyAPI/internal/api/translator"
"github.com/luispater/CLIProxyAPI/internal/client"
log "github.com/sirupsen/logrus"
"net/http"
"strings"
"time"
)
// ClaudeMessages handles Claude-compatible streaming chat completions.
// This function implements a sophisticated client rotation and quota management system
// to ensure high availability and optimal resource utilization across multiple backend clients.
func (h *APIHandlers) ClaudeMessages(c *gin.Context) {
// Extract raw JSON data from the incoming request
rawJson, err := c.GetRawData()
// If data retrieval fails, return a 400 Bad Request error.
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{
Error: ErrorDetail{
Message: fmt.Sprintf("Invalid request: %v", err),
Type: "invalid_request_error",
},
})
return
}
// Set up Server-Sent Events (SSE) headers for streaming response
// These headers are essential for maintaining a persistent connection
// and enabling real-time streaming of chat completions
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("Access-Control-Allow-Origin", "*")
// Get the http.Flusher interface to manually flush the response.
// This is crucial for streaming as it allows immediate sending of data chunks
flusher, ok := c.Writer.(http.Flusher)
if !ok {
c.JSON(http.StatusInternalServerError, ErrorResponse{
Error: ErrorDetail{
Message: "Streaming not supported",
Type: "server_error",
},
})
return
}
// Parse and prepare the Claude request, extracting model name, system instructions,
// conversation contents, and available tools from the raw JSON
modelName, systemInstruction, contents, tools := translator.PrepareClaudeRequest(rawJson)
// Map Claude model names to corresponding Gemini models
// This allows the proxy to handle Claude API calls using Gemini backend
if modelName == "claude-sonnet-4-20250514" {
modelName = "gemini-2.5-pro"
} else if modelName == "claude-3-5-haiku-20241022" {
modelName = "gemini-2.5-flash"
}
// Create a cancellable context for the backend client request
// This allows proper cleanup and cancellation of ongoing requests
cliCtx, cliCancel := context.WithCancel(context.Background())
var cliClient *client.Client
defer func() {
// Ensure the client's mutex is unlocked on function exit.
// This prevents deadlocks and ensures proper resource cleanup
if cliClient != nil {
cliClient.RequestMutex.Unlock()
}
}()
// Main client rotation loop with quota management
// This loop implements a sophisticated load balancing and failover mechanism
outLoop:
for {
var errorResponse *client.ErrorMessage
cliClient, errorResponse = h.getClient(modelName)
if errorResponse != nil {
c.Status(errorResponse.StatusCode)
_, _ = fmt.Fprint(c.Writer, errorResponse.Error)
flusher.Flush()
cliCancel()
return
}
// Determine the authentication method being used by the selected client
// This affects how responses are formatted and logged
isGlAPIKey := false
if glAPIKey := cliClient.GetGenerativeLanguageAPIKey(); glAPIKey != "" {
log.Debugf("Request use generative language API Key: %s", glAPIKey)
isGlAPIKey = true
} else {
log.Debugf("Request use account: %s, project id: %s", cliClient.GetEmail(), cliClient.GetProjectID())
}
// Initiate streaming communication with the backend client
// This returns two channels: one for response chunks and one for errors
includeThoughts := false
if userAgent, hasKey := c.Request.Header["User-Agent"]; hasKey {
includeThoughts = !strings.Contains(userAgent[0], "claude-cli")
}
respChan, errChan := cliClient.SendMessageStream(cliCtx, rawJson, modelName, systemInstruction, contents, tools, includeThoughts)
// Track response state for proper Claude format conversion
hasFirstResponse := false
responseType := 0
responseIndex := 0
// Main streaming loop - handles multiple concurrent events using Go channels
// This select statement manages four different types of events simultaneously
for {
select {
// Case 1: Handle client disconnection
// Detects when the HTTP client has disconnected and cleans up resources
case <-c.Request.Context().Done():
if c.Request.Context().Err().Error() == "context canceled" {
log.Debugf("Client disconnected: %v", c.Request.Context().Err())
cliCancel() // Cancel the backend request to prevent resource leaks
return
}
// Case 2: Process incoming response chunks from the backend
// This handles the actual streaming data from the AI model
case chunk, okStream := <-respChan:
if !okStream {
// Stream has ended - send the final message_stop event
// This follows the Claude API specification for stream termination
_, _ = c.Writer.Write([]byte(`event: message_stop`))
_, _ = c.Writer.Write([]byte("\n"))
_, _ = c.Writer.Write([]byte(`data: {"type":"message_stop"}`))
_, _ = c.Writer.Write([]byte("\n\n\n"))
flusher.Flush()
cliCancel()
return
} else {
// Convert the backend response to Claude-compatible format
// This translation layer ensures API compatibility
claudeFormat := translator.ConvertCliToClaude(chunk, isGlAPIKey, hasFirstResponse, &responseType, &responseIndex)
if claudeFormat != "" {
_, _ = c.Writer.Write([]byte(claudeFormat))
flusher.Flush() // Immediately send the chunk to the client
}
hasFirstResponse = true
}
// Case 3: Handle errors from the backend
// This manages various error conditions and implements retry logic
case errInfo, okError := <-errChan:
if okError {
// Special handling for quota exceeded errors
// If configured, attempt to switch to a different project/client
if errInfo.StatusCode == 429 && h.cfg.QuotaExceeded.SwitchProject {
continue outLoop // Restart the client selection process
} else {
// Forward other errors directly to the client
c.Status(errInfo.StatusCode)
_, _ = fmt.Fprint(c.Writer, errInfo.Error.Error())
flusher.Flush()
cliCancel()
}
return
}
// Case 4: Send periodic keep-alive signals
// Prevents connection timeouts during long-running requests
case <-time.After(500 * time.Millisecond):
if hasFirstResponse {
// Send a ping event to maintain the connection
// This is especially important for slow AI model responses
output := "event: ping\n"
output = output + `data: {"type": "ping"}`
output = output + "\n\n\n"
_, _ = c.Writer.Write([]byte(output))
flusher.Flush()
}
}
}
}
}

View File

@@ -1,239 +0,0 @@
package api
import (
"bytes"
"context"
"fmt"
"github.com/gin-gonic/gin"
"github.com/luispater/CLIProxyAPI/internal/client"
"github.com/luispater/CLIProxyAPI/internal/util"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
"io"
"net/http"
"strings"
"time"
)
func (h *APIHandlers) CLIHandler(c *gin.Context) {
if !strings.HasPrefix(c.Request.RemoteAddr, "127.0.0.1:") {
c.JSON(http.StatusForbidden, ErrorResponse{
Error: ErrorDetail{
Message: "CLI reply only allow local access",
Type: "forbidden",
},
})
return
}
rawJson, _ := c.GetRawData()
requestRawURI := c.Request.URL.Path
if requestRawURI == "/v1internal:generateContent" {
h.internalGenerateContent(c, rawJson)
} else if requestRawURI == "/v1internal:streamGenerateContent" {
h.internalStreamGenerateContent(c, rawJson)
} else {
reqBody := bytes.NewBuffer(rawJson)
req, err := http.NewRequest("POST", fmt.Sprintf("https://cloudcode-pa.googleapis.com%s", c.Request.URL.RequestURI()), reqBody)
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{
Error: ErrorDetail{
Message: fmt.Sprintf("Invalid request: %v", err),
Type: "invalid_request_error",
},
})
return
}
for key, value := range c.Request.Header {
req.Header[key] = value
}
httpClient, err := util.SetProxy(h.cfg, &http.Client{})
if err != nil {
log.Fatalf("set proxy failed: %v", err)
}
resp, err := httpClient.Do(req)
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{
Error: ErrorDetail{
Message: fmt.Sprintf("Invalid request: %v", err),
Type: "invalid_request_error",
},
})
return
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
defer func() {
if err = resp.Body.Close(); err != nil {
log.Printf("warn: failed to close response body: %v", err)
}
}()
bodyBytes, _ := io.ReadAll(resp.Body)
c.JSON(http.StatusBadRequest, ErrorResponse{
Error: ErrorDetail{
Message: string(bodyBytes),
Type: "invalid_request_error",
},
})
return
}
defer func() {
_ = resp.Body.Close()
}()
for key, value := range resp.Header {
c.Header(key, value[0])
}
output, err := io.ReadAll(resp.Body)
if err != nil {
log.Errorf("Failed to read response body: %v", err)
return
}
_, _ = c.Writer.Write(output)
}
}
func (h *APIHandlers) internalStreamGenerateContent(c *gin.Context, rawJson []byte) {
// Get the http.Flusher interface to manually flush the response.
flusher, ok := c.Writer.(http.Flusher)
if !ok {
c.JSON(http.StatusInternalServerError, ErrorResponse{
Error: ErrorDetail{
Message: "Streaming not supported",
Type: "server_error",
},
})
return
}
modelResult := gjson.GetBytes(rawJson, "model")
modelName := modelResult.String()
cliCtx, cliCancel := context.WithCancel(context.Background())
var cliClient *client.Client
defer func() {
// Ensure the client's mutex is unlocked on function exit.
if cliClient != nil {
cliClient.RequestMutex.Unlock()
}
}()
outLoop:
for {
var errorResponse *client.ErrorMessage
cliClient, errorResponse = h.getClient(modelName)
if errorResponse != nil {
c.Status(errorResponse.StatusCode)
_, _ = fmt.Fprint(c.Writer, errorResponse.Error)
flusher.Flush()
cliCancel()
return
}
if glAPIKey := cliClient.GetGenerativeLanguageAPIKey(); glAPIKey != "" {
log.Debugf("Request use generative language API Key: %s", glAPIKey)
} else {
log.Debugf("Request use account: %s, project id: %s", cliClient.GetEmail(), cliClient.GetProjectID())
}
// Send the message and receive response chunks and errors via channels.
respChan, errChan := cliClient.SendRawMessageStream(cliCtx, rawJson, "")
hasFirstResponse := false
for {
select {
// Handle client disconnection.
case <-c.Request.Context().Done():
if c.Request.Context().Err().Error() == "context canceled" {
log.Debugf("Client disconnected: %v", c.Request.Context().Err())
cliCancel() // Cancel the backend request.
return
}
// Process incoming response chunks.
case chunk, okStream := <-respChan:
if !okStream {
cliCancel()
return
} else {
hasFirstResponse = true
if cliClient.GetGenerativeLanguageAPIKey() != "" {
chunk, _ = sjson.SetRawBytes(chunk, "response", chunk)
}
_, _ = c.Writer.Write([]byte("data: "))
_, _ = c.Writer.Write(chunk)
_, _ = c.Writer.Write([]byte("\n\n"))
flusher.Flush()
}
// Handle errors from the backend.
case err, okError := <-errChan:
if okError {
if err.StatusCode == 429 && h.cfg.QuotaExceeded.SwitchProject {
continue outLoop
} else {
c.Status(err.StatusCode)
_, _ = fmt.Fprint(c.Writer, err.Error.Error())
flusher.Flush()
cliCancel()
}
return
}
// Send a keep-alive signal to the client.
case <-time.After(500 * time.Millisecond):
if hasFirstResponse {
_, _ = c.Writer.Write([]byte("\n"))
flusher.Flush()
}
}
}
}
}
func (h *APIHandlers) internalGenerateContent(c *gin.Context, rawJson []byte) {
c.Header("Content-Type", "application/json")
modelResult := gjson.GetBytes(rawJson, "model")
modelName := modelResult.String()
cliCtx, cliCancel := context.WithCancel(context.Background())
var cliClient *client.Client
defer func() {
if cliClient != nil {
cliClient.RequestMutex.Unlock()
}
}()
for {
var errorResponse *client.ErrorMessage
cliClient, errorResponse = h.getClient(modelName)
if errorResponse != nil {
c.Status(errorResponse.StatusCode)
_, _ = fmt.Fprint(c.Writer, errorResponse.Error)
cliCancel()
return
}
if glAPIKey := cliClient.GetGenerativeLanguageAPIKey(); glAPIKey != "" {
log.Debugf("Request use generative language API Key: %s", glAPIKey)
} else {
log.Debugf("Request use account: %s, project id: %s", cliClient.GetEmail(), cliClient.GetProjectID())
}
resp, err := cliClient.SendRawMessage(cliCtx, rawJson, "")
if err != nil {
if err.StatusCode == 429 && h.cfg.QuotaExceeded.SwitchProject {
continue
} else {
c.Status(err.StatusCode)
_, _ = c.Writer.Write([]byte(err.Error.Error()))
cliCancel()
}
break
} else {
_, _ = c.Writer.Write(resp)
cliCancel()
break
}
}
}

View File

@@ -1,402 +0,0 @@
package api
import (
"context"
"fmt"
"github.com/gin-gonic/gin"
"github.com/luispater/CLIProxyAPI/internal/api/translator"
"github.com/luispater/CLIProxyAPI/internal/client"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
"net/http"
"strings"
"time"
)
func (h *APIHandlers) GeminiModels(c *gin.Context) {
c.Status(http.StatusOK)
c.Header("Content-Type", "application/json; charset=UTF-8")
_, _ = c.Writer.Write([]byte(`{"models":[{"name":"models/gemini-2.5-flash","version":"001","displayName":"Gemini `))
_, _ = c.Writer.Write([]byte(`2.5 Flash","description":"Stable version of Gemini 2.5 Flash, our mid-size multimod`))
_, _ = c.Writer.Write([]byte(`al model that supports up to 1 million tokens, released in June of 2025.","inputTok`))
_, _ = c.Writer.Write([]byte(`enLimit":1048576,"outputTokenLimit":65536,"supportedGenerationMethods":["generateCo`))
_, _ = c.Writer.Write([]byte(`ntent","countTokens","createCachedContent","batchGenerateContent"],"temperature":1,`))
_, _ = c.Writer.Write([]byte(`"topP":0.95,"topK":64,"maxTemperature":2,"thinking":true},{"name":"models/gemini-2.`))
_, _ = c.Writer.Write([]byte(`5-pro","version":"2.5","displayName":"Gemini 2.5 Pro","description":"Stable release`))
_, _ = c.Writer.Write([]byte(` (June 17th, 2025) of Gemini 2.5 Pro","inputTokenLimit":1048576,"outputTokenLimit":`))
_, _ = c.Writer.Write([]byte(`65536,"supportedGenerationMethods":["generateContent","countTokens","createCachedCo`))
_, _ = c.Writer.Write([]byte(`ntent","batchGenerateContent"],"temperature":1,"topP":0.95,"topK":64,"maxTemperatur`))
_, _ = c.Writer.Write([]byte(`e":2,"thinking":true}],"nextPageToken":""}`))
}
func (h *APIHandlers) GeminiGetHandler(c *gin.Context) {
var request struct {
Action string `uri:"action" binding:"required"`
}
if err := c.ShouldBindUri(&request); err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{
Error: ErrorDetail{
Message: fmt.Sprintf("Invalid request: %v", err),
Type: "invalid_request_error",
},
})
return
}
if request.Action == "gemini-2.5-pro" {
c.Status(http.StatusOK)
c.Header("Content-Type", "application/json; charset=UTF-8")
_, _ = c.Writer.Write([]byte(`{"name":"models/gemini-2.5-pro","version":"2.5","displayName":"Gemini 2.5 Pro",`))
_, _ = c.Writer.Write([]byte(`"description":"Stable release (June 17th, 2025) of Gemini 2.5 Pro","inputTokenL`))
_, _ = c.Writer.Write([]byte(`imit":1048576,"outputTokenLimit":65536,"supportedGenerationMethods":["generateC`))
_, _ = c.Writer.Write([]byte(`ontent","countTokens","createCachedContent","batchGenerateContent"],"temperatur`))
_, _ = c.Writer.Write([]byte(`e":1,"topP":0.95,"topK":64,"maxTemperature":2,"thinking":true}`))
} else if request.Action == "gemini-2.5-flash" {
c.Status(http.StatusOK)
c.Header("Content-Type", "application/json; charset=UTF-8")
_, _ = c.Writer.Write([]byte(`{"name":"models/gemini-2.5-flash","version":"001","displayName":"Gemini 2.5 Fla`))
_, _ = c.Writer.Write([]byte(`sh","description":"Stable version of Gemini 2.5 Flash, our mid-size multimodal `))
_, _ = c.Writer.Write([]byte(`model that supports up to 1 million tokens, released in June of 2025.","inputTo`))
_, _ = c.Writer.Write([]byte(`kenLimit":1048576,"outputTokenLimit":65536,"supportedGenerationMethods":["gener`))
_, _ = c.Writer.Write([]byte(`ateContent","countTokens","createCachedContent","batchGenerateContent"],"temper`))
_, _ = c.Writer.Write([]byte(`ature":1,"topP":0.95,"topK":64,"maxTemperature":2,"thinking":true}`))
} else {
c.Status(http.StatusNotFound)
_, _ = c.Writer.Write([]byte(
`{"error":{"message":"Not Found","code":404,"status":"NOT_FOUND"}}`,
))
}
}
func (h *APIHandlers) GeminiHandler(c *gin.Context) {
var request struct {
Action string `uri:"action" binding:"required"`
}
if err := c.ShouldBindUri(&request); err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{
Error: ErrorDetail{
Message: fmt.Sprintf("Invalid request: %v", err),
Type: "invalid_request_error",
},
})
return
}
action := strings.Split(request.Action, ":")
if len(action) != 2 {
c.JSON(http.StatusNotFound, ErrorResponse{
Error: ErrorDetail{
Message: fmt.Sprintf("%s not found.", c.Request.URL.Path),
Type: "invalid_request_error",
},
})
return
}
modelName := action[0]
method := action[1]
rawJson, _ := c.GetRawData()
rawJson, _ = sjson.SetBytes(rawJson, "model", []byte(modelName))
if method == "generateContent" {
h.geminiGenerateContent(c, rawJson)
} else if method == "streamGenerateContent" {
h.geminiStreamGenerateContent(c, rawJson)
} else if method == "countTokens" {
h.geminiCountTokens(c, rawJson)
}
}
func (h *APIHandlers) geminiStreamGenerateContent(c *gin.Context, rawJson []byte) {
// Get the http.Flusher interface to manually flush the response.
flusher, ok := c.Writer.(http.Flusher)
if !ok {
c.JSON(http.StatusInternalServerError, ErrorResponse{
Error: ErrorDetail{
Message: "Streaming not supported",
Type: "server_error",
},
})
return
}
modelResult := gjson.GetBytes(rawJson, "model")
modelName := modelResult.String()
alt := h.getAlt(c)
cliCtx, cliCancel := context.WithCancel(context.Background())
var cliClient *client.Client
defer func() {
// Ensure the client's mutex is unlocked on function exit.
if cliClient != nil {
cliClient.RequestMutex.Unlock()
}
}()
outLoop:
for {
var errorResponse *client.ErrorMessage
cliClient, errorResponse = h.getClient(modelName)
if errorResponse != nil {
c.Status(errorResponse.StatusCode)
_, _ = fmt.Fprint(c.Writer, errorResponse.Error)
flusher.Flush()
cliCancel()
return
}
template := `{"project":"","request":{},"model":""}`
template, _ = sjson.SetRaw(template, "request", string(rawJson))
template, _ = sjson.Set(template, "model", gjson.Get(template, "request.model").String())
template, _ = sjson.Delete(template, "request.model")
template, errFixCLIToolResponse := translator.FixCLIToolResponse(template)
if errFixCLIToolResponse != nil {
c.JSON(http.StatusInternalServerError, ErrorResponse{
Error: ErrorDetail{
Message: errFixCLIToolResponse.Error(),
Type: "server_error",
},
})
cliCancel()
return
}
systemInstructionResult := gjson.Get(template, "request.system_instruction")
if systemInstructionResult.Exists() {
template, _ = sjson.SetRaw(template, "request.systemInstruction", systemInstructionResult.Raw)
template, _ = sjson.Delete(template, "request.system_instruction")
}
rawJson = []byte(template)
if glAPIKey := cliClient.GetGenerativeLanguageAPIKey(); glAPIKey != "" {
log.Debugf("Request use generative language API Key: %s", glAPIKey)
} else {
log.Debugf("Request use account: %s, project id: %s", cliClient.GetEmail(), cliClient.GetProjectID())
}
// Send the message and receive response chunks and errors via channels.
respChan, errChan := cliClient.SendRawMessageStream(cliCtx, rawJson, alt)
for {
select {
// Handle client disconnection.
case <-c.Request.Context().Done():
if c.Request.Context().Err().Error() == "context canceled" {
log.Debugf("Client disconnected: %v", c.Request.Context().Err())
cliCancel() // Cancel the backend request.
return
}
// Process incoming response chunks.
case chunk, okStream := <-respChan:
if !okStream {
cliCancel()
return
} else {
if cliClient.GetGenerativeLanguageAPIKey() == "" {
if alt == "" {
responseResult := gjson.GetBytes(chunk, "response")
if responseResult.Exists() {
chunk = []byte(responseResult.Raw)
}
} else {
chunkTemplate := "[]"
responseResult := gjson.ParseBytes(chunk)
if responseResult.IsArray() {
responseResultItems := responseResult.Array()
for i := 0; i < len(responseResultItems); i++ {
responseResultItem := responseResultItems[i]
if responseResultItem.Get("response").Exists() {
chunkTemplate, _ = sjson.SetRaw(chunkTemplate, "-1", responseResultItem.Get("response").Raw)
}
}
}
chunk = []byte(chunkTemplate)
}
}
if alt == "" {
_, _ = c.Writer.Write([]byte("data: "))
_, _ = c.Writer.Write(chunk)
_, _ = c.Writer.Write([]byte("\n\n"))
} else {
_, _ = c.Writer.Write(chunk)
}
flusher.Flush()
}
// Handle errors from the backend.
case err, okError := <-errChan:
if okError {
if err.StatusCode == 429 && h.cfg.QuotaExceeded.SwitchProject {
continue outLoop
} else {
c.Status(err.StatusCode)
_, _ = fmt.Fprint(c.Writer, err.Error.Error())
flusher.Flush()
cliCancel()
}
return
}
// Send a keep-alive signal to the client.
case <-time.After(500 * time.Millisecond):
}
}
}
}
func (h *APIHandlers) geminiCountTokens(c *gin.Context, rawJson []byte) {
c.Header("Content-Type", "application/json")
alt := h.getAlt(c)
// orgRawJson := rawJson
modelResult := gjson.GetBytes(rawJson, "model")
modelName := modelResult.String()
cliCtx, cliCancel := context.WithCancel(context.Background())
var cliClient *client.Client
defer func() {
if cliClient != nil {
cliClient.RequestMutex.Unlock()
}
}()
for {
var errorResponse *client.ErrorMessage
cliClient, errorResponse = h.getClient(modelName)
if errorResponse != nil {
c.Status(errorResponse.StatusCode)
_, _ = fmt.Fprint(c.Writer, errorResponse.Error)
cliCancel()
return
}
if glAPIKey := cliClient.GetGenerativeLanguageAPIKey(); glAPIKey != "" {
log.Debugf("Request use generative language API Key: %s", glAPIKey)
} else {
log.Debugf("Request use account: %s, project id: %s", cliClient.GetEmail(), cliClient.GetProjectID())
template := `{"request":{}}`
if gjson.GetBytes(rawJson, "generateContentRequest").Exists() {
template, _ = sjson.SetRaw(template, "request", gjson.GetBytes(rawJson, "generateContentRequest").Raw)
template, _ = sjson.Delete(template, "generateContentRequest")
} else if gjson.GetBytes(rawJson, "contents").Exists() {
template, _ = sjson.SetRaw(template, "request.contents", gjson.GetBytes(rawJson, "contents").Raw)
template, _ = sjson.Delete(template, "contents")
}
rawJson = []byte(template)
}
resp, err := cliClient.SendRawTokenCount(cliCtx, rawJson, alt)
if err != nil {
if err.StatusCode == 429 && h.cfg.QuotaExceeded.SwitchProject {
continue
} else {
c.Status(err.StatusCode)
_, _ = c.Writer.Write([]byte(err.Error.Error()))
cliCancel()
// log.Debugf(err.Error.Error())
// log.Debugf(string(rawJson))
// log.Debugf(string(orgRawJson))
}
break
} else {
if cliClient.GetGenerativeLanguageAPIKey() == "" {
responseResult := gjson.GetBytes(resp, "response")
if responseResult.Exists() {
resp = []byte(responseResult.Raw)
}
}
_, _ = c.Writer.Write(resp)
cliCancel()
break
}
}
}
func (h *APIHandlers) geminiGenerateContent(c *gin.Context, rawJson []byte) {
c.Header("Content-Type", "application/json")
alt := h.getAlt(c)
modelResult := gjson.GetBytes(rawJson, "model")
modelName := modelResult.String()
cliCtx, cliCancel := context.WithCancel(context.Background())
var cliClient *client.Client
defer func() {
if cliClient != nil {
cliClient.RequestMutex.Unlock()
}
}()
for {
var errorResponse *client.ErrorMessage
cliClient, errorResponse = h.getClient(modelName)
if errorResponse != nil {
c.Status(errorResponse.StatusCode)
_, _ = fmt.Fprint(c.Writer, errorResponse.Error)
cliCancel()
return
}
template := `{"project":"","request":{},"model":""}`
template, _ = sjson.SetRaw(template, "request", string(rawJson))
template, _ = sjson.Set(template, "model", gjson.Get(template, "request.model").String())
template, _ = sjson.Delete(template, "request.model")
template, errFixCLIToolResponse := translator.FixCLIToolResponse(template)
if errFixCLIToolResponse != nil {
c.JSON(http.StatusInternalServerError, ErrorResponse{
Error: ErrorDetail{
Message: errFixCLIToolResponse.Error(),
Type: "server_error",
},
})
cliCancel()
return
}
systemInstructionResult := gjson.Get(template, "request.system_instruction")
if systemInstructionResult.Exists() {
template, _ = sjson.SetRaw(template, "request.systemInstruction", systemInstructionResult.Raw)
template, _ = sjson.Delete(template, "request.system_instruction")
}
rawJson = []byte(template)
if glAPIKey := cliClient.GetGenerativeLanguageAPIKey(); glAPIKey != "" {
log.Debugf("Request use generative language API Key: %s", glAPIKey)
} else {
log.Debugf("Request use account: %s, project id: %s", cliClient.GetEmail(), cliClient.GetProjectID())
}
resp, err := cliClient.SendRawMessage(cliCtx, rawJson, alt)
if err != nil {
if err.StatusCode == 429 && h.cfg.QuotaExceeded.SwitchProject {
continue
} else {
c.Status(err.StatusCode)
_, _ = c.Writer.Write([]byte(err.Error.Error()))
cliCancel()
}
break
} else {
if cliClient.GetGenerativeLanguageAPIKey() == "" {
responseResult := gjson.GetBytes(resp, "response")
if responseResult.Exists() {
resp = []byte(responseResult.Raw)
}
}
_, _ = c.Writer.Write(resp)
cliCancel()
break
}
}
}
func (h *APIHandlers) getAlt(c *gin.Context) string {
var alt string
var hasAlt bool
alt, hasAlt = c.GetQuery("alt")
if !hasAlt {
alt, _ = c.GetQuery("$alt")
}
if alt == "sse" {
return ""
}
return alt
}

View File

@@ -1,313 +0,0 @@
package api
import (
"context"
"fmt"
"github.com/luispater/CLIProxyAPI/internal/api/translator"
"github.com/luispater/CLIProxyAPI/internal/client"
"github.com/luispater/CLIProxyAPI/internal/config"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
)
var (
mutex = &sync.Mutex{}
lastUsedClientIndex = 0
)
// APIHandlers contains the handlers for API endpoints.
// It holds a pool of clients to interact with the backend service.
type APIHandlers struct {
cliClients []*client.Client
cfg *config.Config
}
// NewAPIHandlers creates a new API handlers instance.
// It takes a slice of clients and a debug flag as input.
func NewAPIHandlers(cliClients []*client.Client, cfg *config.Config) *APIHandlers {
return &APIHandlers{
cliClients: cliClients,
cfg: cfg,
}
}
// Models handles the /v1/models endpoint.
// It returns a hardcoded list of available AI models.
func (h *APIHandlers) Models(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"data": []map[string]any{
{
"id": "gemini-2.5-pro",
"object": "model",
"version": "2.5",
"name": "Gemini 2.5 Pro",
"description": "Stable release (June 17th, 2025) of Gemini 2.5 Pro",
"context_length": 1048576,
"max_completion_tokens": 65536,
"supported_parameters": []string{
"tools",
"temperature",
"top_p",
"top_k",
},
"temperature": 1,
"topP": 0.95,
"topK": 64,
"maxTemperature": 2,
"thinking": true,
},
{
"id": "gemini-2.5-flash",
"object": "model",
"version": "001",
"name": "Gemini 2.5 Flash",
"description": "Stable version of Gemini 2.5 Flash, our mid-size multimodal model that supports up to 1 million tokens, released in June of 2025.",
"context_length": 1048576,
"max_completion_tokens": 65536,
"supported_parameters": []string{
"tools",
"temperature",
"top_p",
"top_k",
},
"temperature": 1,
"topP": 0.95,
"topK": 64,
"maxTemperature": 2,
"thinking": true,
},
},
})
}
func (h *APIHandlers) getClient(modelName string) (*client.Client, *client.ErrorMessage) {
if len(h.cliClients) == 0 {
return nil, &client.ErrorMessage{StatusCode: 500, Error: fmt.Errorf("no clients available")}
}
var cliClient *client.Client
// Lock the mutex to update the last used client index
mutex.Lock()
startIndex := lastUsedClientIndex
currentIndex := (startIndex + 1) % len(h.cliClients)
lastUsedClientIndex = currentIndex
mutex.Unlock()
// Reorder the client to start from the last used index
reorderedClients := make([]*client.Client, 0)
for i := 0; i < len(h.cliClients); i++ {
cliClient = h.cliClients[(startIndex+1+i)%len(h.cliClients)]
if cliClient.IsModelQuotaExceeded(modelName) {
log.Debugf("Model %s is quota exceeded for account %s, project id: %s", modelName, cliClient.GetEmail(), cliClient.GetProjectID())
cliClient = nil
continue
}
reorderedClients = append(reorderedClients, cliClient)
}
if len(reorderedClients) == 0 {
return nil, &client.ErrorMessage{StatusCode: 429, Error: fmt.Errorf(`{"error":{"code":429,"message":"All the models of '%s' are quota exceeded","status":"RESOURCE_EXHAUSTED"}}`, modelName)}
}
locked := false
for i := 0; i < len(reorderedClients); i++ {
cliClient = reorderedClients[i]
if cliClient.RequestMutex.TryLock() {
locked = true
break
}
}
if !locked {
cliClient = h.cliClients[0]
cliClient.RequestMutex.Lock()
}
return cliClient, nil
}
// ChatCompletions handles the /v1/chat/completions endpoint.
// It determines whether the request is for a streaming or non-streaming response
// and calls the appropriate handler.
func (h *APIHandlers) ChatCompletions(c *gin.Context) {
rawJson, err := c.GetRawData()
// If data retrieval fails, return a 400 Bad Request error.
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{
Error: ErrorDetail{
Message: fmt.Sprintf("Invalid request: %v", err),
Type: "invalid_request_error",
},
})
return
}
// Check if the client requested a streaming response.
streamResult := gjson.GetBytes(rawJson, "stream")
if streamResult.Type == gjson.True {
h.handleStreamingResponse(c, rawJson)
} else {
h.handleNonStreamingResponse(c, rawJson)
}
}
// handleNonStreamingResponse handles non-streaming chat completion responses.
// It selects a client from the pool, sends the request, and aggregates the response
// before sending it back to the client.
func (h *APIHandlers) handleNonStreamingResponse(c *gin.Context, rawJson []byte) {
c.Header("Content-Type", "application/json")
modelName, systemInstruction, contents, tools := translator.PrepareRequest(rawJson)
cliCtx, cliCancel := context.WithCancel(context.Background())
var cliClient *client.Client
defer func() {
if cliClient != nil {
cliClient.RequestMutex.Unlock()
}
}()
for {
var errorResponse *client.ErrorMessage
cliClient, errorResponse = h.getClient(modelName)
if errorResponse != nil {
c.Status(errorResponse.StatusCode)
_, _ = fmt.Fprint(c.Writer, errorResponse.Error)
cliCancel()
return
}
isGlAPIKey := false
if glAPIKey := cliClient.GetGenerativeLanguageAPIKey(); glAPIKey != "" {
log.Debugf("Request use generative language API Key: %s", glAPIKey)
isGlAPIKey = true
} else {
log.Debugf("Request use account: %s, project id: %s", cliClient.GetEmail(), cliClient.GetProjectID())
}
resp, err := cliClient.SendMessage(cliCtx, rawJson, modelName, systemInstruction, contents, tools)
if err != nil {
if err.StatusCode == 429 && h.cfg.QuotaExceeded.SwitchProject {
continue
} else {
c.Status(err.StatusCode)
_, _ = c.Writer.Write([]byte(err.Error.Error()))
cliCancel()
}
break
} else {
openAIFormat := translator.ConvertCliToOpenAINonStream(resp, time.Now().Unix(), isGlAPIKey)
if openAIFormat != "" {
_, _ = c.Writer.Write([]byte(openAIFormat))
}
cliCancel()
break
}
}
}
// handleStreamingResponse handles streaming responses
func (h *APIHandlers) handleStreamingResponse(c *gin.Context, rawJson []byte) {
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("Access-Control-Allow-Origin", "*")
// Get the http.Flusher interface to manually flush the response.
flusher, ok := c.Writer.(http.Flusher)
if !ok {
c.JSON(http.StatusInternalServerError, ErrorResponse{
Error: ErrorDetail{
Message: "Streaming not supported",
Type: "server_error",
},
})
return
}
// Prepare the request for the backend client.
modelName, systemInstruction, contents, tools := translator.PrepareRequest(rawJson)
cliCtx, cliCancel := context.WithCancel(context.Background())
var cliClient *client.Client
defer func() {
// Ensure the client's mutex is unlocked on function exit.
if cliClient != nil {
cliClient.RequestMutex.Unlock()
}
}()
outLoop:
for {
var errorResponse *client.ErrorMessage
cliClient, errorResponse = h.getClient(modelName)
if errorResponse != nil {
c.Status(errorResponse.StatusCode)
_, _ = fmt.Fprint(c.Writer, errorResponse.Error)
flusher.Flush()
cliCancel()
return
}
isGlAPIKey := false
if glAPIKey := cliClient.GetGenerativeLanguageAPIKey(); glAPIKey != "" {
log.Debugf("Request use generative language API Key: %s", glAPIKey)
isGlAPIKey = true
} else {
log.Debugf("Request use account: %s, project id: %s", cliClient.GetEmail(), cliClient.GetProjectID())
}
// Send the message and receive response chunks and errors via channels.
respChan, errChan := cliClient.SendMessageStream(cliCtx, rawJson, modelName, systemInstruction, contents, tools)
hasFirstResponse := false
for {
select {
// Handle client disconnection.
case <-c.Request.Context().Done():
if c.Request.Context().Err().Error() == "context canceled" {
log.Debugf("Client disconnected: %v", c.Request.Context().Err())
cliCancel() // Cancel the backend request.
return
}
// Process incoming response chunks.
case chunk, okStream := <-respChan:
if !okStream {
// Stream is closed, send the final [DONE] message.
_, _ = fmt.Fprintf(c.Writer, "data: [DONE]\n\n")
flusher.Flush()
cliCancel()
return
} else {
// Convert the chunk to OpenAI format and send it to the client.
hasFirstResponse = true
openAIFormat := translator.ConvertCliToOpenAI(chunk, time.Now().Unix(), isGlAPIKey)
if openAIFormat != "" {
_, _ = fmt.Fprintf(c.Writer, "data: %s\n\n", openAIFormat)
flusher.Flush()
}
}
// Handle errors from the backend.
case err, okError := <-errChan:
if okError {
if err.StatusCode == 429 && h.cfg.QuotaExceeded.SwitchProject {
continue outLoop
} else {
c.Status(err.StatusCode)
_, _ = fmt.Fprint(c.Writer, err.Error.Error())
flusher.Flush()
cliCancel()
}
return
}
// Send a keep-alive signal to the client.
case <-time.After(500 * time.Millisecond):
if hasFirstResponse {
_, _ = c.Writer.Write([]byte(": CLI-PROXY-API PROCESSING\n\n"))
flusher.Flush()
}
}
}
}
}

View File

@@ -0,0 +1,974 @@
package management
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/claude"
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex"
geminiAuth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/gemini"
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/qwen"
// legacy client removed
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
)
var (
oauthStatus = make(map[string]string)
)
var lastRefreshKeys = []string{"last_refresh", "lastRefresh", "last_refreshed_at", "lastRefreshedAt"}
func extractLastRefreshTimestamp(meta map[string]any) (time.Time, bool) {
if len(meta) == 0 {
return time.Time{}, false
}
for _, key := range lastRefreshKeys {
if val, ok := meta[key]; ok {
if ts, ok1 := parseLastRefreshValue(val); ok1 {
return ts, true
}
}
}
return time.Time{}, false
}
func parseLastRefreshValue(v any) (time.Time, bool) {
switch val := v.(type) {
case string:
s := strings.TrimSpace(val)
if s == "" {
return time.Time{}, false
}
layouts := []string{time.RFC3339, time.RFC3339Nano, "2006-01-02 15:04:05", "2006-01-02T15:04:05Z07:00"}
for _, layout := range layouts {
if ts, err := time.Parse(layout, s); err == nil {
return ts.UTC(), true
}
}
if unix, err := strconv.ParseInt(s, 10, 64); err == nil && unix > 0 {
return time.Unix(unix, 0).UTC(), true
}
case float64:
if val <= 0 {
return time.Time{}, false
}
return time.Unix(int64(val), 0).UTC(), true
case int64:
if val <= 0 {
return time.Time{}, false
}
return time.Unix(val, 0).UTC(), true
case int:
if val <= 0 {
return time.Time{}, false
}
return time.Unix(int64(val), 0).UTC(), true
case json.Number:
if i, err := val.Int64(); err == nil && i > 0 {
return time.Unix(i, 0).UTC(), true
}
}
return time.Time{}, false
}
// List auth files
func (h *Handler) ListAuthFiles(c *gin.Context) {
entries, err := os.ReadDir(h.cfg.AuthDir)
if err != nil {
c.JSON(500, gin.H{"error": fmt.Sprintf("failed to read auth dir: %v", err)})
return
}
files := make([]gin.H, 0)
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if !strings.HasSuffix(strings.ToLower(name), ".json") {
continue
}
if info, errInfo := e.Info(); errInfo == nil {
fileData := gin.H{"name": name, "size": info.Size(), "modtime": info.ModTime()}
// Read file to get type field
full := filepath.Join(h.cfg.AuthDir, name)
if data, errRead := os.ReadFile(full); errRead == nil {
typeValue := gjson.GetBytes(data, "type").String()
fileData["type"] = typeValue
}
files = append(files, fileData)
}
}
c.JSON(200, gin.H{"files": files})
}
// Download single auth file by name
func (h *Handler) DownloadAuthFile(c *gin.Context) {
name := c.Query("name")
if name == "" || strings.Contains(name, string(os.PathSeparator)) {
c.JSON(400, gin.H{"error": "invalid name"})
return
}
if !strings.HasSuffix(strings.ToLower(name), ".json") {
c.JSON(400, gin.H{"error": "name must end with .json"})
return
}
full := filepath.Join(h.cfg.AuthDir, name)
data, err := os.ReadFile(full)
if err != nil {
if os.IsNotExist(err) {
c.JSON(404, gin.H{"error": "file not found"})
} else {
c.JSON(500, gin.H{"error": fmt.Sprintf("failed to read file: %v", err)})
}
return
}
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", name))
c.Data(200, "application/json", data)
}
// Upload auth file: multipart or raw JSON with ?name=
func (h *Handler) UploadAuthFile(c *gin.Context) {
if h.authManager == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "core auth manager unavailable"})
return
}
ctx := c.Request.Context()
if file, err := c.FormFile("file"); err == nil && file != nil {
name := filepath.Base(file.Filename)
if !strings.HasSuffix(strings.ToLower(name), ".json") {
c.JSON(400, gin.H{"error": "file must be .json"})
return
}
dst := filepath.Join(h.cfg.AuthDir, name)
if !filepath.IsAbs(dst) {
if abs, errAbs := filepath.Abs(dst); errAbs == nil {
dst = abs
}
}
if errSave := c.SaveUploadedFile(file, dst); errSave != nil {
c.JSON(500, gin.H{"error": fmt.Sprintf("failed to save file: %v", errSave)})
return
}
data, errRead := os.ReadFile(dst)
if errRead != nil {
c.JSON(500, gin.H{"error": fmt.Sprintf("failed to read saved file: %v", errRead)})
return
}
if errReg := h.registerAuthFromFile(ctx, dst, data); errReg != nil {
c.JSON(500, gin.H{"error": errReg.Error()})
return
}
c.JSON(200, gin.H{"status": "ok"})
return
}
name := c.Query("name")
if name == "" || strings.Contains(name, string(os.PathSeparator)) {
c.JSON(400, gin.H{"error": "invalid name"})
return
}
if !strings.HasSuffix(strings.ToLower(name), ".json") {
c.JSON(400, gin.H{"error": "name must end with .json"})
return
}
data, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(400, gin.H{"error": "failed to read body"})
return
}
dst := filepath.Join(h.cfg.AuthDir, filepath.Base(name))
if !filepath.IsAbs(dst) {
if abs, errAbs := filepath.Abs(dst); errAbs == nil {
dst = abs
}
}
if errWrite := os.WriteFile(dst, data, 0o600); errWrite != nil {
c.JSON(500, gin.H{"error": fmt.Sprintf("failed to write file: %v", errWrite)})
return
}
if err = h.registerAuthFromFile(ctx, dst, data); err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
c.JSON(200, gin.H{"status": "ok"})
}
// Delete auth files: single by name or all
func (h *Handler) DeleteAuthFile(c *gin.Context) {
if h.authManager == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "core auth manager unavailable"})
return
}
ctx := c.Request.Context()
if all := c.Query("all"); all == "true" || all == "1" || all == "*" {
entries, err := os.ReadDir(h.cfg.AuthDir)
if err != nil {
c.JSON(500, gin.H{"error": fmt.Sprintf("failed to read auth dir: %v", err)})
return
}
deleted := 0
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if !strings.HasSuffix(strings.ToLower(name), ".json") {
continue
}
full := filepath.Join(h.cfg.AuthDir, name)
if !filepath.IsAbs(full) {
if abs, errAbs := filepath.Abs(full); errAbs == nil {
full = abs
}
}
if err = os.Remove(full); err == nil {
deleted++
h.disableAuth(ctx, full)
}
}
c.JSON(200, gin.H{"status": "ok", "deleted": deleted})
return
}
name := c.Query("name")
if name == "" || strings.Contains(name, string(os.PathSeparator)) {
c.JSON(400, gin.H{"error": "invalid name"})
return
}
full := filepath.Join(h.cfg.AuthDir, filepath.Base(name))
if !filepath.IsAbs(full) {
if abs, errAbs := filepath.Abs(full); errAbs == nil {
full = abs
}
}
if err := os.Remove(full); err != nil {
if os.IsNotExist(err) {
c.JSON(404, gin.H{"error": "file not found"})
} else {
c.JSON(500, gin.H{"error": fmt.Sprintf("failed to remove file: %v", err)})
}
return
}
h.disableAuth(ctx, full)
c.JSON(200, gin.H{"status": "ok"})
}
func (h *Handler) registerAuthFromFile(ctx context.Context, path string, data []byte) error {
if h.authManager == nil {
return nil
}
if path == "" {
return fmt.Errorf("auth path is empty")
}
if data == nil {
var err error
data, err = os.ReadFile(path)
if err != nil {
return fmt.Errorf("failed to read auth file: %w", err)
}
}
metadata := make(map[string]any)
if err := json.Unmarshal(data, &metadata); err != nil {
return fmt.Errorf("invalid auth file: %w", err)
}
provider, _ := metadata["type"].(string)
if provider == "" {
provider = "unknown"
}
label := provider
if email, ok := metadata["email"].(string); ok && email != "" {
label = email
}
lastRefresh, hasLastRefresh := extractLastRefreshTimestamp(metadata)
attr := map[string]string{
"path": path,
"source": path,
}
auth := &coreauth.Auth{
ID: path,
Provider: provider,
Label: label,
Status: coreauth.StatusActive,
Attributes: attr,
Metadata: metadata,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if hasLastRefresh {
auth.LastRefreshedAt = lastRefresh
}
if existing, ok := h.authManager.GetByID(path); ok {
auth.CreatedAt = existing.CreatedAt
if !hasLastRefresh {
auth.LastRefreshedAt = existing.LastRefreshedAt
}
auth.NextRefreshAfter = existing.NextRefreshAfter
auth.Runtime = existing.Runtime
_, err := h.authManager.Update(ctx, auth)
return err
}
_, err := h.authManager.Register(ctx, auth)
return err
}
func (h *Handler) disableAuth(ctx context.Context, id string) {
if h.authManager == nil || id == "" {
return
}
if auth, ok := h.authManager.GetByID(id); ok {
auth.Disabled = true
auth.Status = coreauth.StatusDisabled
auth.StatusMessage = "removed via management API"
auth.UpdatedAt = time.Now()
_, _ = h.authManager.Update(ctx, auth)
}
}
func (h *Handler) saveTokenRecord(ctx context.Context, record *coreauth.Auth) (string, error) {
if record == nil {
return "", fmt.Errorf("token record is nil")
}
store := h.tokenStore
if store == nil {
store = sdkAuth.GetTokenStore()
h.tokenStore = store
}
if h.cfg != nil {
if dirSetter, ok := store.(interface{ SetBaseDir(string) }); ok {
dirSetter.SetBaseDir(h.cfg.AuthDir)
}
}
return store.Save(ctx, record)
}
func (h *Handler) RequestAnthropicToken(c *gin.Context) {
ctx := context.Background()
fmt.Println("Initializing Claude authentication...")
// Generate PKCE codes
pkceCodes, err := claude.GeneratePKCECodes()
if err != nil {
log.Fatalf("Failed to generate PKCE codes: %v", err)
return
}
// Generate random state parameter
state, err := misc.GenerateRandomState()
if err != nil {
log.Fatalf("Failed to generate state parameter: %v", err)
return
}
// Initialize Claude auth service
anthropicAuth := claude.NewClaudeAuth(h.cfg)
// Generate authorization URL (then override redirect_uri to reuse server port)
authURL, state, err := anthropicAuth.GenerateAuthURL(state, pkceCodes)
if err != nil {
log.Fatalf("Failed to generate authorization URL: %v", err)
return
}
// Override redirect_uri in authorization URL to current server port
go func() {
// Helper: wait for callback file
waitFile := filepath.Join(h.cfg.AuthDir, fmt.Sprintf(".oauth-anthropic-%s.oauth", state))
waitForFile := func(path string, timeout time.Duration) (map[string]string, error) {
deadline := time.Now().Add(timeout)
for {
if time.Now().After(deadline) {
oauthStatus[state] = "Timeout waiting for OAuth callback"
return nil, fmt.Errorf("timeout waiting for OAuth callback")
}
data, errRead := os.ReadFile(path)
if errRead == nil {
var m map[string]string
_ = json.Unmarshal(data, &m)
_ = os.Remove(path)
return m, nil
}
time.Sleep(500 * time.Millisecond)
}
}
fmt.Println("Waiting for authentication callback...")
// Wait up to 5 minutes
resultMap, errWait := waitForFile(waitFile, 5*time.Minute)
if errWait != nil {
authErr := claude.NewAuthenticationError(claude.ErrCallbackTimeout, errWait)
log.Error(claude.GetUserFriendlyMessage(authErr))
return
}
if errStr := resultMap["error"]; errStr != "" {
oauthErr := claude.NewOAuthError(errStr, "", http.StatusBadRequest)
log.Error(claude.GetUserFriendlyMessage(oauthErr))
oauthStatus[state] = "Bad request"
return
}
if resultMap["state"] != state {
authErr := claude.NewAuthenticationError(claude.ErrInvalidState, fmt.Errorf("expected %s, got %s", state, resultMap["state"]))
log.Error(claude.GetUserFriendlyMessage(authErr))
oauthStatus[state] = "State code error"
return
}
// Parse code (Claude may append state after '#')
rawCode := resultMap["code"]
code := strings.Split(rawCode, "#")[0]
// Exchange code for tokens (replicate logic using updated redirect_uri)
// Extract client_id from the modified auth URL
clientID := ""
if u2, errP := url.Parse(authURL); errP == nil {
clientID = u2.Query().Get("client_id")
}
// Build request
bodyMap := map[string]any{
"code": code,
"state": state,
"grant_type": "authorization_code",
"client_id": clientID,
"redirect_uri": "http://localhost:54545/callback",
"code_verifier": pkceCodes.CodeVerifier,
}
bodyJSON, _ := json.Marshal(bodyMap)
httpClient := util.SetProxy(&h.cfg.SDKConfig, &http.Client{})
req, _ := http.NewRequestWithContext(ctx, "POST", "https://console.anthropic.com/v1/oauth/token", strings.NewReader(string(bodyJSON)))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, errDo := httpClient.Do(req)
if errDo != nil {
authErr := claude.NewAuthenticationError(claude.ErrCodeExchangeFailed, errDo)
log.Errorf("Failed to exchange authorization code for tokens: %v", authErr)
oauthStatus[state] = "Failed to exchange authorization code for tokens"
return
}
defer func() {
if errClose := resp.Body.Close(); errClose != nil {
log.Errorf("failed to close response body: %v", errClose)
}
}()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
log.Errorf("token exchange failed with status %d: %s", resp.StatusCode, string(respBody))
oauthStatus[state] = fmt.Sprintf("token exchange failed with status %d", resp.StatusCode)
return
}
var tResp struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
Account struct {
EmailAddress string `json:"email_address"`
} `json:"account"`
}
if errU := json.Unmarshal(respBody, &tResp); errU != nil {
log.Errorf("failed to parse token response: %v", errU)
oauthStatus[state] = "Failed to parse token response"
return
}
bundle := &claude.ClaudeAuthBundle{
TokenData: claude.ClaudeTokenData{
AccessToken: tResp.AccessToken,
RefreshToken: tResp.RefreshToken,
Email: tResp.Account.EmailAddress,
Expire: time.Now().Add(time.Duration(tResp.ExpiresIn) * time.Second).Format(time.RFC3339),
},
LastRefresh: time.Now().Format(time.RFC3339),
}
// Create token storage
tokenStorage := anthropicAuth.CreateTokenStorage(bundle)
record := &coreauth.Auth{
ID: fmt.Sprintf("claude-%s.json", tokenStorage.Email),
Provider: "claude",
FileName: fmt.Sprintf("claude-%s.json", tokenStorage.Email),
Storage: tokenStorage,
Metadata: map[string]any{"email": tokenStorage.Email},
}
savedPath, errSave := h.saveTokenRecord(ctx, record)
if errSave != nil {
log.Fatalf("Failed to save authentication tokens: %v", errSave)
oauthStatus[state] = "Failed to save authentication tokens"
return
}
fmt.Printf("Authentication successful! Token saved to %s\n", savedPath)
if bundle.APIKey != "" {
fmt.Println("API key obtained and saved")
}
fmt.Println("You can now use Claude services through this CLI")
delete(oauthStatus, state)
}()
oauthStatus[state] = ""
c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state})
}
func (h *Handler) RequestGeminiCLIToken(c *gin.Context) {
ctx := context.Background()
// Optional project ID from query
projectID := c.Query("project_id")
fmt.Println("Initializing Google authentication...")
// OAuth2 configuration (mirrors internal/auth/gemini)
conf := &oauth2.Config{
ClientID: "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com",
ClientSecret: "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",
RedirectURL: "http://localhost:8085/oauth2callback",
Scopes: []string{
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
},
Endpoint: google.Endpoint,
}
// Build authorization URL and return it immediately
state := fmt.Sprintf("gem-%d", time.Now().UnixNano())
authURL := conf.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("prompt", "consent"))
go func() {
// Wait for callback file written by server route
waitFile := filepath.Join(h.cfg.AuthDir, fmt.Sprintf(".oauth-gemini-%s.oauth", state))
fmt.Println("Waiting for authentication callback...")
deadline := time.Now().Add(5 * time.Minute)
var authCode string
for {
if time.Now().After(deadline) {
log.Error("oauth flow timed out")
oauthStatus[state] = "OAuth flow timed out"
return
}
if data, errR := os.ReadFile(waitFile); errR == nil {
var m map[string]string
_ = json.Unmarshal(data, &m)
_ = os.Remove(waitFile)
if errStr := m["error"]; errStr != "" {
log.Errorf("Authentication failed: %s", errStr)
oauthStatus[state] = "Authentication failed"
return
}
authCode = m["code"]
if authCode == "" {
log.Errorf("Authentication failed: code not found")
oauthStatus[state] = "Authentication failed: code not found"
return
}
break
}
time.Sleep(500 * time.Millisecond)
}
// Exchange authorization code for token
token, err := conf.Exchange(ctx, authCode)
if err != nil {
log.Errorf("Failed to exchange token: %v", err)
oauthStatus[state] = "Failed to exchange token"
return
}
// Create token storage (mirrors internal/auth/gemini createTokenStorage)
httpClient := conf.Client(ctx, token)
req, errNewRequest := http.NewRequestWithContext(ctx, "GET", "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", nil)
if errNewRequest != nil {
log.Errorf("Could not get user info: %v", errNewRequest)
oauthStatus[state] = "Could not get user info"
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken))
resp, errDo := httpClient.Do(req)
if errDo != nil {
log.Errorf("Failed to execute request: %v", errDo)
oauthStatus[state] = "Failed to execute request"
return
}
defer func() {
if errClose := resp.Body.Close(); errClose != nil {
log.Printf("warn: failed to close response body: %v", errClose)
}
}()
bodyBytes, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
log.Errorf("Get user info request failed with status %d: %s", resp.StatusCode, string(bodyBytes))
oauthStatus[state] = fmt.Sprintf("Get user info request failed with status %d", resp.StatusCode)
return
}
email := gjson.GetBytes(bodyBytes, "email").String()
if email != "" {
fmt.Printf("Authenticated user email: %s\n", email)
} else {
fmt.Println("Failed to get user email from token")
oauthStatus[state] = "Failed to get user email from token"
}
// Marshal/unmarshal oauth2.Token to generic map and enrich fields
var ifToken map[string]any
jsonData, _ := json.Marshal(token)
if errUnmarshal := json.Unmarshal(jsonData, &ifToken); errUnmarshal != nil {
log.Errorf("Failed to unmarshal token: %v", errUnmarshal)
oauthStatus[state] = "Failed to unmarshal token"
return
}
ifToken["token_uri"] = "https://oauth2.googleapis.com/token"
ifToken["client_id"] = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com"
ifToken["client_secret"] = "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"
ifToken["scopes"] = []string{
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
}
ifToken["universe_domain"] = "googleapis.com"
ts := geminiAuth.GeminiTokenStorage{
Token: ifToken,
ProjectID: projectID,
Email: email,
}
// Initialize authenticated HTTP client via GeminiAuth to honor proxy settings
gemAuth := geminiAuth.NewGeminiAuth()
_, errGetClient := gemAuth.GetAuthenticatedClient(ctx, &ts, h.cfg, true)
if errGetClient != nil {
log.Fatalf("failed to get authenticated client: %v", errGetClient)
oauthStatus[state] = "Failed to get authenticated client"
return
}
fmt.Println("Authentication successful.")
record := &coreauth.Auth{
ID: fmt.Sprintf("gemini-%s.json", ts.Email),
Provider: "gemini",
FileName: fmt.Sprintf("gemini-%s.json", ts.Email),
Storage: &ts,
Metadata: map[string]any{
"email": ts.Email,
"project_id": ts.ProjectID,
},
}
savedPath, errSave := h.saveTokenRecord(ctx, record)
if errSave != nil {
log.Fatalf("Failed to save token to file: %v", errSave)
oauthStatus[state] = "Failed to save token to file"
return
}
delete(oauthStatus, state)
fmt.Printf("You can now use Gemini CLI services through this CLI; token saved to %s\n", savedPath)
}()
oauthStatus[state] = ""
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()
fmt.Println("Initializing Codex authentication...")
// Generate PKCE codes
pkceCodes, err := codex.GeneratePKCECodes()
if err != nil {
log.Fatalf("Failed to generate PKCE codes: %v", err)
return
}
// Generate random state parameter
state, err := misc.GenerateRandomState()
if err != nil {
log.Fatalf("Failed to generate state parameter: %v", err)
return
}
// Initialize Codex auth service
openaiAuth := codex.NewCodexAuth(h.cfg)
// Generate authorization URL
authURL, err := openaiAuth.GenerateAuthURL(state, pkceCodes)
if err != nil {
log.Fatalf("Failed to generate authorization URL: %v", err)
return
}
go func() {
// Wait for callback file
waitFile := filepath.Join(h.cfg.AuthDir, fmt.Sprintf(".oauth-codex-%s.oauth", state))
deadline := time.Now().Add(5 * time.Minute)
var code string
for {
if time.Now().After(deadline) {
authErr := codex.NewAuthenticationError(codex.ErrCallbackTimeout, fmt.Errorf("timeout waiting for OAuth callback"))
log.Error(codex.GetUserFriendlyMessage(authErr))
oauthStatus[state] = "Timeout waiting for OAuth callback"
return
}
if data, errR := os.ReadFile(waitFile); errR == nil {
var m map[string]string
_ = json.Unmarshal(data, &m)
_ = os.Remove(waitFile)
if errStr := m["error"]; errStr != "" {
oauthErr := codex.NewOAuthError(errStr, "", http.StatusBadRequest)
log.Error(codex.GetUserFriendlyMessage(oauthErr))
oauthStatus[state] = "Bad Request"
return
}
if m["state"] != state {
authErr := codex.NewAuthenticationError(codex.ErrInvalidState, fmt.Errorf("expected %s, got %s", state, m["state"]))
oauthStatus[state] = "State code error"
log.Error(codex.GetUserFriendlyMessage(authErr))
return
}
code = m["code"]
break
}
time.Sleep(500 * time.Millisecond)
}
log.Debug("Authorization code received, exchanging for tokens...")
// Extract client_id from authURL
clientID := ""
if u2, errP := url.Parse(authURL); errP == nil {
clientID = u2.Query().Get("client_id")
}
// Exchange code for tokens with redirect equal to mgmtRedirect
form := url.Values{
"grant_type": {"authorization_code"},
"client_id": {clientID},
"code": {code},
"redirect_uri": {"http://localhost:1455/auth/callback"},
"code_verifier": {pkceCodes.CodeVerifier},
}
httpClient := util.SetProxy(&h.cfg.SDKConfig, &http.Client{})
req, _ := http.NewRequestWithContext(ctx, "POST", "https://auth.openai.com/oauth/token", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, errDo := httpClient.Do(req)
if errDo != nil {
authErr := codex.NewAuthenticationError(codex.ErrCodeExchangeFailed, errDo)
oauthStatus[state] = "Failed to exchange authorization code for tokens"
log.Errorf("Failed to exchange authorization code for tokens: %v", authErr)
return
}
defer func() { _ = resp.Body.Close() }()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
oauthStatus[state] = fmt.Sprintf("Token exchange failed with status %d", resp.StatusCode)
log.Errorf("token exchange failed with status %d: %s", resp.StatusCode, string(respBody))
return
}
var tokenResp struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
IDToken string `json:"id_token"`
ExpiresIn int `json:"expires_in"`
}
if errU := json.Unmarshal(respBody, &tokenResp); errU != nil {
oauthStatus[state] = "Failed to parse token response"
log.Errorf("failed to parse token response: %v", errU)
return
}
claims, _ := codex.ParseJWTToken(tokenResp.IDToken)
email := ""
accountID := ""
if claims != nil {
email = claims.GetUserEmail()
accountID = claims.GetAccountID()
}
// Build bundle compatible with existing storage
bundle := &codex.CodexAuthBundle{
TokenData: codex.CodexTokenData{
IDToken: tokenResp.IDToken,
AccessToken: tokenResp.AccessToken,
RefreshToken: tokenResp.RefreshToken,
AccountID: accountID,
Email: email,
Expire: time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339),
},
LastRefresh: time.Now().Format(time.RFC3339),
}
// Create token storage and persist
tokenStorage := openaiAuth.CreateTokenStorage(bundle)
record := &coreauth.Auth{
ID: fmt.Sprintf("codex-%s.json", tokenStorage.Email),
Provider: "codex",
FileName: fmt.Sprintf("codex-%s.json", tokenStorage.Email),
Storage: tokenStorage,
Metadata: map[string]any{
"email": tokenStorage.Email,
"account_id": tokenStorage.AccountID,
},
}
savedPath, errSave := h.saveTokenRecord(ctx, record)
if errSave != nil {
oauthStatus[state] = "Failed to save authentication tokens"
log.Fatalf("Failed to save authentication tokens: %v", errSave)
return
}
fmt.Printf("Authentication successful! Token saved to %s\n", savedPath)
if bundle.APIKey != "" {
fmt.Println("API key obtained and saved")
}
fmt.Println("You can now use Codex services through this CLI")
delete(oauthStatus, state)
}()
oauthStatus[state] = ""
c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state})
}
func (h *Handler) RequestQwenToken(c *gin.Context) {
ctx := context.Background()
fmt.Println("Initializing Qwen authentication...")
state := fmt.Sprintf("gem-%d", time.Now().UnixNano())
// Initialize Qwen auth service
qwenAuth := qwen.NewQwenAuth(h.cfg)
// Generate authorization URL
deviceFlow, err := qwenAuth.InitiateDeviceFlow(ctx)
if err != nil {
log.Fatalf("Failed to generate authorization URL: %v", err)
return
}
authURL := deviceFlow.VerificationURIComplete
go func() {
fmt.Println("Waiting for authentication...")
tokenData, errPollForToken := qwenAuth.PollForToken(deviceFlow.DeviceCode, deviceFlow.CodeVerifier)
if errPollForToken != nil {
oauthStatus[state] = "Authentication failed"
fmt.Printf("Authentication failed: %v\n", errPollForToken)
return
}
// Create token storage
tokenStorage := qwenAuth.CreateTokenStorage(tokenData)
tokenStorage.Email = fmt.Sprintf("qwen-%d", time.Now().UnixMilli())
record := &coreauth.Auth{
ID: fmt.Sprintf("qwen-%s.json", tokenStorage.Email),
Provider: "qwen",
FileName: fmt.Sprintf("qwen-%s.json", tokenStorage.Email),
Storage: tokenStorage,
Metadata: map[string]any{"email": tokenStorage.Email},
}
savedPath, errSave := h.saveTokenRecord(ctx, record)
if errSave != nil {
log.Fatalf("Failed to save authentication tokens: %v", errSave)
oauthStatus[state] = "Failed to save authentication tokens"
return
}
fmt.Printf("Authentication successful! Token saved to %s\n", savedPath)
fmt.Println("You can now use Qwen services through this CLI")
delete(oauthStatus, state)
}()
oauthStatus[state] = ""
c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state})
}
func (h *Handler) GetAuthStatus(c *gin.Context) {
state := c.Query("state")
if err, ok := oauthStatus[state]; ok {
if err != "" {
c.JSON(200, gin.H{"status": "error", "error": err})
} else {
c.JSON(200, gin.H{"status": "wait"})
return
}
} else {
c.JSON(200, gin.H{"status": "ok"})
}
delete(oauthStatus, state)
}

View File

@@ -0,0 +1,53 @@
package management
import (
"github.com/gin-gonic/gin"
)
func (h *Handler) GetConfig(c *gin.Context) {
c.JSON(200, h.cfg)
}
// Debug
func (h *Handler) GetDebug(c *gin.Context) { c.JSON(200, gin.H{"debug": h.cfg.Debug}) }
func (h *Handler) PutDebug(c *gin.Context) { h.updateBoolField(c, func(v bool) { h.cfg.Debug = v }) }
// UsageStatisticsEnabled
func (h *Handler) GetUsageStatisticsEnabled(c *gin.Context) {
c.JSON(200, gin.H{"usage-statistics-enabled": h.cfg.UsageStatisticsEnabled})
}
func (h *Handler) PutUsageStatisticsEnabled(c *gin.Context) {
h.updateBoolField(c, func(v bool) { h.cfg.UsageStatisticsEnabled = v })
}
// UsageStatisticsEnabled
func (h *Handler) GetLoggingToFile(c *gin.Context) {
c.JSON(200, gin.H{"logging-to-file": h.cfg.LoggingToFile})
}
func (h *Handler) PutLoggingToFile(c *gin.Context) {
h.updateBoolField(c, func(v bool) { h.cfg.LoggingToFile = v })
}
// Request log
func (h *Handler) GetRequestLog(c *gin.Context) { c.JSON(200, gin.H{"request-log": h.cfg.RequestLog}) }
func (h *Handler) PutRequestLog(c *gin.Context) {
h.updateBoolField(c, func(v bool) { h.cfg.RequestLog = v })
}
// Request retry
func (h *Handler) GetRequestRetry(c *gin.Context) {
c.JSON(200, gin.H{"request-retry": h.cfg.RequestRetry})
}
func (h *Handler) PutRequestRetry(c *gin.Context) {
h.updateIntField(c, func(v int) { h.cfg.RequestRetry = v })
}
// Proxy URL
func (h *Handler) GetProxyURL(c *gin.Context) { c.JSON(200, gin.H{"proxy-url": h.cfg.ProxyURL}) }
func (h *Handler) PutProxyURL(c *gin.Context) {
h.updateStringField(c, func(v string) { h.cfg.ProxyURL = v })
}
func (h *Handler) DeleteProxyURL(c *gin.Context) {
h.cfg.ProxyURL = ""
h.persist(c)
}

View File

@@ -0,0 +1,404 @@
package management
import (
"encoding/json"
"fmt"
"strings"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
)
// Generic helpers for list[string]
func (h *Handler) putStringList(c *gin.Context, set func([]string), after func()) {
data, err := c.GetRawData()
if err != nil {
c.JSON(400, gin.H{"error": "failed to read body"})
return
}
var arr []string
if err = json.Unmarshal(data, &arr); err != nil {
var obj struct {
Items []string `json:"items"`
}
if err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 {
c.JSON(400, gin.H{"error": "invalid body"})
return
}
arr = obj.Items
}
set(arr)
if after != nil {
after()
}
h.persist(c)
}
func (h *Handler) patchStringList(c *gin.Context, target *[]string, after func()) {
var body struct {
Old *string `json:"old"`
New *string `json:"new"`
Index *int `json:"index"`
Value *string `json:"value"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(400, gin.H{"error": "invalid body"})
return
}
if body.Index != nil && body.Value != nil && *body.Index >= 0 && *body.Index < len(*target) {
(*target)[*body.Index] = *body.Value
if after != nil {
after()
}
h.persist(c)
return
}
if body.Old != nil && body.New != nil {
for i := range *target {
if (*target)[i] == *body.Old {
(*target)[i] = *body.New
if after != nil {
after()
}
h.persist(c)
return
}
}
*target = append(*target, *body.New)
if after != nil {
after()
}
h.persist(c)
return
}
c.JSON(400, gin.H{"error": "missing fields"})
}
func (h *Handler) deleteFromStringList(c *gin.Context, target *[]string, after func()) {
if idxStr := c.Query("index"); idxStr != "" {
var idx int
_, err := fmt.Sscanf(idxStr, "%d", &idx)
if err == nil && idx >= 0 && idx < len(*target) {
*target = append((*target)[:idx], (*target)[idx+1:]...)
if after != nil {
after()
}
h.persist(c)
return
}
}
if val := c.Query("value"); val != "" {
out := make([]string, 0, len(*target))
for _, v := range *target {
if v != val {
out = append(out, v)
}
}
*target = out
if after != nil {
after()
}
h.persist(c)
return
}
c.JSON(400, gin.H{"error": "missing index or value"})
}
// api-keys
func (h *Handler) GetAPIKeys(c *gin.Context) { c.JSON(200, gin.H{"api-keys": h.cfg.APIKeys}) }
func (h *Handler) PutAPIKeys(c *gin.Context) {
h.putStringList(c, func(v []string) {
h.cfg.APIKeys = append([]string(nil), v...)
h.cfg.Access.Providers = nil
}, nil)
}
func (h *Handler) PatchAPIKeys(c *gin.Context) {
h.patchStringList(c, &h.cfg.APIKeys, func() { h.cfg.Access.Providers = nil })
}
func (h *Handler) DeleteAPIKeys(c *gin.Context) {
h.deleteFromStringList(c, &h.cfg.APIKeys, func() { h.cfg.Access.Providers = nil })
}
// generative-language-api-key
func (h *Handler) GetGlKeys(c *gin.Context) {
c.JSON(200, gin.H{"generative-language-api-key": h.cfg.GlAPIKey})
}
func (h *Handler) PutGlKeys(c *gin.Context) {
h.putStringList(c, func(v []string) { h.cfg.GlAPIKey = v }, nil)
}
func (h *Handler) PatchGlKeys(c *gin.Context) { h.patchStringList(c, &h.cfg.GlAPIKey, nil) }
func (h *Handler) DeleteGlKeys(c *gin.Context) { h.deleteFromStringList(c, &h.cfg.GlAPIKey, nil) }
// claude-api-key: []ClaudeKey
func (h *Handler) GetClaudeKeys(c *gin.Context) {
c.JSON(200, gin.H{"claude-api-key": h.cfg.ClaudeKey})
}
func (h *Handler) PutClaudeKeys(c *gin.Context) {
data, err := c.GetRawData()
if err != nil {
c.JSON(400, gin.H{"error": "failed to read body"})
return
}
var arr []config.ClaudeKey
if err = json.Unmarshal(data, &arr); err != nil {
var obj struct {
Items []config.ClaudeKey `json:"items"`
}
if err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 {
c.JSON(400, gin.H{"error": "invalid body"})
return
}
arr = obj.Items
}
h.cfg.ClaudeKey = arr
h.persist(c)
}
func (h *Handler) PatchClaudeKey(c *gin.Context) {
var body struct {
Index *int `json:"index"`
Match *string `json:"match"`
Value *config.ClaudeKey `json:"value"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
c.JSON(400, gin.H{"error": "invalid body"})
return
}
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.ClaudeKey) {
h.cfg.ClaudeKey[*body.Index] = *body.Value
h.persist(c)
return
}
if body.Match != nil {
for i := range h.cfg.ClaudeKey {
if h.cfg.ClaudeKey[i].APIKey == *body.Match {
h.cfg.ClaudeKey[i] = *body.Value
h.persist(c)
return
}
}
}
c.JSON(404, gin.H{"error": "item not found"})
}
func (h *Handler) DeleteClaudeKey(c *gin.Context) {
if val := c.Query("api-key"); val != "" {
out := make([]config.ClaudeKey, 0, len(h.cfg.ClaudeKey))
for _, v := range h.cfg.ClaudeKey {
if v.APIKey != val {
out = append(out, v)
}
}
h.cfg.ClaudeKey = out
h.persist(c)
return
}
if idxStr := c.Query("index"); idxStr != "" {
var idx int
_, err := fmt.Sscanf(idxStr, "%d", &idx)
if err == nil && idx >= 0 && idx < len(h.cfg.ClaudeKey) {
h.cfg.ClaudeKey = append(h.cfg.ClaudeKey[:idx], h.cfg.ClaudeKey[idx+1:]...)
h.persist(c)
return
}
}
c.JSON(400, gin.H{"error": "missing api-key or index"})
}
// openai-compatibility: []OpenAICompatibility
func (h *Handler) GetOpenAICompat(c *gin.Context) {
c.JSON(200, gin.H{"openai-compatibility": normalizedOpenAICompatibilityEntries(h.cfg.OpenAICompatibility)})
}
func (h *Handler) PutOpenAICompat(c *gin.Context) {
data, err := c.GetRawData()
if err != nil {
c.JSON(400, gin.H{"error": "failed to read body"})
return
}
var arr []config.OpenAICompatibility
if err = json.Unmarshal(data, &arr); err != nil {
var obj struct {
Items []config.OpenAICompatibility `json:"items"`
}
if err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 {
c.JSON(400, gin.H{"error": "invalid body"})
return
}
arr = obj.Items
}
for i := range arr {
normalizeOpenAICompatibilityEntry(&arr[i])
}
h.cfg.OpenAICompatibility = arr
h.persist(c)
}
func (h *Handler) PatchOpenAICompat(c *gin.Context) {
var body struct {
Name *string `json:"name"`
Index *int `json:"index"`
Value *config.OpenAICompatibility `json:"value"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
c.JSON(400, gin.H{"error": "invalid body"})
return
}
normalizeOpenAICompatibilityEntry(body.Value)
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.OpenAICompatibility) {
h.cfg.OpenAICompatibility[*body.Index] = *body.Value
h.persist(c)
return
}
if body.Name != nil {
for i := range h.cfg.OpenAICompatibility {
if h.cfg.OpenAICompatibility[i].Name == *body.Name {
h.cfg.OpenAICompatibility[i] = *body.Value
h.persist(c)
return
}
}
}
c.JSON(404, gin.H{"error": "item not found"})
}
func (h *Handler) DeleteOpenAICompat(c *gin.Context) {
if name := c.Query("name"); name != "" {
out := make([]config.OpenAICompatibility, 0, len(h.cfg.OpenAICompatibility))
for _, v := range h.cfg.OpenAICompatibility {
if v.Name != name {
out = append(out, v)
}
}
h.cfg.OpenAICompatibility = out
h.persist(c)
return
}
if idxStr := c.Query("index"); idxStr != "" {
var idx int
_, err := fmt.Sscanf(idxStr, "%d", &idx)
if err == nil && idx >= 0 && idx < len(h.cfg.OpenAICompatibility) {
h.cfg.OpenAICompatibility = append(h.cfg.OpenAICompatibility[:idx], h.cfg.OpenAICompatibility[idx+1:]...)
h.persist(c)
return
}
}
c.JSON(400, gin.H{"error": "missing name or index"})
}
// codex-api-key: []CodexKey
func (h *Handler) GetCodexKeys(c *gin.Context) {
c.JSON(200, gin.H{"codex-api-key": h.cfg.CodexKey})
}
func (h *Handler) PutCodexKeys(c *gin.Context) {
data, err := c.GetRawData()
if err != nil {
c.JSON(400, gin.H{"error": "failed to read body"})
return
}
var arr []config.CodexKey
if err = json.Unmarshal(data, &arr); err != nil {
var obj struct {
Items []config.CodexKey `json:"items"`
}
if err2 := json.Unmarshal(data, &obj); err2 != nil || len(obj.Items) == 0 {
c.JSON(400, gin.H{"error": "invalid body"})
return
}
arr = obj.Items
}
h.cfg.CodexKey = arr
h.persist(c)
}
func (h *Handler) PatchCodexKey(c *gin.Context) {
var body struct {
Index *int `json:"index"`
Match *string `json:"match"`
Value *config.CodexKey `json:"value"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
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
h.persist(c)
return
}
}
}
c.JSON(404, gin.H{"error": "item not found"})
}
func (h *Handler) DeleteCodexKey(c *gin.Context) {
if val := c.Query("api-key"); val != "" {
out := make([]config.CodexKey, 0, len(h.cfg.CodexKey))
for _, v := range h.cfg.CodexKey {
if v.APIKey != val {
out = append(out, v)
}
}
h.cfg.CodexKey = out
h.persist(c)
return
}
if idxStr := c.Query("index"); idxStr != "" {
var idx int
_, err := fmt.Sscanf(idxStr, "%d", &idx)
if err == nil && idx >= 0 && idx < len(h.cfg.CodexKey) {
h.cfg.CodexKey = append(h.cfg.CodexKey[:idx], h.cfg.CodexKey[idx+1:]...)
h.persist(c)
return
}
}
c.JSON(400, gin.H{"error": "missing api-key or index"})
}
func normalizeOpenAICompatibilityEntry(entry *config.OpenAICompatibility) {
if entry == nil {
return
}
existing := make(map[string]struct{}, len(entry.APIKeyEntries))
for i := range entry.APIKeyEntries {
trimmed := strings.TrimSpace(entry.APIKeyEntries[i].APIKey)
entry.APIKeyEntries[i].APIKey = trimmed
if trimmed != "" {
existing[trimmed] = struct{}{}
}
}
if len(entry.APIKeys) == 0 {
return
}
for _, legacyKey := range entry.APIKeys {
trimmed := strings.TrimSpace(legacyKey)
if trimmed == "" {
continue
}
if _, ok := existing[trimmed]; ok {
continue
}
entry.APIKeyEntries = append(entry.APIKeyEntries, config.OpenAICompatibilityAPIKey{APIKey: trimmed})
existing[trimmed] = struct{}{}
}
entry.APIKeys = nil
}
func normalizedOpenAICompatibilityEntries(entries []config.OpenAICompatibility) []config.OpenAICompatibility {
if len(entries) == 0 {
return nil
}
out := make([]config.OpenAICompatibility, len(entries))
for i := range entries {
copyEntry := entries[i]
if len(copyEntry.APIKeyEntries) > 0 {
copyEntry.APIKeyEntries = append([]config.OpenAICompatibilityAPIKey(nil), copyEntry.APIKeyEntries...)
}
if len(copyEntry.APIKeys) > 0 {
copyEntry.APIKeys = append([]string(nil), copyEntry.APIKeys...)
}
normalizeOpenAICompatibilityEntry(&copyEntry)
out[i] = copyEntry
}
return out
}

View File

@@ -0,0 +1,231 @@
// Package management provides the management API handlers and middleware
// for configuring the server and managing auth files.
package management
import (
"crypto/subtle"
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
"golang.org/x/crypto/bcrypt"
)
type attemptInfo struct {
count int
blockedUntil time.Time
}
// 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
}
// NewHandler creates a new management handler instance.
func NewHandler(cfg *config.Config, configFilePath string, manager *coreauth.Manager) *Handler {
return &Handler{
cfg: cfg,
configFilePath: configFilePath,
failedAttempts: make(map[string]*attemptInfo),
authManager: manager,
usageStats: usage.GetRequestStatistics(),
tokenStore: sdkAuth.GetTokenStore(),
}
}
// SetConfig updates the in-memory config reference when the server hot-reloads.
func (h *Handler) SetConfig(cfg *config.Config) { h.cfg = cfg }
// SetAuthManager updates the auth manager reference used by management endpoints.
func (h *Handler) SetAuthManager(manager *coreauth.Manager) { h.authManager = manager }
// SetUsageStatistics allows replacing the usage statistics reference.
func (h *Handler) SetUsageStatistics(stats *usage.RequestStatistics) { h.usageStats = stats }
// SetLocalPassword configures the runtime-local password accepted for localhost requests.
func (h *Handler) SetLocalPassword(password string) { h.localPassword = password }
// Middleware enforces access control for management endpoints.
// All requests (local and remote) require a valid management key.
// Additionally, remote access requires allow-remote-management=true.
func (h *Handler) Middleware() gin.HandlerFunc {
const maxFailures = 5
const banDuration = 30 * time.Minute
return func(c *gin.Context) {
clientIP := c.ClientIP()
localClient := clientIP == "127.0.0.1" || clientIP == "::1"
fail := func() {}
if !localClient {
h.attemptsMu.Lock()
ai := h.failedAttempts[clientIP]
if ai != nil {
if !ai.blockedUntil.IsZero() {
if time.Now().Before(ai.blockedUntil) {
remaining := time.Until(ai.blockedUntil).Round(time.Second)
h.attemptsMu.Unlock()
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": fmt.Sprintf("IP banned due to too many failed attempts. Try again in %s", remaining)})
return
}
// Ban expired, reset state
ai.blockedUntil = time.Time{}
ai.count = 0
}
}
h.attemptsMu.Unlock()
if !h.cfg.RemoteManagement.AllowRemote {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "remote management disabled"})
return
}
fail = func() {
h.attemptsMu.Lock()
aip := h.failedAttempts[clientIP]
if aip == nil {
aip = &attemptInfo{}
h.failedAttempts[clientIP] = aip
}
aip.count++
if aip.count >= maxFailures {
aip.blockedUntil = time.Now().Add(banDuration)
aip.count = 0
}
h.attemptsMu.Unlock()
}
}
secret := h.cfg.RemoteManagement.SecretKey
if secret == "" {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "remote management key not set"})
return
}
// Accept either Authorization: Bearer <key> or X-Management-Key
var provided string
if ah := c.GetHeader("Authorization"); ah != "" {
parts := strings.SplitN(ah, " ", 2)
if len(parts) == 2 && strings.ToLower(parts[0]) == "bearer" {
provided = parts[1]
} else {
provided = ah
}
}
if provided == "" {
provided = c.GetHeader("X-Management-Key")
}
if provided == "" {
if !localClient {
fail()
}
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing management key"})
return
}
if localClient {
if lp := h.localPassword; lp != "" {
if subtle.ConstantTimeCompare([]byte(provided), []byte(lp)) == 1 {
c.Next()
return
}
}
}
if err := bcrypt.CompareHashAndPassword([]byte(secret), []byte(provided)); err != nil {
if !localClient {
fail()
}
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid management key"})
return
}
if !localClient {
h.attemptsMu.Lock()
if ai := h.failedAttempts[clientIP]; ai != nil {
ai.count = 0
ai.blockedUntil = time.Time{}
}
h.attemptsMu.Unlock()
}
c.Next()
}
}
// persist saves the current in-memory config to disk.
func (h *Handler) persist(c *gin.Context) bool {
h.mu.Lock()
defer h.mu.Unlock()
// Preserve comments when writing
if err := config.SaveConfigPreserveComments(h.configFilePath, h.cfg); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to save config: %v", err)})
return false
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
return true
}
// Helper methods for simple types
func (h *Handler) updateBoolField(c *gin.Context, set func(bool)) {
var body struct {
Value *bool `json:"value"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
var m map[string]any
if err2 := c.ShouldBindJSON(&m); err2 == nil {
for _, v := range m {
if b, ok := v.(bool); ok {
set(b)
h.persist(c)
return
}
}
}
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
return
}
set(*body.Value)
h.persist(c)
}
func (h *Handler) updateIntField(c *gin.Context, set func(int)) {
var body struct {
Value *int `json:"value"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
return
}
set(*body.Value)
h.persist(c)
}
func (h *Handler) updateStringField(c *gin.Context, set func(string)) {
var body struct {
Value *string `json:"value"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
return
}
set(*body.Value)
h.persist(c)
}

View File

@@ -0,0 +1,18 @@
package management
import "github.com/gin-gonic/gin"
// Quota exceeded toggles
func (h *Handler) GetSwitchProject(c *gin.Context) {
c.JSON(200, gin.H{"switch-project": h.cfg.QuotaExceeded.SwitchProject})
}
func (h *Handler) PutSwitchProject(c *gin.Context) {
h.updateBoolField(c, func(v bool) { h.cfg.QuotaExceeded.SwitchProject = v })
}
func (h *Handler) GetSwitchPreviewModel(c *gin.Context) {
c.JSON(200, gin.H{"switch-preview-model": h.cfg.QuotaExceeded.SwitchPreviewModel})
}
func (h *Handler) PutSwitchPreviewModel(c *gin.Context) {
h.updateBoolField(c, func(v bool) { h.cfg.QuotaExceeded.SwitchPreviewModel = v })
}

View File

@@ -0,0 +1,17 @@
package management
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
)
// GetUsageStatistics returns the in-memory request statistics snapshot.
func (h *Handler) GetUsageStatistics(c *gin.Context) {
var snapshot usage.StatisticsSnapshot
if h != nil && h.usageStats != nil {
snapshot = h.usageStats.Snapshot()
}
c.JSON(http.StatusOK, gin.H{"usage": snapshot})
}

View File

@@ -0,0 +1,92 @@
// Package middleware provides HTTP middleware components for the CLI Proxy API server.
// This file contains the request logging middleware that captures comprehensive
// request and response data when enabled through configuration.
package middleware
import (
"bytes"
"io"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
)
// RequestLoggingMiddleware creates a Gin middleware that logs HTTP requests and responses.
// It captures detailed information about the request and response, including headers and body,
// and uses the provided RequestLogger to record this data. If logging is disabled in the
// logger, the middleware has minimal overhead.
func RequestLoggingMiddleware(logger logging.RequestLogger) gin.HandlerFunc {
return func(c *gin.Context) {
// Early return if logging is disabled (zero overhead)
if !logger.IsEnabled() {
c.Next()
return
}
// Capture request information
requestInfo, err := captureRequestInfo(c)
if err != nil {
// Log error but continue processing
// In a real implementation, you might want to use a proper logger here
c.Next()
return
}
// Create response writer wrapper
wrapper := NewResponseWriterWrapper(c.Writer, logger, requestInfo)
c.Writer = wrapper
// Process the request
c.Next()
// Finalize logging after request processing
if err = wrapper.Finalize(c); err != nil {
// Log error but don't interrupt the response
// In a real implementation, you might want to use a proper logger here
}
}
}
// captureRequestInfo extracts relevant information from the incoming HTTP request.
// It captures the URL, method, headers, and body. The request body is read and then
// restored so that it can be processed by subsequent handlers.
func captureRequestInfo(c *gin.Context) (*RequestInfo, error) {
// Capture URL
url := c.Request.URL.String()
if c.Request.URL.Path != "" {
url = c.Request.URL.Path
if c.Request.URL.RawQuery != "" {
url += "?" + c.Request.URL.RawQuery
}
}
// Capture method
method := c.Request.Method
// Capture headers
headers := make(map[string][]string)
for key, values := range c.Request.Header {
headers[key] = values
}
// Capture request body
var body []byte
if c.Request.Body != nil {
// Read the body
bodyBytes, err := io.ReadAll(c.Request.Body)
if err != nil {
return nil, err
}
// Restore the body for the actual request processing
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
body = bodyBytes
}
return &RequestInfo{
URL: url,
Method: method,
Headers: headers,
Body: body,
}, nil
}

View File

@@ -0,0 +1,309 @@
// Package middleware provides Gin HTTP middleware for the CLI Proxy API server.
// It includes a sophisticated response writer wrapper designed to capture and log request and response data,
// including support for streaming responses, without impacting latency.
package middleware
import (
"bytes"
"strings"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
)
// RequestInfo holds essential details of an incoming HTTP request for logging purposes.
type RequestInfo struct {
URL string // URL is the request URL.
Method string // Method is the HTTP method (e.g., GET, POST).
Headers map[string][]string // Headers contains the request headers.
Body []byte // Body is the raw request body.
}
// ResponseWriterWrapper wraps the standard gin.ResponseWriter to intercept and log response data.
// It is designed to handle both standard and streaming responses, ensuring that logging operations do not block the client response.
type ResponseWriterWrapper struct {
gin.ResponseWriter
body *bytes.Buffer // body is a buffer to store the response body for non-streaming responses.
isStreaming bool // isStreaming indicates whether the response is a streaming type (e.g., text/event-stream).
streamWriter logging.StreamingLogWriter // streamWriter is a writer for handling streaming log entries.
chunkChannel chan []byte // chunkChannel is a channel for asynchronously passing response chunks to the logger.
streamDone chan struct{} // streamDone signals when the streaming goroutine completes.
logger logging.RequestLogger // logger is the instance of the request logger service.
requestInfo *RequestInfo // requestInfo holds the details of the original request.
statusCode int // statusCode stores the HTTP status code of the response.
headers map[string][]string // headers stores the response headers.
}
// NewResponseWriterWrapper creates and initializes a new ResponseWriterWrapper.
// It takes the original gin.ResponseWriter, a logger instance, and request information.
//
// Parameters:
// - w: The original gin.ResponseWriter to wrap.
// - logger: The logging service to use for recording requests.
// - requestInfo: The pre-captured information about the incoming request.
//
// Returns:
// - A pointer to a new ResponseWriterWrapper.
func NewResponseWriterWrapper(w gin.ResponseWriter, logger logging.RequestLogger, requestInfo *RequestInfo) *ResponseWriterWrapper {
return &ResponseWriterWrapper{
ResponseWriter: w,
body: &bytes.Buffer{},
logger: logger,
requestInfo: requestInfo,
headers: make(map[string][]string),
}
}
// Write wraps the underlying ResponseWriter's Write method to capture response data.
// For non-streaming responses, it writes to an internal buffer. For streaming responses,
// it sends data chunks to a non-blocking channel for asynchronous logging.
// CRITICAL: This method prioritizes writing to the client to ensure zero latency,
// handling logging operations subsequently.
func (w *ResponseWriterWrapper) Write(data []byte) (int, error) {
// Ensure headers are captured before first write
// This is critical because Write() may trigger WriteHeader() internally
w.ensureHeadersCaptured()
// CRITICAL: Write to client first (zero latency)
n, err := w.ResponseWriter.Write(data)
// THEN: Handle logging based on response type
if w.isStreaming {
// For streaming responses: Send to async logging channel (non-blocking)
if w.chunkChannel != nil {
select {
case w.chunkChannel <- append([]byte(nil), data...): // Non-blocking send with copy
default: // Channel full, skip logging to avoid blocking
}
}
} else {
// For non-streaming responses: Buffer complete response
w.body.Write(data)
}
return n, err
}
// WriteHeader wraps the underlying ResponseWriter's WriteHeader method.
// It captures the status code, detects if the response is streaming based on the Content-Type header,
// and initializes the appropriate logging mechanism (standard or streaming).
func (w *ResponseWriterWrapper) WriteHeader(statusCode int) {
w.statusCode = statusCode
// Capture response headers using the new method
w.captureCurrentHeaders()
// Detect streaming based on Content-Type
contentType := w.ResponseWriter.Header().Get("Content-Type")
w.isStreaming = w.detectStreaming(contentType)
// If streaming, initialize streaming log writer
if w.isStreaming && w.logger.IsEnabled() {
streamWriter, err := w.logger.LogStreamingRequest(
w.requestInfo.URL,
w.requestInfo.Method,
w.requestInfo.Headers,
w.requestInfo.Body,
)
if err == nil {
w.streamWriter = streamWriter
w.chunkChannel = make(chan []byte, 100) // Buffered channel for async writes
doneChan := make(chan struct{})
w.streamDone = doneChan
// Start async chunk processor
go w.processStreamingChunks(doneChan)
// Write status immediately
_ = streamWriter.WriteStatus(statusCode, w.headers)
}
}
// Call original WriteHeader
w.ResponseWriter.WriteHeader(statusCode)
}
// ensureHeadersCaptured is a helper function to make sure response headers are captured.
// It is safe to call this method multiple times; it will always refresh the headers
// with the latest state from the underlying ResponseWriter.
func (w *ResponseWriterWrapper) ensureHeadersCaptured() {
// Always capture the current headers to ensure we have the latest state
w.captureCurrentHeaders()
}
// captureCurrentHeaders reads all headers from the underlying ResponseWriter and stores them
// in the wrapper's headers map. It creates copies of the header values to prevent race conditions.
func (w *ResponseWriterWrapper) captureCurrentHeaders() {
// Initialize headers map if needed
if w.headers == nil {
w.headers = make(map[string][]string)
}
// Capture all current headers from the underlying ResponseWriter
for key, values := range w.ResponseWriter.Header() {
// Make a copy of the values slice to avoid reference issues
headerValues := make([]string, len(values))
copy(headerValues, values)
w.headers[key] = headerValues
}
}
// detectStreaming determines if a response should be treated as a streaming response.
// It checks for a "text/event-stream" Content-Type or a '"stream": true'
// field in the original request body.
func (w *ResponseWriterWrapper) detectStreaming(contentType string) bool {
// Check Content-Type for Server-Sent Events
if strings.Contains(contentType, "text/event-stream") {
return true
}
// Check request body for streaming indicators
if w.requestInfo.Body != nil {
bodyStr := string(w.requestInfo.Body)
if strings.Contains(bodyStr, `"stream": true`) || strings.Contains(bodyStr, `"stream":true`) {
return true
}
}
return false
}
// processStreamingChunks runs in a separate goroutine to process response chunks from the chunkChannel.
// It asynchronously writes each chunk to the streaming log writer.
func (w *ResponseWriterWrapper) processStreamingChunks(done chan struct{}) {
if done == nil {
return
}
defer close(done)
if w.streamWriter == nil || w.chunkChannel == nil {
return
}
for chunk := range w.chunkChannel {
w.streamWriter.WriteChunkAsync(chunk)
}
}
// Finalize completes the logging process for the request and response.
// For streaming responses, it closes the chunk channel and the stream writer.
// For non-streaming responses, it logs the complete request and response details,
// including any API-specific request/response data stored in the Gin context.
func (w *ResponseWriterWrapper) Finalize(c *gin.Context) error {
if !w.logger.IsEnabled() {
return nil
}
if w.isStreaming {
// Close streaming channel and writer
if w.chunkChannel != nil {
close(w.chunkChannel)
w.chunkChannel = nil
}
if w.streamDone != nil {
<-w.streamDone
w.streamDone = nil
}
if w.streamWriter != nil {
err := w.streamWriter.Close()
w.streamWriter = nil
return err
}
} else {
// Capture final status code and headers if not already captured
finalStatusCode := w.statusCode
if finalStatusCode == 0 {
// Get status from underlying ResponseWriter if available
if statusWriter, ok := w.ResponseWriter.(interface{ Status() int }); ok {
finalStatusCode = statusWriter.Status()
} else {
finalStatusCode = 200 // Default
}
}
// Ensure we have the latest headers before finalizing
w.ensureHeadersCaptured()
// Use the captured headers as the final headers
finalHeaders := make(map[string][]string)
for key, values := range w.headers {
// Make a copy of the values slice to avoid reference issues
headerValues := make([]string, len(values))
copy(headerValues, values)
finalHeaders[key] = headerValues
}
var apiRequestBody []byte
apiRequest, isExist := c.Get("API_REQUEST")
if isExist {
var ok bool
apiRequestBody, ok = apiRequest.([]byte)
if !ok {
apiRequestBody = nil
}
}
var apiResponseBody []byte
apiResponse, isExist := c.Get("API_RESPONSE")
if isExist {
var ok bool
apiResponseBody, ok = apiResponse.([]byte)
if !ok {
apiResponseBody = nil
}
}
var slicesAPIResponseError []*interfaces.ErrorMessage
apiResponseError, isExist := c.Get("API_RESPONSE_ERROR")
if isExist {
var ok bool
slicesAPIResponseError, ok = apiResponseError.([]*interfaces.ErrorMessage)
if !ok {
slicesAPIResponseError = nil
}
}
// Log complete non-streaming response
return w.logger.LogRequest(
w.requestInfo.URL,
w.requestInfo.Method,
w.requestInfo.Headers,
w.requestInfo.Body,
finalStatusCode,
finalHeaders,
w.body.Bytes(),
apiRequestBody,
apiResponseBody,
slicesAPIResponseError,
)
}
return nil
}
// Status returns the HTTP response status code captured by the wrapper.
// It defaults to 200 if WriteHeader has not been called.
func (w *ResponseWriterWrapper) Status() int {
if w.statusCode == 0 {
return 200 // Default status code
}
return w.statusCode
}
// Size returns the size of the response body in bytes for non-streaming responses.
// For streaming responses, it returns -1, as the total size is unknown.
func (w *ResponseWriterWrapper) Size() int {
if w.isStreaming {
return -1 // Unknown size for streaming responses
}
return w.body.Len()
}
// Written returns true if the response header has been written (i.e., a status code has been set).
func (w *ResponseWriterWrapper) Written() bool {
return w.statusCode != 0
}

View File

@@ -1,18 +0,0 @@
package api
// ErrorResponse represents a standard error response format for the API.
// It contains a single ErrorDetail field.
type ErrorResponse struct {
Error ErrorDetail `json:"error"`
}
// ErrorDetail provides specific information about an error that occurred.
// It includes a human-readable message, an error type, and an optional error code.
type ErrorDetail struct {
// A human-readable message providing more details about the error.
Message string `json:"message"`
// The type of error that occurred (e.g., "invalid_request_error").
Type string `json:"type"`
// A short code identifying the error, if applicable.
Code string `json:"code,omitempty"`
}

View File

@@ -1,54 +1,218 @@
// Package api provides the HTTP API server implementation for the CLI Proxy API.
// It includes the main server struct, routing setup, middleware for CORS and authentication,
// and integration with various AI API handlers (OpenAI, Claude, Gemini).
// The server supports hot-reloading of clients and configuration.
package api
import (
"context"
"crypto/subtle"
"errors"
"fmt"
"github.com/gin-gonic/gin"
"github.com/luispater/CLIProxyAPI/internal/client"
"github.com/luispater/CLIProxyAPI/internal/config"
log "github.com/sirupsen/logrus"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v6/internal/access"
managementHandlers "github.com/router-for-me/CLIProxyAPI/v6/internal/api/handlers/management"
"github.com/router-for-me/CLIProxyAPI/v6/internal/api/middleware"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
"github.com/router-for-me/CLIProxyAPI/v6/internal/managementasset"
"github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers/claude"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers/gemini"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers/openai"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
log "github.com/sirupsen/logrus"
)
type serverOptionConfig struct {
extraMiddleware []gin.HandlerFunc
engineConfigurator func(*gin.Engine)
routerConfigurator func(*gin.Engine, *handlers.BaseAPIHandler, *config.Config)
requestLoggerFactory func(*config.Config, string) logging.RequestLogger
localPassword string
keepAliveEnabled bool
keepAliveTimeout time.Duration
keepAliveOnTimeout func()
}
// ServerOption customises HTTP server construction.
type ServerOption func(*serverOptionConfig)
func defaultRequestLoggerFactory(cfg *config.Config, configPath string) logging.RequestLogger {
return logging.NewFileRequestLogger(cfg.RequestLog, "logs", filepath.Dir(configPath))
}
// WithMiddleware appends additional Gin middleware during server construction.
func WithMiddleware(mw ...gin.HandlerFunc) ServerOption {
return func(cfg *serverOptionConfig) {
cfg.extraMiddleware = append(cfg.extraMiddleware, mw...)
}
}
// WithEngineConfigurator allows callers to mutate the Gin engine prior to middleware setup.
func WithEngineConfigurator(fn func(*gin.Engine)) ServerOption {
return func(cfg *serverOptionConfig) {
cfg.engineConfigurator = fn
}
}
// WithRouterConfigurator appends a callback after default routes are registered.
func WithRouterConfigurator(fn func(*gin.Engine, *handlers.BaseAPIHandler, *config.Config)) ServerOption {
return func(cfg *serverOptionConfig) {
cfg.routerConfigurator = fn
}
}
// WithLocalManagementPassword stores a runtime-only management password accepted for localhost requests.
func WithLocalManagementPassword(password string) ServerOption {
return func(cfg *serverOptionConfig) {
cfg.localPassword = password
}
}
// WithKeepAliveEndpoint enables a keep-alive endpoint with the provided timeout and callback.
func WithKeepAliveEndpoint(timeout time.Duration, onTimeout func()) ServerOption {
return func(cfg *serverOptionConfig) {
if timeout <= 0 || onTimeout == nil {
return
}
cfg.keepAliveEnabled = true
cfg.keepAliveTimeout = timeout
cfg.keepAliveOnTimeout = onTimeout
}
}
// WithRequestLoggerFactory customises request logger creation.
func WithRequestLoggerFactory(factory func(*config.Config, string) logging.RequestLogger) ServerOption {
return func(cfg *serverOptionConfig) {
cfg.requestLoggerFactory = factory
}
}
// Server represents the main API server.
// It encapsulates the Gin engine, HTTP server, handlers, and configuration.
type Server struct {
engine *gin.Engine
server *http.Server
handlers *APIHandlers
cfg *config.Config
// engine is the Gin web framework engine instance.
engine *gin.Engine
// server is the underlying HTTP server.
server *http.Server
// handlers contains the API handlers for processing requests.
handlers *handlers.BaseAPIHandler
// cfg holds the current server configuration.
cfg *config.Config
// accessManager handles request authentication providers.
accessManager *sdkaccess.Manager
// requestLogger is the request logger instance for dynamic configuration updates.
requestLogger logging.RequestLogger
loggerToggle func(bool)
// configFilePath is the absolute path to the YAML config file for persistence.
configFilePath string
// management handler
mgmt *managementHandlers.Handler
localPassword string
keepAliveEnabled bool
keepAliveTimeout time.Duration
keepAliveOnTimeout func()
keepAliveHeartbeat chan struct{}
keepAliveStop chan struct{}
}
// NewServer creates and initializes a new API server instance.
// It sets up the Gin engine, middleware, routes, and handlers.
func NewServer(cfg *config.Config, cliClients []*client.Client) *Server {
//
// Parameters:
// - cfg: The server configuration
// - authManager: core runtime auth manager
// - accessManager: request authentication manager
//
// Returns:
// - *Server: A new server instance
func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdkaccess.Manager, configFilePath string, opts ...ServerOption) *Server {
optionState := &serverOptionConfig{
requestLoggerFactory: defaultRequestLoggerFactory,
}
for i := range opts {
opts[i](optionState)
}
// Set gin mode
if !cfg.Debug {
gin.SetMode(gin.ReleaseMode)
}
// Create handlers
handlers := NewAPIHandlers(cliClients, cfg)
// Create gin engine
engine := gin.New()
if optionState.engineConfigurator != nil {
optionState.engineConfigurator(engine)
}
// Add middleware
engine.Use(gin.Logger())
engine.Use(gin.Recovery())
engine.Use(logging.GinLogrusLogger())
engine.Use(logging.GinLogrusRecovery())
for _, mw := range optionState.extraMiddleware {
engine.Use(mw)
}
// Add request logging middleware (positioned after recovery, before auth)
// Resolve logs directory relative to the configuration file directory.
var requestLogger logging.RequestLogger
var toggle func(bool)
if optionState.requestLoggerFactory != nil {
requestLogger = optionState.requestLoggerFactory(cfg, configFilePath)
}
if requestLogger != nil {
engine.Use(middleware.RequestLoggingMiddleware(requestLogger))
if setter, ok := requestLogger.(interface{ SetEnabled(bool) }); ok {
toggle = setter.SetEnabled
}
}
engine.Use(corsMiddleware())
// Create server instance
s := &Server{
engine: engine,
handlers: handlers,
cfg: cfg,
engine: engine,
handlers: handlers.NewBaseAPIHandlers(&cfg.SDKConfig, authManager),
cfg: cfg,
accessManager: accessManager,
requestLogger: requestLogger,
loggerToggle: toggle,
configFilePath: configFilePath,
}
s.applyAccessConfig(nil, cfg)
// Initialize management handler
s.mgmt = managementHandlers.NewHandler(cfg, configFilePath, authManager)
if optionState.localPassword != "" {
s.mgmt.SetLocalPassword(optionState.localPassword)
}
s.localPassword = optionState.localPassword
// Setup routes
s.setupRoutes()
if optionState.routerConfigurator != nil {
optionState.routerConfigurator(engine, s.handlers, cfg)
}
if optionState.keepAliveEnabled {
s.enableKeepAlive(optionState.keepAliveTimeout, optionState.keepAliveOnTimeout)
}
// Create HTTP server
s.server = &http.Server{
@@ -62,22 +226,32 @@ func NewServer(cfg *config.Config, cliClients []*client.Client) *Server {
// setupRoutes configures the API routes for the server.
// It defines the endpoints and associates them with their respective handlers.
func (s *Server) setupRoutes() {
s.engine.GET("/management.html", s.serveManagementControlPanel)
openaiHandlers := openai.NewOpenAIAPIHandler(s.handlers)
geminiHandlers := gemini.NewGeminiAPIHandler(s.handlers)
geminiCLIHandlers := gemini.NewGeminiCLIAPIHandler(s.handlers)
claudeCodeHandlers := claude.NewClaudeCodeAPIHandler(s.handlers)
openaiResponsesHandlers := openai.NewOpenAIResponsesAPIHandler(s.handlers)
// OpenAI compatible API routes
v1 := s.engine.Group("/v1")
v1.Use(AuthMiddleware(s.cfg))
v1.Use(AuthMiddleware(s.accessManager))
{
v1.GET("/models", s.handlers.Models)
v1.POST("/chat/completions", s.handlers.ChatCompletions)
v1.POST("/messages", s.handlers.ClaudeMessages)
v1.GET("/models", s.unifiedModelsHandler(openaiHandlers, claudeCodeHandlers))
v1.POST("/chat/completions", openaiHandlers.ChatCompletions)
v1.POST("/completions", openaiHandlers.Completions)
v1.POST("/messages", claudeCodeHandlers.ClaudeMessages)
v1.POST("/messages/count_tokens", claudeCodeHandlers.ClaudeCountTokens)
v1.POST("/responses", openaiResponsesHandlers.Responses)
}
// Gemini compatible API routes
v1beta := s.engine.Group("/v1beta")
v1beta.Use(AuthMiddleware(s.cfg))
v1beta.Use(AuthMiddleware(s.accessManager))
{
v1beta.GET("/models", s.handlers.GeminiModels)
v1beta.POST("/models/:action", s.handlers.GeminiHandler)
v1beta.GET("/models/:action", s.handlers.GeminiGetHandler)
v1beta.GET("/models", geminiHandlers.GeminiModels)
v1beta.POST("/models/:action", geminiHandlers.GeminiHandler)
v1beta.GET("/models/:action", geminiHandlers.GeminiGetHandler)
}
// Root endpoint
@@ -87,16 +261,265 @@ func (s *Server) setupRoutes() {
"version": "1.0.0",
"endpoints": []string{
"POST /v1/chat/completions",
"POST /v1/completions",
"GET /v1/models",
},
})
})
s.engine.POST("/v1internal:method", s.handlers.CLIHandler)
s.engine.POST("/v1internal:method", geminiCLIHandlers.CLIHandler)
// OAuth callback endpoints (reuse main server port)
// These endpoints receive provider redirects and persist
// the short-lived code/state for the waiting goroutine.
s.engine.GET("/anthropic/callback", func(c *gin.Context) {
code := c.Query("code")
state := c.Query("state")
errStr := c.Query("error")
// Persist to a temporary file keyed by state
if state != "" {
file := fmt.Sprintf("%s/.oauth-anthropic-%s.oauth", s.cfg.AuthDir, state)
_ = os.WriteFile(file, []byte(fmt.Sprintf(`{"code":"%s","state":"%s","error":"%s"}`, code, state, errStr)), 0o600)
}
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(http.StatusOK, "<html><body><h1>Authentication successful!</h1><p>You can close this window.</p></body></html>")
})
s.engine.GET("/codex/callback", func(c *gin.Context) {
code := c.Query("code")
state := c.Query("state")
errStr := c.Query("error")
if state != "" {
file := fmt.Sprintf("%s/.oauth-codex-%s.oauth", s.cfg.AuthDir, state)
_ = os.WriteFile(file, []byte(fmt.Sprintf(`{"code":"%s","state":"%s","error":"%s"}`, code, state, errStr)), 0o600)
}
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(http.StatusOK, "<html><body><h1>Authentication successful!</h1><p>You can close this window.</p></body></html>")
})
s.engine.GET("/google/callback", func(c *gin.Context) {
code := c.Query("code")
state := c.Query("state")
errStr := c.Query("error")
if state != "" {
file := fmt.Sprintf("%s/.oauth-gemini-%s.oauth", s.cfg.AuthDir, state)
_ = os.WriteFile(file, []byte(fmt.Sprintf(`{"code":"%s","state":"%s","error":"%s"}`, code, state, errStr)), 0o600)
}
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(http.StatusOK, "<html><body><h1>Authentication successful!</h1><p>You can close this window.</p></body></html>")
})
// Management API routes (delegated to management handlers)
// New logic: if remote-management-key is empty, do not expose any management endpoint (404).
if s.cfg.RemoteManagement.SecretKey != "" {
mgmt := s.engine.Group("/v0/management")
mgmt.Use(s.mgmt.Middleware())
{
mgmt.GET("/usage", s.mgmt.GetUsageStatistics)
mgmt.GET("/config", s.mgmt.GetConfig)
mgmt.GET("/debug", s.mgmt.GetDebug)
mgmt.PUT("/debug", s.mgmt.PutDebug)
mgmt.PATCH("/debug", s.mgmt.PutDebug)
mgmt.GET("/logging-to-file", s.mgmt.GetLoggingToFile)
mgmt.PUT("/logging-to-file", s.mgmt.PutLoggingToFile)
mgmt.PATCH("/logging-to-file", s.mgmt.PutLoggingToFile)
mgmt.GET("/usage-statistics-enabled", s.mgmt.GetUsageStatisticsEnabled)
mgmt.PUT("/usage-statistics-enabled", s.mgmt.PutUsageStatisticsEnabled)
mgmt.PATCH("/usage-statistics-enabled", s.mgmt.PutUsageStatisticsEnabled)
mgmt.GET("/proxy-url", s.mgmt.GetProxyURL)
mgmt.PUT("/proxy-url", s.mgmt.PutProxyURL)
mgmt.PATCH("/proxy-url", s.mgmt.PutProxyURL)
mgmt.DELETE("/proxy-url", s.mgmt.DeleteProxyURL)
mgmt.GET("/quota-exceeded/switch-project", s.mgmt.GetSwitchProject)
mgmt.PUT("/quota-exceeded/switch-project", s.mgmt.PutSwitchProject)
mgmt.PATCH("/quota-exceeded/switch-project", s.mgmt.PutSwitchProject)
mgmt.GET("/quota-exceeded/switch-preview-model", s.mgmt.GetSwitchPreviewModel)
mgmt.PUT("/quota-exceeded/switch-preview-model", s.mgmt.PutSwitchPreviewModel)
mgmt.PATCH("/quota-exceeded/switch-preview-model", s.mgmt.PutSwitchPreviewModel)
mgmt.GET("/api-keys", s.mgmt.GetAPIKeys)
mgmt.PUT("/api-keys", s.mgmt.PutAPIKeys)
mgmt.PATCH("/api-keys", s.mgmt.PatchAPIKeys)
mgmt.DELETE("/api-keys", s.mgmt.DeleteAPIKeys)
mgmt.GET("/generative-language-api-key", s.mgmt.GetGlKeys)
mgmt.PUT("/generative-language-api-key", s.mgmt.PutGlKeys)
mgmt.PATCH("/generative-language-api-key", s.mgmt.PatchGlKeys)
mgmt.DELETE("/generative-language-api-key", s.mgmt.DeleteGlKeys)
mgmt.GET("/request-log", s.mgmt.GetRequestLog)
mgmt.PUT("/request-log", s.mgmt.PutRequestLog)
mgmt.PATCH("/request-log", s.mgmt.PutRequestLog)
mgmt.GET("/request-retry", s.mgmt.GetRequestRetry)
mgmt.PUT("/request-retry", s.mgmt.PutRequestRetry)
mgmt.PATCH("/request-retry", s.mgmt.PutRequestRetry)
mgmt.GET("/claude-api-key", s.mgmt.GetClaudeKeys)
mgmt.PUT("/claude-api-key", s.mgmt.PutClaudeKeys)
mgmt.PATCH("/claude-api-key", s.mgmt.PatchClaudeKey)
mgmt.DELETE("/claude-api-key", s.mgmt.DeleteClaudeKey)
mgmt.GET("/codex-api-key", s.mgmt.GetCodexKeys)
mgmt.PUT("/codex-api-key", s.mgmt.PutCodexKeys)
mgmt.PATCH("/codex-api-key", s.mgmt.PatchCodexKey)
mgmt.DELETE("/codex-api-key", s.mgmt.DeleteCodexKey)
mgmt.GET("/openai-compatibility", s.mgmt.GetOpenAICompat)
mgmt.PUT("/openai-compatibility", s.mgmt.PutOpenAICompat)
mgmt.PATCH("/openai-compatibility", s.mgmt.PatchOpenAICompat)
mgmt.DELETE("/openai-compatibility", s.mgmt.DeleteOpenAICompat)
mgmt.GET("/auth-files", s.mgmt.ListAuthFiles)
mgmt.GET("/auth-files/download", s.mgmt.DownloadAuthFile)
mgmt.POST("/auth-files", s.mgmt.UploadAuthFile)
mgmt.DELETE("/auth-files", s.mgmt.DeleteAuthFile)
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("/get-auth-status", s.mgmt.GetAuthStatus)
}
}
}
func (s *Server) serveManagementControlPanel(c *gin.Context) {
cfg := s.cfg
if cfg == nil || cfg.RemoteManagement.DisableControlPanel {
c.AbortWithStatus(http.StatusNotFound)
return
}
filePath := managementasset.FilePath(s.configFilePath)
if strings.TrimSpace(filePath) == "" {
c.AbortWithStatus(http.StatusNotFound)
return
}
if _, err := os.Stat(filePath); err != nil {
if os.IsNotExist(err) {
go managementasset.EnsureLatestManagementHTML(context.Background(), managementasset.StaticDir(s.configFilePath), cfg.ProxyURL)
c.AbortWithStatus(http.StatusNotFound)
return
}
log.WithError(err).Error("failed to stat management control panel asset")
c.AbortWithStatus(http.StatusInternalServerError)
return
}
c.File(filePath)
}
func (s *Server) enableKeepAlive(timeout time.Duration, onTimeout func()) {
if timeout <= 0 || onTimeout == nil {
return
}
s.keepAliveEnabled = true
s.keepAliveTimeout = timeout
s.keepAliveOnTimeout = onTimeout
s.keepAliveHeartbeat = make(chan struct{}, 1)
s.keepAliveStop = make(chan struct{}, 1)
s.engine.GET("/keep-alive", s.handleKeepAlive)
go s.watchKeepAlive()
}
func (s *Server) handleKeepAlive(c *gin.Context) {
if s.localPassword != "" {
provided := strings.TrimSpace(c.GetHeader("Authorization"))
if provided != "" {
parts := strings.SplitN(provided, " ", 2)
if len(parts) == 2 && strings.EqualFold(parts[0], "bearer") {
provided = parts[1]
}
}
if provided == "" {
provided = strings.TrimSpace(c.GetHeader("X-Local-Password"))
}
if subtle.ConstantTimeCompare([]byte(provided), []byte(s.localPassword)) != 1 {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid password"})
return
}
}
s.signalKeepAlive()
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}
func (s *Server) signalKeepAlive() {
if !s.keepAliveEnabled {
return
}
select {
case s.keepAliveHeartbeat <- struct{}{}:
default:
}
}
func (s *Server) watchKeepAlive() {
if !s.keepAliveEnabled {
return
}
timer := time.NewTimer(s.keepAliveTimeout)
defer timer.Stop()
for {
select {
case <-timer.C:
log.Warnf("keep-alive endpoint idle for %s, shutting down", s.keepAliveTimeout)
if s.keepAliveOnTimeout != nil {
s.keepAliveOnTimeout()
}
return
case <-s.keepAliveHeartbeat:
if !timer.Stop() {
select {
case <-timer.C:
default:
}
}
timer.Reset(s.keepAliveTimeout)
case <-s.keepAliveStop:
return
}
}
}
// unifiedModelsHandler creates a unified handler for the /v1/models endpoint
// that routes to different handlers based on the User-Agent header.
// If User-Agent starts with "claude-cli", it routes to Claude handler,
// otherwise it routes to OpenAI handler.
func (s *Server) unifiedModelsHandler(openaiHandler *openai.OpenAIAPIHandler, claudeHandler *claude.ClaudeCodeAPIHandler) gin.HandlerFunc {
return func(c *gin.Context) {
userAgent := c.GetHeader("User-Agent")
// Route to Claude handler if User-Agent starts with "claude-cli"
if strings.HasPrefix(userAgent, "claude-cli") {
// log.Debugf("Routing /v1/models to Claude handler for User-Agent: %s", userAgent)
claudeHandler.ClaudeModels(c)
} else {
// log.Debugf("Routing /v1/models to OpenAI handler for User-Agent: %s", userAgent)
openaiHandler.OpenAIModels(c)
}
}
}
// Start begins listening for and serving HTTP requests.
// It's a blocking call and will only return on an unrecoverable error.
//
// Returns:
// - error: An error if the server fails to start
func (s *Server) Start() error {
log.Debugf("Starting API server on %s", s.server.Addr)
@@ -110,9 +533,22 @@ func (s *Server) Start() error {
// Stop gracefully shuts down the API server without interrupting any
// active connections.
//
// Parameters:
// - ctx: The context for graceful shutdown
//
// Returns:
// - error: An error if the server fails to stop
func (s *Server) Stop(ctx context.Context) error {
log.Debug("Stopping API server...")
if s.keepAliveEnabled {
select {
case s.keepAliveStop <- struct{}{}:
default:
}
}
// Shutdown the HTTP server.
if err := s.server.Shutdown(ctx); err != nil {
return fmt.Errorf("failed to shutdown HTTP server: %v", err)
@@ -124,11 +560,14 @@ func (s *Server) Stop(ctx context.Context) error {
// corsMiddleware returns a Gin middleware handler that adds CORS headers
// to every response, allowing cross-origin requests.
//
// Returns:
// - gin.HandlerFunc: The CORS middleware handler
func corsMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization")
c.Header("Access-Control-Allow-Headers", "*")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(http.StatusNoContent)
@@ -139,57 +578,143 @@ func corsMiddleware() gin.HandlerFunc {
}
}
func (s *Server) applyAccessConfig(oldCfg, newCfg *config.Config) {
if s == nil || s.accessManager == nil || newCfg == nil {
return
}
if _, err := access.ApplyAccessProviders(s.accessManager, oldCfg, newCfg); err != nil {
return
}
}
// UpdateClients updates the server's client list and configuration.
// This method is called when the configuration or authentication tokens change.
//
// Parameters:
// - clients: The new slice of AI service clients
// - cfg: The new application configuration
func (s *Server) UpdateClients(cfg *config.Config) {
oldCfg := s.cfg
// Update request logger enabled state if it has changed
previousRequestLog := false
if oldCfg != nil {
previousRequestLog = oldCfg.RequestLog
}
if s.requestLogger != nil && (oldCfg == nil || previousRequestLog != cfg.RequestLog) {
if s.loggerToggle != nil {
s.loggerToggle(cfg.RequestLog)
} else if toggler, ok := s.requestLogger.(interface{ SetEnabled(bool) }); ok {
toggler.SetEnabled(cfg.RequestLog)
}
if oldCfg != nil {
log.Debugf("request logging updated from %t to %t", previousRequestLog, cfg.RequestLog)
} else {
log.Debugf("request logging toggled to %t", cfg.RequestLog)
}
}
if oldCfg != nil && oldCfg.LoggingToFile != cfg.LoggingToFile {
if err := logging.ConfigureLogOutput(cfg.LoggingToFile); err != nil {
log.Errorf("failed to reconfigure log output: %v", err)
} else {
log.Debugf("logging_to_file updated from %t to %t", oldCfg.LoggingToFile, cfg.LoggingToFile)
}
}
if oldCfg == nil || oldCfg.UsageStatisticsEnabled != cfg.UsageStatisticsEnabled {
usage.SetStatisticsEnabled(cfg.UsageStatisticsEnabled)
if oldCfg != nil {
log.Debugf("usage_statistics_enabled updated from %t to %t", oldCfg.UsageStatisticsEnabled, cfg.UsageStatisticsEnabled)
} else {
log.Debugf("usage_statistics_enabled toggled to %t", cfg.UsageStatisticsEnabled)
}
}
// Update log level dynamically when debug flag changes
if oldCfg == nil || oldCfg.Debug != cfg.Debug {
util.SetLogLevel(cfg)
if oldCfg != nil {
log.Debugf("debug mode updated from %t to %t", oldCfg.Debug, cfg.Debug)
} else {
log.Debugf("debug mode toggled to %t", cfg.Debug)
}
}
s.applyAccessConfig(oldCfg, cfg)
s.cfg = cfg
s.handlers.UpdateClients(&cfg.SDKConfig)
if !cfg.RemoteManagement.DisableControlPanel {
staticDir := managementasset.StaticDir(s.configFilePath)
go managementasset.EnsureLatestManagementHTML(context.Background(), staticDir, cfg.ProxyURL)
}
if s.mgmt != nil {
s.mgmt.SetConfig(cfg)
s.mgmt.SetAuthManager(s.handlers.AuthManager)
}
// Count client sources from configuration and auth directory
authFiles := util.CountAuthFiles(cfg.AuthDir)
glAPIKeyCount := len(cfg.GlAPIKey)
claudeAPIKeyCount := len(cfg.ClaudeKey)
codexAPIKeyCount := len(cfg.CodexKey)
openAICompatCount := 0
for i := range cfg.OpenAICompatibility {
entry := cfg.OpenAICompatibility[i]
if len(entry.APIKeyEntries) > 0 {
openAICompatCount += len(entry.APIKeyEntries)
continue
}
openAICompatCount += len(entry.APIKeys)
}
total := authFiles + glAPIKeyCount + claudeAPIKeyCount + codexAPIKeyCount + openAICompatCount
fmt.Printf("server clients and configuration updated: %d clients (%d auth files + %d GL API keys + %d Claude API keys + %d Codex keys + %d OpenAI-compat)\n",
total,
authFiles,
glAPIKeyCount,
claudeAPIKeyCount,
codexAPIKeyCount,
openAICompatCount,
)
}
// (management handlers moved to internal/api/handlers/management)
// AuthMiddleware returns a Gin middleware handler that authenticates requests
// using API keys. If no API keys are configured, it allows all requests.
func AuthMiddleware(cfg *config.Config) gin.HandlerFunc {
// using the configured authentication providers. When no providers are available,
// it allows all requests (legacy behaviour).
func AuthMiddleware(manager *sdkaccess.Manager) gin.HandlerFunc {
return func(c *gin.Context) {
if len(cfg.ApiKeys) == 0 {
if manager == nil {
c.Next()
return
}
// Get the Authorization header
authHeader := c.GetHeader("Authorization")
authHeaderGoogle := c.GetHeader("X-Goog-Api-Key")
authHeaderAnthropic := c.GetHeader("X-Api-Key")
// Get the API key from the query parameter
apiKeyQuery, _ := c.GetQuery("key")
if authHeader == "" && authHeaderGoogle == "" && authHeaderAnthropic == "" && apiKeyQuery == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "Missing API key",
})
return
}
// Extract the API key
parts := strings.Split(authHeader, " ")
var apiKey string
if len(parts) == 2 && strings.ToLower(parts[0]) == "bearer" {
apiKey = parts[1]
} else {
apiKey = authHeader
}
// Find the API key in the in-memory list
var foundKey string
for i := range cfg.ApiKeys {
if cfg.ApiKeys[i] == apiKey || cfg.ApiKeys[i] == authHeaderGoogle || cfg.ApiKeys[i] == authHeaderAnthropic || cfg.ApiKeys[i] == apiKeyQuery {
foundKey = cfg.ApiKeys[i]
break
result, err := manager.Authenticate(c.Request.Context(), c.Request)
if err == nil {
if result != nil {
c.Set("apiKey", result.Principal)
c.Set("accessProvider", result.Provider)
if len(result.Metadata) > 0 {
c.Set("accessMetadata", result.Metadata)
}
}
}
if foundKey == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "Invalid API key",
})
c.Next()
return
}
// Store the API key and user in the context
c.Set("apiKey", foundKey)
c.Next()
switch {
case errors.Is(err, sdkaccess.ErrNoCredentials):
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Missing API key"})
case errors.Is(err, sdkaccess.ErrInvalidCredential):
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "Invalid API key"})
default:
log.Errorf("authentication middleware error: %v", err)
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": "Authentication service error"})
}
}
}
// legacy clientsToSlice removed; handlers no longer consume legacy client slices

View File

@@ -1,544 +0,0 @@
package translator
import (
"bytes"
"encoding/json"
"fmt"
"github.com/tidwall/sjson"
"strings"
"github.com/luispater/CLIProxyAPI/internal/client"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
)
// PrepareRequest translates a raw JSON request from an OpenAI-compatible format
// to the internal format expected by the backend client. It parses messages,
// roles, content types (text, image, file), and tool calls.
func PrepareRequest(rawJson []byte) (string, *client.Content, []client.Content, []client.ToolDeclaration) {
// Extract the model name from the request, defaulting to "gemini-2.5-pro".
modelName := "gemini-2.5-pro"
modelResult := gjson.GetBytes(rawJson, "model")
if modelResult.Type == gjson.String {
modelName = modelResult.String()
}
// Initialize data structures for processing conversation messages
// contents: stores the processed conversation history
// systemInstruction: stores system-level instructions separate from conversation
contents := make([]client.Content, 0)
var systemInstruction *client.Content
messagesResult := gjson.GetBytes(rawJson, "messages")
// Pre-process tool responses to create a lookup map
// This first pass collects all tool responses so they can be matched with their corresponding calls
toolItems := make(map[string]*client.FunctionResponse)
if messagesResult.IsArray() {
messagesResults := messagesResult.Array()
for i := 0; i < len(messagesResults); i++ {
messageResult := messagesResults[i]
roleResult := messageResult.Get("role")
if roleResult.Type != gjson.String {
continue
}
contentResult := messageResult.Get("content")
// Extract tool responses for later matching with function calls
if roleResult.String() == "tool" {
toolCallID := messageResult.Get("tool_call_id").String()
if toolCallID != "" {
var responseData string
// Handle both string and object-based tool response formats
if contentResult.Type == gjson.String {
responseData = contentResult.String()
} else if contentResult.IsObject() && contentResult.Get("type").String() == "text" {
responseData = contentResult.Get("text").String()
}
// Clean up tool call ID by removing timestamp suffix
// This normalizes IDs for consistent matching between calls and responses
toolCallIDs := strings.Split(toolCallID, "-")
strings.Join(toolCallIDs, "-")
newToolCallID := strings.Join(toolCallIDs[:len(toolCallIDs)-1], "-")
// Create function response object with normalized ID and response data
functionResponse := client.FunctionResponse{Name: newToolCallID, Response: map[string]interface{}{"result": responseData}}
toolItems[toolCallID] = &functionResponse
}
}
}
}
if messagesResult.IsArray() {
messagesResults := messagesResult.Array()
for i := 0; i < len(messagesResults); i++ {
messageResult := messagesResults[i]
roleResult := messageResult.Get("role")
contentResult := messageResult.Get("content")
if roleResult.Type != gjson.String {
continue
}
switch roleResult.String() {
// System messages are converted to a user message followed by a model's acknowledgment.
case "system":
if contentResult.Type == gjson.String {
systemInstruction = &client.Content{Role: "user", Parts: []client.Part{{Text: contentResult.String()}}}
} else if contentResult.IsObject() {
// Handle object-based system messages.
if contentResult.Get("type").String() == "text" {
systemInstruction = &client.Content{Role: "user", Parts: []client.Part{{Text: contentResult.Get("text").String()}}}
}
}
// User messages can contain simple text or a multi-part body.
case "user":
if contentResult.Type == gjson.String {
contents = append(contents, client.Content{Role: "user", Parts: []client.Part{{Text: contentResult.String()}}})
} else if contentResult.IsArray() {
// Handle multi-part user messages (text, images, files).
contentItemResults := contentResult.Array()
parts := make([]client.Part, 0)
for j := 0; j < len(contentItemResults); j++ {
contentItemResult := contentItemResults[j]
contentTypeResult := contentItemResult.Get("type")
switch contentTypeResult.String() {
case "text":
parts = append(parts, client.Part{Text: contentItemResult.Get("text").String()})
case "image_url":
// Parse data URI for images.
imageURL := contentItemResult.Get("image_url.url").String()
if len(imageURL) > 5 {
imageURLs := strings.SplitN(imageURL[5:], ";", 2)
if len(imageURLs) == 2 && len(imageURLs[1]) > 7 {
parts = append(parts, client.Part{InlineData: &client.InlineData{
MimeType: imageURLs[0],
Data: imageURLs[1][7:],
}})
}
}
case "file":
// Handle file attachments by determining MIME type from extension.
filename := contentItemResult.Get("file.filename").String()
fileData := contentItemResult.Get("file.file_data").String()
ext := ""
if split := strings.Split(filename, "."); len(split) > 1 {
ext = split[len(split)-1]
}
if mimeType, ok := MimeTypes[ext]; ok {
parts = append(parts, client.Part{InlineData: &client.InlineData{
MimeType: mimeType,
Data: fileData,
}})
} else {
log.Warnf("Unknown file name extension '%s' at index %d, skipping file", ext, j)
}
}
}
contents = append(contents, client.Content{Role: "user", Parts: parts})
}
// Assistant messages can contain text responses or tool calls
// In the internal format, assistant messages are converted to "model" role
case "assistant":
if contentResult.Type == gjson.String {
// Simple text response from the assistant
contents = append(contents, client.Content{Role: "model", Parts: []client.Part{{Text: contentResult.String()}}})
} else if !contentResult.Exists() || contentResult.Type == gjson.Null {
// Handle complex tool calls made by the assistant
// This processes function calls and matches them with their responses
functionIDs := make([]string, 0)
toolCallsResult := messageResult.Get("tool_calls")
if toolCallsResult.IsArray() {
parts := make([]client.Part, 0)
tcsResult := toolCallsResult.Array()
// Process each tool call in the assistant's message
for j := 0; j < len(tcsResult); j++ {
tcResult := tcsResult[j]
// Extract function call details
functionID := tcResult.Get("id").String()
functionIDs = append(functionIDs, functionID)
functionName := tcResult.Get("function.name").String()
functionArgs := tcResult.Get("function.arguments").String()
// Parse function arguments from JSON string to map
var args map[string]any
if err := json.Unmarshal([]byte(functionArgs), &args); err == nil {
parts = append(parts, client.Part{
FunctionCall: &client.FunctionCall{
Name: functionName,
Args: args,
},
})
}
}
// Add the model's function calls to the conversation
if len(parts) > 0 {
contents = append(contents, client.Content{
Role: "model", Parts: parts,
})
// Create a separate tool response message with the collected responses
// This matches function calls with their corresponding responses
toolParts := make([]client.Part, 0)
for _, functionID := range functionIDs {
if functionResponse, ok := toolItems[functionID]; ok {
toolParts = append(toolParts, client.Part{FunctionResponse: functionResponse})
}
}
// Add the tool responses as a separate message in the conversation
contents = append(contents, client.Content{Role: "tool", Parts: toolParts})
}
}
}
}
}
}
// Translate the tool declarations from the request.
var tools []client.ToolDeclaration
toolsResult := gjson.GetBytes(rawJson, "tools")
if toolsResult.IsArray() {
tools = make([]client.ToolDeclaration, 1)
tools[0].FunctionDeclarations = make([]any, 0)
toolsResults := toolsResult.Array()
for i := 0; i < len(toolsResults); i++ {
toolResult := toolsResults[i]
if toolResult.Get("type").String() == "function" {
functionTypeResult := toolResult.Get("function")
if functionTypeResult.Exists() && functionTypeResult.IsObject() {
var functionDeclaration any
if err := json.Unmarshal([]byte(functionTypeResult.Raw), &functionDeclaration); err == nil {
tools[0].FunctionDeclarations = append(tools[0].FunctionDeclarations, functionDeclaration)
}
}
}
}
} else {
tools = make([]client.ToolDeclaration, 0)
}
return modelName, systemInstruction, contents, tools
}
// FunctionCallGroup represents a group of function calls and their responses
type FunctionCallGroup struct {
ModelContent map[string]interface{}
FunctionCalls []gjson.Result
ResponsesNeeded int
}
// FixCLIToolResponse performs sophisticated tool response format conversion and grouping.
// This function transforms the CLI tool response format by intelligently grouping function calls
// with their corresponding responses, ensuring proper conversation flow and API compatibility.
// It converts from a linear format (1.json) to a grouped format (2.json) where function calls
// and their responses are properly associated and structured.
func FixCLIToolResponse(input string) (string, error) {
// Parse the input JSON to extract the conversation structure
parsed := gjson.Parse(input)
// Extract the contents array which contains the conversation messages
contents := parsed.Get("request.contents")
if !contents.Exists() {
return input, fmt.Errorf("contents not found in input")
}
// Initialize data structures for processing and grouping
var newContents []interface{} // Final processed contents array
var pendingGroups []*FunctionCallGroup // Groups awaiting completion with responses
var collectedResponses []gjson.Result // Standalone responses to be matched
// Process each content object in the conversation
// This iterates through messages and groups function calls with their responses
contents.ForEach(func(key, value gjson.Result) bool {
role := value.Get("role").String()
parts := value.Get("parts")
// Check if this content has function responses
var responsePartsInThisContent []gjson.Result
parts.ForEach(func(_, part gjson.Result) bool {
if part.Get("functionResponse").Exists() {
responsePartsInThisContent = append(responsePartsInThisContent, part)
}
return true
})
// If this content has function responses, collect them
if len(responsePartsInThisContent) > 0 {
collectedResponses = append(collectedResponses, responsePartsInThisContent...)
// Check if any pending groups can be satisfied
for i := len(pendingGroups) - 1; i >= 0; i-- {
group := pendingGroups[i]
if len(collectedResponses) >= group.ResponsesNeeded {
// Take the needed responses for this group
groupResponses := collectedResponses[:group.ResponsesNeeded]
collectedResponses = collectedResponses[group.ResponsesNeeded:]
// Create merged function response content
var responseParts []interface{}
for _, response := range groupResponses {
var responseMap map[string]interface{}
errUnmarshal := json.Unmarshal([]byte(response.Raw), &responseMap)
if errUnmarshal != nil {
log.Warnf("failed to unmarshal function response: %v\n", errUnmarshal)
continue
}
responseParts = append(responseParts, responseMap)
}
if len(responseParts) > 0 {
functionResponseContent := map[string]interface{}{
"parts": responseParts,
"role": "function",
}
newContents = append(newContents, functionResponseContent)
}
// Remove this group as it's been satisfied
pendingGroups = append(pendingGroups[:i], pendingGroups[i+1:]...)
break
}
}
return true // Skip adding this content, responses are merged
}
// If this is a model with function calls, create a new group
if role == "model" {
var functionCallsInThisModel []gjson.Result
parts.ForEach(func(_, part gjson.Result) bool {
if part.Get("functionCall").Exists() {
functionCallsInThisModel = append(functionCallsInThisModel, part)
}
return true
})
if len(functionCallsInThisModel) > 0 {
// Add the model content
var contentMap map[string]interface{}
errUnmarshal := json.Unmarshal([]byte(value.Raw), &contentMap)
if errUnmarshal != nil {
log.Warnf("failed to unmarshal model content: %v\n", errUnmarshal)
return true
}
newContents = append(newContents, contentMap)
// Create a new group for tracking responses
group := &FunctionCallGroup{
ModelContent: contentMap,
FunctionCalls: functionCallsInThisModel,
ResponsesNeeded: len(functionCallsInThisModel),
}
pendingGroups = append(pendingGroups, group)
} else {
// Regular model content without function calls
var contentMap map[string]interface{}
errUnmarshal := json.Unmarshal([]byte(value.Raw), &contentMap)
if errUnmarshal != nil {
log.Warnf("failed to unmarshal content: %v\n", errUnmarshal)
return true
}
newContents = append(newContents, contentMap)
}
} else {
// Non-model content (user, etc.)
var contentMap map[string]interface{}
errUnmarshal := json.Unmarshal([]byte(value.Raw), &contentMap)
if errUnmarshal != nil {
log.Warnf("failed to unmarshal content: %v\n", errUnmarshal)
return true
}
newContents = append(newContents, contentMap)
}
return true
})
// Handle any remaining pending groups with remaining responses
for _, group := range pendingGroups {
if len(collectedResponses) >= group.ResponsesNeeded {
groupResponses := collectedResponses[:group.ResponsesNeeded]
collectedResponses = collectedResponses[group.ResponsesNeeded:]
var responseParts []interface{}
for _, response := range groupResponses {
var responseMap map[string]interface{}
errUnmarshal := json.Unmarshal([]byte(response.Raw), &responseMap)
if errUnmarshal != nil {
log.Warnf("failed to unmarshal function response: %v\n", errUnmarshal)
continue
}
responseParts = append(responseParts, responseMap)
}
if len(responseParts) > 0 {
functionResponseContent := map[string]interface{}{
"parts": responseParts,
"role": "function",
}
newContents = append(newContents, functionResponseContent)
}
}
}
// Update the original JSON with the new contents
result := input
newContentsJSON, _ := json.Marshal(newContents)
result, _ = sjson.Set(result, "request.contents", json.RawMessage(newContentsJSON))
return result, nil
}
func PrepareClaudeRequest(rawJson []byte) (string, *client.Content, []client.Content, []client.ToolDeclaration) {
var pathsToDelete []string
root := gjson.ParseBytes(rawJson)
walk(root, "", "additionalProperties", &pathsToDelete)
walk(root, "", "$schema", &pathsToDelete)
var err error
for _, p := range pathsToDelete {
rawJson, err = sjson.DeleteBytes(rawJson, p)
if err != nil {
continue
}
}
rawJson = bytes.Replace(rawJson, []byte(`"url":{"type":"string","format":"uri",`), []byte(`"url":{"type":"string",`), -1)
// log.Debug(string(rawJson))
modelName := "gemini-2.5-pro"
modelResult := gjson.GetBytes(rawJson, "model")
if modelResult.Type == gjson.String {
modelName = modelResult.String()
}
contents := make([]client.Content, 0)
var systemInstruction *client.Content
systemResult := gjson.GetBytes(rawJson, "system")
if systemResult.IsArray() {
systemResults := systemResult.Array()
systemInstruction = &client.Content{Role: "user", Parts: []client.Part{}}
for i := 0; i < len(systemResults); i++ {
systemPromptResult := systemResults[i]
systemTypePromptResult := systemPromptResult.Get("type")
if systemTypePromptResult.Type == gjson.String && systemTypePromptResult.String() == "text" {
systemPrompt := systemPromptResult.Get("text").String()
systemPart := client.Part{Text: systemPrompt}
systemInstruction.Parts = append(systemInstruction.Parts, systemPart)
}
}
if len(systemInstruction.Parts) == 0 {
systemInstruction = nil
}
}
messagesResult := gjson.GetBytes(rawJson, "messages")
if messagesResult.IsArray() {
messageResults := messagesResult.Array()
for i := 0; i < len(messageResults); i++ {
messageResult := messageResults[i]
roleResult := messageResult.Get("role")
if roleResult.Type != gjson.String {
continue
}
role := roleResult.String()
if role == "assistant" {
role = "model"
}
clientContent := client.Content{Role: role, Parts: []client.Part{}}
contentsResult := messageResult.Get("content")
if contentsResult.IsArray() {
contentResults := contentsResult.Array()
for j := 0; j < len(contentResults); j++ {
contentResult := contentResults[j]
contentTypeResult := contentResult.Get("type")
if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "text" {
prompt := contentResult.Get("text").String()
clientContent.Parts = append(clientContent.Parts, client.Part{Text: prompt})
} else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "tool_use" {
functionName := contentResult.Get("name").String()
functionArgs := contentResult.Get("input").String()
var args map[string]any
if err = json.Unmarshal([]byte(functionArgs), &args); err == nil {
clientContent.Parts = append(clientContent.Parts, client.Part{
FunctionCall: &client.FunctionCall{
Name: functionName,
Args: args,
},
})
}
} else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "tool_result" {
toolCallID := contentResult.Get("tool_use_id").String()
if toolCallID != "" {
funcName := toolCallID
toolCallIDs := strings.Split(toolCallID, "-")
if len(toolCallIDs) > 1 {
funcName = strings.Join(toolCallIDs[0:len(toolCallIDs)-1], "-")
}
responseData := contentResult.Get("content").String()
functionResponse := client.FunctionResponse{Name: funcName, Response: map[string]interface{}{"result": responseData}}
clientContent.Parts = append(clientContent.Parts, client.Part{FunctionResponse: &functionResponse})
}
}
}
contents = append(contents, clientContent)
} else if contentsResult.Type == gjson.String {
prompt := contentsResult.String()
contents = append(contents, client.Content{Role: role, Parts: []client.Part{{Text: prompt}}})
}
}
}
var tools []client.ToolDeclaration
toolsResult := gjson.GetBytes(rawJson, "tools")
if toolsResult.IsArray() {
tools = make([]client.ToolDeclaration, 1)
tools[0].FunctionDeclarations = make([]any, 0)
toolsResults := toolsResult.Array()
for i := 0; i < len(toolsResults); i++ {
toolResult := toolsResults[i]
inputSchemaResult := toolResult.Get("input_schema")
if inputSchemaResult.Exists() && inputSchemaResult.IsObject() {
inputSchema := inputSchemaResult.Raw
inputSchema, _ = sjson.Delete(inputSchema, "additionalProperties")
inputSchema, _ = sjson.Delete(inputSchema, "$schema")
tool, _ := sjson.Delete(toolResult.Raw, "input_schema")
tool, _ = sjson.SetRaw(tool, "parameters", inputSchema)
var toolDeclaration any
if err = json.Unmarshal([]byte(tool), &toolDeclaration); err == nil {
tools[0].FunctionDeclarations = append(tools[0].FunctionDeclarations, toolDeclaration)
}
}
}
} else {
tools = make([]client.ToolDeclaration, 0)
}
return modelName, systemInstruction, contents, tools
}
func walk(value gjson.Result, path, field string, pathsToDelete *[]string) {
switch value.Type {
case gjson.JSON:
value.ForEach(func(key, val gjson.Result) bool {
var childPath string
if path == "" {
childPath = key.String()
} else {
childPath = path + "." + key.String()
}
if key.String() == field {
*pathsToDelete = append(*pathsToDelete, childPath)
}
walk(val, childPath, field, pathsToDelete)
return true
})
case gjson.String, gjson.Number, gjson.True, gjson.False, gjson.Null:
}
}

View File

@@ -1,382 +0,0 @@
package translator
import (
"bytes"
"fmt"
"time"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// ConvertCliToOpenAI translates a single chunk of a streaming response from the
// backend client format to the OpenAI Server-Sent Events (SSE) format.
// It returns an empty string if the chunk contains no useful data.
func ConvertCliToOpenAI(rawJson []byte, unixTimestamp int64, isGlAPIKey bool) string {
if isGlAPIKey {
rawJson, _ = sjson.SetRawBytes(rawJson, "response", rawJson)
}
// Initialize the OpenAI SSE template.
template := `{"id":"","object":"chat.completion.chunk","created":12345,"model":"model","choices":[{"index":0,"delta":{"role":null,"content":null,"reasoning_content":null,"tool_calls":null},"finish_reason":null,"native_finish_reason":null}]}`
// Extract and set the model version.
if modelVersionResult := gjson.GetBytes(rawJson, "response.modelVersion"); modelVersionResult.Exists() {
template, _ = sjson.Set(template, "model", modelVersionResult.String())
}
// Extract and set the creation timestamp.
if createTimeResult := gjson.GetBytes(rawJson, "response.createTime"); createTimeResult.Exists() {
t, err := time.Parse(time.RFC3339Nano, createTimeResult.String())
if err == nil {
unixTimestamp = t.Unix()
}
template, _ = sjson.Set(template, "created", unixTimestamp)
} else {
template, _ = sjson.Set(template, "created", unixTimestamp)
}
// Extract and set the response ID.
if responseIdResult := gjson.GetBytes(rawJson, "response.responseId"); responseIdResult.Exists() {
template, _ = sjson.Set(template, "id", responseIdResult.String())
}
// Extract and set the finish reason.
if finishReasonResult := gjson.GetBytes(rawJson, "response.candidates.0.finishReason"); finishReasonResult.Exists() {
template, _ = sjson.Set(template, "choices.0.finish_reason", finishReasonResult.String())
template, _ = sjson.Set(template, "choices.0.native_finish_reason", finishReasonResult.String())
}
// Extract and set usage metadata (token counts).
if usageResult := gjson.GetBytes(rawJson, "response.usageMetadata"); usageResult.Exists() {
if candidatesTokenCountResult := usageResult.Get("candidatesTokenCount"); candidatesTokenCountResult.Exists() {
template, _ = sjson.Set(template, "usage.completion_tokens", candidatesTokenCountResult.Int())
}
if totalTokenCountResult := usageResult.Get("totalTokenCount"); totalTokenCountResult.Exists() {
template, _ = sjson.Set(template, "usage.total_tokens", totalTokenCountResult.Int())
}
promptTokenCount := usageResult.Get("promptTokenCount").Int()
thoughtsTokenCount := usageResult.Get("thoughtsTokenCount").Int()
template, _ = sjson.Set(template, "usage.prompt_tokens", promptTokenCount+thoughtsTokenCount)
if thoughtsTokenCount > 0 {
template, _ = sjson.Set(template, "usage.completion_tokens_details.reasoning_tokens", thoughtsTokenCount)
}
}
// Process the main content part of the response.
partsResult := gjson.GetBytes(rawJson, "response.candidates.0.content.parts")
if partsResult.IsArray() {
partResults := partsResult.Array()
for i := 0; i < len(partResults); i++ {
partResult := partResults[i]
partTextResult := partResult.Get("text")
functionCallResult := partResult.Get("functionCall")
if partTextResult.Exists() {
// Handle text content, distinguishing between regular content and reasoning/thoughts.
if partResult.Get("thought").Bool() {
template, _ = sjson.Set(template, "choices.0.delta.reasoning_content", partTextResult.String())
} else {
template, _ = sjson.Set(template, "choices.0.delta.content", partTextResult.String())
}
template, _ = sjson.Set(template, "choices.0.delta.role", "assistant")
} else if functionCallResult.Exists() {
// Handle function call content.
toolCallsResult := gjson.Get(template, "choices.0.delta.tool_calls")
if !toolCallsResult.Exists() || !toolCallsResult.IsArray() {
template, _ = sjson.SetRaw(template, "choices.0.delta.tool_calls", `[]`)
}
functionCallTemplate := `{"id": "","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, "function.name", fcName)
if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() {
functionCallTemplate, _ = sjson.Set(functionCallTemplate, "function.arguments", fcArgsResult.Raw)
}
template, _ = sjson.Set(template, "choices.0.delta.role", "assistant")
template, _ = sjson.SetRaw(template, "choices.0.message.tool_calls.-1", functionCallTemplate)
}
}
}
return template
}
// ConvertCliToOpenAINonStream aggregates response from the backend client
// convert a single, non-streaming OpenAI-compatible JSON response.
func ConvertCliToOpenAINonStream(rawJson []byte, unixTimestamp int64, isGlAPIKey bool) string {
if isGlAPIKey {
rawJson, _ = sjson.SetRawBytes(rawJson, "response", rawJson)
}
template := `{"id":"","object":"chat.completion","created":123456,"model":"model","choices":[{"index":0,"message":{"role":"assistant","content":null,"reasoning_content":null,"tool_calls":null},"finish_reason":null,"native_finish_reason":null}]}`
if modelVersionResult := gjson.GetBytes(rawJson, "response.modelVersion"); modelVersionResult.Exists() {
template, _ = sjson.Set(template, "model", modelVersionResult.String())
}
if createTimeResult := gjson.GetBytes(rawJson, "response.createTime"); createTimeResult.Exists() {
t, err := time.Parse(time.RFC3339Nano, createTimeResult.String())
if err == nil {
unixTimestamp = t.Unix()
}
template, _ = sjson.Set(template, "created", unixTimestamp)
} else {
template, _ = sjson.Set(template, "created", unixTimestamp)
}
if responseIdResult := gjson.GetBytes(rawJson, "response.responseId"); responseIdResult.Exists() {
template, _ = sjson.Set(template, "id", responseIdResult.String())
}
if finishReasonResult := gjson.GetBytes(rawJson, "response.candidates.0.finishReason"); finishReasonResult.Exists() {
template, _ = sjson.Set(template, "choices.0.finish_reason", finishReasonResult.String())
template, _ = sjson.Set(template, "choices.0.native_finish_reason", finishReasonResult.String())
}
if usageResult := gjson.GetBytes(rawJson, "response.usageMetadata"); usageResult.Exists() {
if candidatesTokenCountResult := usageResult.Get("candidatesTokenCount"); candidatesTokenCountResult.Exists() {
template, _ = sjson.Set(template, "usage.completion_tokens", candidatesTokenCountResult.Int())
}
if totalTokenCountResult := usageResult.Get("totalTokenCount"); totalTokenCountResult.Exists() {
template, _ = sjson.Set(template, "usage.total_tokens", totalTokenCountResult.Int())
}
promptTokenCount := usageResult.Get("promptTokenCount").Int()
thoughtsTokenCount := usageResult.Get("thoughtsTokenCount").Int()
template, _ = sjson.Set(template, "usage.prompt_tokens", promptTokenCount+thoughtsTokenCount)
if thoughtsTokenCount > 0 {
template, _ = sjson.Set(template, "usage.completion_tokens_details.reasoning_tokens", thoughtsTokenCount)
}
}
// Process the main content part of the response.
partsResult := gjson.GetBytes(rawJson, "response.candidates.0.content.parts")
if partsResult.IsArray() {
partsResults := partsResult.Array()
for i := 0; i < len(partsResults); i++ {
partResult := partsResults[i]
partTextResult := partResult.Get("text")
functionCallResult := partResult.Get("functionCall")
if partTextResult.Exists() {
// Append text content, distinguishing between regular content and reasoning.
if partResult.Get("thought").Bool() {
template, _ = sjson.Set(template, "choices.0.message.reasoning_content", partTextResult.String())
} else {
template, _ = sjson.Set(template, "choices.0.message.content", partTextResult.String())
}
template, _ = sjson.Set(template, "choices.0.message.role", "assistant")
} else if functionCallResult.Exists() {
// Append function call content to the tool_calls array.
toolCallsResult := gjson.Get(template, "choices.0.message.tool_calls")
if !toolCallsResult.Exists() || !toolCallsResult.IsArray() {
template, _ = sjson.SetRaw(template, "choices.0.message.tool_calls", `[]`)
}
functionCallItemTemplate := `{"id": "","type": "function","function": {"name": "","arguments": ""}}`
fcName := functionCallResult.Get("name").String()
functionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, "id", fmt.Sprintf("%s-%d", fcName, time.Now().UnixNano()))
functionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, "function.name", fcName)
if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() {
functionCallItemTemplate, _ = sjson.Set(functionCallItemTemplate, "function.arguments", fcArgsResult.Raw)
}
template, _ = sjson.Set(template, "choices.0.message.role", "assistant")
template, _ = sjson.SetRaw(template, "choices.0.message.tool_calls.-1", functionCallItemTemplate)
} else {
// If no usable content is found, return an empty string.
return ""
}
}
}
return template
}
// ConvertCliToClaude performs sophisticated streaming response format conversion.
// This function implements a complex state machine that translates backend client responses
// into Claude-compatible Server-Sent Events (SSE) format. It manages different response types
// and handles state transitions between content blocks, thinking processes, and function calls.
//
// Response type states: 0=none, 1=content, 2=thinking, 3=function
// The function maintains state across multiple calls to ensure proper SSE event sequencing.
func ConvertCliToClaude(rawJson []byte, isGlAPIKey, hasFirstResponse bool, responseType, responseIndex *int) string {
// Normalize the response format for different API key types
// Generative Language API keys have a different response structure
if isGlAPIKey {
rawJson, _ = sjson.SetRawBytes(rawJson, "response", rawJson)
}
// Track whether tools are being used in this response chunk
usedTool := false
output := ""
// Initialize the streaming session with a message_start event
// This is only sent for the very first response chunk
if !hasFirstResponse {
output = "event: message_start\n"
// Create the initial message structure with default values
// This follows the Claude API specification for streaming message initialization
messageStartTemplate := `{"type": "message_start", "message": {"id": "msg_1nZdL29xx5MUA1yADyHTEsnR8uuvGzszyY", "type": "message", "role": "assistant", "content": [], "model": "claude-3-5-sonnet-20241022", "stop_reason": null, "stop_sequence": null, "usage": {"input_tokens": 0, "output_tokens": 0}}}`
// Override default values with actual response metadata if available
if modelVersionResult := gjson.GetBytes(rawJson, "response.modelVersion"); modelVersionResult.Exists() {
messageStartTemplate, _ = sjson.Set(messageStartTemplate, "message.model", modelVersionResult.String())
}
if responseIdResult := gjson.GetBytes(rawJson, "response.responseId"); responseIdResult.Exists() {
messageStartTemplate, _ = sjson.Set(messageStartTemplate, "message.id", responseIdResult.String())
}
output = output + fmt.Sprintf("data: %s\n\n\n", messageStartTemplate)
}
// Process the response parts array from the backend client
// Each part can contain text content, thinking content, or function calls
partsResult := gjson.GetBytes(rawJson, "response.candidates.0.content.parts")
if partsResult.IsArray() {
partResults := partsResult.Array()
for i := 0; i < len(partResults); i++ {
partResult := partResults[i]
// Extract the different types of content from each part
partTextResult := partResult.Get("text")
functionCallResult := partResult.Get("functionCall")
// Handle text content (both regular content and thinking)
if partTextResult.Exists() {
// Process thinking content (internal reasoning)
if partResult.Get("thought").Bool() {
// Continue existing thinking block
if *responseType == 2 {
output = output + "event: content_block_delta\n"
data, _ := sjson.Set(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"thinking_delta","thinking":""}}`, *responseIndex), "delta.thinking", partTextResult.String())
output = output + fmt.Sprintf("data: %s\n\n\n", data)
} else {
// Transition from another state to thinking
// First, close any existing content block
if *responseType != 0 {
if *responseType == 2 {
output = output + "event: content_block_delta\n"
output = output + fmt.Sprintf(`data: {"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":null}}`, *responseIndex)
output = output + "\n\n\n"
}
output = output + "event: content_block_stop\n"
output = output + fmt.Sprintf(`data: {"type":"content_block_stop","index":%d}`, *responseIndex)
output = output + "\n\n\n"
*responseIndex++
}
// Start a new thinking content block
output = output + "event: content_block_start\n"
output = output + fmt.Sprintf(`data: {"type":"content_block_start","index":%d,"content_block":{"type":"thinking","thinking":""}}`, *responseIndex)
output = output + "\n\n\n"
output = output + "event: content_block_delta\n"
data, _ := sjson.Set(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"thinking_delta","thinking":""}}`, *responseIndex), "delta.thinking", partTextResult.String())
output = output + fmt.Sprintf("data: %s\n\n\n", data)
*responseType = 2 // Set state to thinking
}
} else {
// Process regular text content (user-visible output)
// Continue existing text block
if *responseType == 1 {
output = output + "event: content_block_delta\n"
data, _ := sjson.Set(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"text_delta","text":""}}`, *responseIndex), "delta.text", partTextResult.String())
output = output + fmt.Sprintf("data: %s\n\n\n", data)
} else {
// Transition from another state to text content
// First, close any existing content block
if *responseType != 0 {
if *responseType == 2 {
output = output + "event: content_block_delta\n"
output = output + fmt.Sprintf(`data: {"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":null}}`, *responseIndex)
output = output + "\n\n\n"
}
output = output + "event: content_block_stop\n"
output = output + fmt.Sprintf(`data: {"type":"content_block_stop","index":%d}`, *responseIndex)
output = output + "\n\n\n"
*responseIndex++
}
// Start a new text content block
output = output + "event: content_block_start\n"
output = output + fmt.Sprintf(`data: {"type":"content_block_start","index":%d,"content_block":{"type":"text","text":""}}`, *responseIndex)
output = output + "\n\n\n"
output = output + "event: content_block_delta\n"
data, _ := sjson.Set(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"text_delta","text":""}}`, *responseIndex), "delta.text", partTextResult.String())
output = output + fmt.Sprintf("data: %s\n\n\n", data)
*responseType = 1 // Set state to content
}
}
} else if functionCallResult.Exists() {
// Handle function/tool calls from the AI model
// This processes tool usage requests and formats them for Claude API compatibility
usedTool = true
fcName := functionCallResult.Get("name").String()
// Handle state transitions when switching to function calls
// Close any existing function call block first
if *responseType == 3 {
output = output + "event: content_block_stop\n"
output = output + fmt.Sprintf(`data: {"type":"content_block_stop","index":%d}`, *responseIndex)
output = output + "\n\n\n"
*responseIndex++
*responseType = 0
}
// Special handling for thinking state transition
if *responseType == 2 {
output = output + "event: content_block_delta\n"
output = output + fmt.Sprintf(`data: {"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":null}}`, *responseIndex)
output = output + "\n\n\n"
}
// Close any other existing content block
if *responseType != 0 {
output = output + "event: content_block_stop\n"
output = output + fmt.Sprintf(`data: {"type":"content_block_stop","index":%d}`, *responseIndex)
output = output + "\n\n\n"
*responseIndex++
}
// Start a new tool use content block
// This creates the structure for a function call in Claude format
output = output + "event: content_block_start\n"
// Create the tool use block with unique ID and function details
data := fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"tool_use","id":"","name":"","input":{}}}`, *responseIndex)
data, _ = sjson.Set(data, "content_block.id", fmt.Sprintf("%s-%d", fcName, time.Now().UnixNano()))
data, _ = sjson.Set(data, "content_block.name", fcName)
output = output + fmt.Sprintf("data: %s\n\n\n", data)
if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() {
output = output + "event: content_block_delta\n"
data, _ = sjson.Set(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, *responseIndex), "delta.partial_json", fcArgsResult.Raw)
output = output + fmt.Sprintf("data: %s\n\n\n", data)
}
*responseType = 3
}
}
}
usageResult := gjson.GetBytes(rawJson, "response.usageMetadata")
if usageResult.Exists() && bytes.Contains(rawJson, []byte(`"finishReason"`)) {
if candidatesTokenCountResult := usageResult.Get("candidatesTokenCount"); candidatesTokenCountResult.Exists() {
output = output + "event: content_block_stop\n"
output = output + fmt.Sprintf(`data: {"type":"content_block_stop","index":%d}`, *responseIndex)
output = output + "\n\n\n"
output = output + "event: message_delta\n"
output = output + `data: `
template := `{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`
if usedTool {
template = `{"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`
}
thoughtsTokenCount := usageResult.Get("thoughtsTokenCount").Int()
template, _ = sjson.Set(template, "usage.output_tokens", candidatesTokenCountResult.Int()+thoughtsTokenCount)
template, _ = sjson.Set(template, "usage.input_tokens", usageResult.Get("promptTokenCount").Int())
output = output + template + "\n\n\n"
}
}
return output
}

View File

@@ -0,0 +1,32 @@
package claude
// PKCECodes holds PKCE verification codes for OAuth2 PKCE flow
type PKCECodes struct {
// CodeVerifier is the cryptographically random string used to correlate
// the authorization request to the token request
CodeVerifier string `json:"code_verifier"`
// CodeChallenge is the SHA256 hash of the code verifier, base64url-encoded
CodeChallenge string `json:"code_challenge"`
}
// ClaudeTokenData holds OAuth token information from Anthropic
type ClaudeTokenData struct {
// AccessToken is the OAuth2 access token for API access
AccessToken string `json:"access_token"`
// RefreshToken is used to obtain new access tokens
RefreshToken string `json:"refresh_token"`
// Email is the Anthropic account email
Email string `json:"email"`
// Expire is the timestamp of the token expire
Expire string `json:"expired"`
}
// ClaudeAuthBundle aggregates authentication data after OAuth flow completion
type ClaudeAuthBundle struct {
// APIKey is the Anthropic API key obtained from token exchange
APIKey string `json:"api_key"`
// TokenData contains the OAuth tokens from the authentication flow
TokenData ClaudeTokenData `json:"token_data"`
// LastRefresh is the timestamp of the last token refresh
LastRefresh string `json:"last_refresh"`
}

View File

@@ -0,0 +1,346 @@
// Package claude provides OAuth2 authentication functionality for Anthropic's Claude API.
// This package implements the complete OAuth2 flow with PKCE (Proof Key for Code Exchange)
// for secure authentication with Claude API, including token exchange, refresh, and storage.
package claude
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
log "github.com/sirupsen/logrus"
)
const (
anthropicAuthURL = "https://claude.ai/oauth/authorize"
anthropicTokenURL = "https://console.anthropic.com/v1/oauth/token"
anthropicClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
redirectURI = "http://localhost:54545/callback"
)
// tokenResponse represents the response structure from Anthropic's OAuth token endpoint.
// It contains access token, refresh token, and associated user/organization information.
type tokenResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
Organization struct {
UUID string `json:"uuid"`
Name string `json:"name"`
} `json:"organization"`
Account struct {
UUID string `json:"uuid"`
EmailAddress string `json:"email_address"`
} `json:"account"`
}
// ClaudeAuth handles Anthropic OAuth2 authentication flow.
// It provides methods for generating authorization URLs, exchanging codes for tokens,
// and refreshing expired tokens using PKCE for enhanced security.
type ClaudeAuth struct {
httpClient *http.Client
}
// NewClaudeAuth creates a new Anthropic authentication service.
// It initializes the HTTP client with proxy settings from the configuration.
//
// Parameters:
// - cfg: The application configuration containing proxy settings
//
// Returns:
// - *ClaudeAuth: A new Claude authentication service instance
func NewClaudeAuth(cfg *config.Config) *ClaudeAuth {
return &ClaudeAuth{
httpClient: util.SetProxy(&cfg.SDKConfig, &http.Client{}),
}
}
// GenerateAuthURL creates the OAuth authorization URL with PKCE.
// This method generates a secure authorization URL including PKCE challenge codes
// for the OAuth2 flow with Anthropic's API.
//
// Parameters:
// - state: A random state parameter for CSRF protection
// - pkceCodes: The PKCE codes for secure code exchange
//
// Returns:
// - string: The complete authorization URL
// - string: The state parameter for verification
// - error: An error if PKCE codes are missing or URL generation fails
func (o *ClaudeAuth) GenerateAuthURL(state string, pkceCodes *PKCECodes) (string, string, error) {
if pkceCodes == nil {
return "", "", fmt.Errorf("PKCE codes are required")
}
params := url.Values{
"code": {"true"},
"client_id": {anthropicClientID},
"response_type": {"code"},
"redirect_uri": {redirectURI},
"scope": {"org:create_api_key user:profile user:inference"},
"code_challenge": {pkceCodes.CodeChallenge},
"code_challenge_method": {"S256"},
"state": {state},
}
authURL := fmt.Sprintf("%s?%s", anthropicAuthURL, params.Encode())
return authURL, state, nil
}
// parseCodeAndState extracts the authorization code and state from the callback response.
// It handles the parsing of the code parameter which may contain additional fragments.
//
// Parameters:
// - code: The raw code parameter from the OAuth callback
//
// Returns:
// - parsedCode: The extracted authorization code
// - parsedState: The extracted state parameter if present
func (c *ClaudeAuth) parseCodeAndState(code string) (parsedCode, parsedState string) {
splits := strings.Split(code, "#")
parsedCode = splits[0]
if len(splits) > 1 {
parsedState = splits[1]
}
return
}
// ExchangeCodeForTokens exchanges authorization code for access tokens.
// This method implements the OAuth2 token exchange flow using PKCE for security.
// It sends the authorization code along with PKCE verifier to get access and refresh tokens.
//
// Parameters:
// - ctx: The context for the request
// - code: The authorization code received from OAuth callback
// - state: The state parameter for verification
// - pkceCodes: The PKCE codes for secure verification
//
// Returns:
// - *ClaudeAuthBundle: The complete authentication bundle with tokens
// - error: An error if token exchange fails
func (o *ClaudeAuth) ExchangeCodeForTokens(ctx context.Context, code, state string, pkceCodes *PKCECodes) (*ClaudeAuthBundle, error) {
if pkceCodes == nil {
return nil, fmt.Errorf("PKCE codes are required for token exchange")
}
newCode, newState := o.parseCodeAndState(code)
// Prepare token exchange request
reqBody := map[string]interface{}{
"code": newCode,
"state": state,
"grant_type": "authorization_code",
"client_id": anthropicClientID,
"redirect_uri": redirectURI,
"code_verifier": pkceCodes.CodeVerifier,
}
// Include state if present
if newState != "" {
reqBody["state"] = newState
}
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %w", err)
}
// log.Debugf("Token exchange request: %s", string(jsonBody))
req, err := http.NewRequestWithContext(ctx, "POST", anthropicTokenURL, strings.NewReader(string(jsonBody)))
if err != nil {
return nil, fmt.Errorf("failed to create token request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := o.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("token exchange request failed: %w", err)
}
defer func() {
if errClose := resp.Body.Close(); errClose != nil {
log.Errorf("failed to close response body: %v", errClose)
}
}()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read token response: %w", err)
}
// log.Debugf("Token response: %s", string(body))
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("token exchange failed with status %d: %s", resp.StatusCode, string(body))
}
// log.Debugf("Token response: %s", string(body))
var tokenResp tokenResponse
if err = json.Unmarshal(body, &tokenResp); err != nil {
return nil, fmt.Errorf("failed to parse token response: %w", err)
}
// Create token data
tokenData := ClaudeTokenData{
AccessToken: tokenResp.AccessToken,
RefreshToken: tokenResp.RefreshToken,
Email: tokenResp.Account.EmailAddress,
Expire: time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339),
}
// Create auth bundle
bundle := &ClaudeAuthBundle{
TokenData: tokenData,
LastRefresh: time.Now().Format(time.RFC3339),
}
return bundle, nil
}
// RefreshTokens refreshes the access token using the refresh token.
// This method exchanges a valid refresh token for a new access token,
// extending the user's authenticated session.
//
// Parameters:
// - ctx: The context for the request
// - refreshToken: The refresh token to use for getting new access token
//
// Returns:
// - *ClaudeTokenData: The new token data with updated access token
// - error: An error if token refresh fails
func (o *ClaudeAuth) RefreshTokens(ctx context.Context, refreshToken string) (*ClaudeTokenData, error) {
if refreshToken == "" {
return nil, fmt.Errorf("refresh token is required")
}
reqBody := map[string]interface{}{
"client_id": anthropicClientID,
"grant_type": "refresh_token",
"refresh_token": refreshToken,
}
jsonBody, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "POST", anthropicTokenURL, strings.NewReader(string(jsonBody)))
if err != nil {
return nil, fmt.Errorf("failed to create refresh request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := o.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("token refresh request failed: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read refresh response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("token refresh failed with status %d: %s", resp.StatusCode, string(body))
}
// log.Debugf("Token response: %s", string(body))
var tokenResp tokenResponse
if err = json.Unmarshal(body, &tokenResp); err != nil {
return nil, fmt.Errorf("failed to parse token response: %w", err)
}
// Create token data
return &ClaudeTokenData{
AccessToken: tokenResp.AccessToken,
RefreshToken: tokenResp.RefreshToken,
Email: tokenResp.Account.EmailAddress,
Expire: time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339),
}, nil
}
// CreateTokenStorage creates a new ClaudeTokenStorage from auth bundle and user info.
// This method converts the authentication bundle into a token storage structure
// suitable for persistence and later use.
//
// Parameters:
// - bundle: The authentication bundle containing token data
//
// Returns:
// - *ClaudeTokenStorage: A new token storage instance
func (o *ClaudeAuth) CreateTokenStorage(bundle *ClaudeAuthBundle) *ClaudeTokenStorage {
storage := &ClaudeTokenStorage{
AccessToken: bundle.TokenData.AccessToken,
RefreshToken: bundle.TokenData.RefreshToken,
LastRefresh: bundle.LastRefresh,
Email: bundle.TokenData.Email,
Expire: bundle.TokenData.Expire,
}
return storage
}
// RefreshTokensWithRetry refreshes tokens with automatic retry logic.
// This method implements exponential backoff retry logic for token refresh operations,
// providing resilience against temporary network or service issues.
//
// Parameters:
// - ctx: The context for the request
// - refreshToken: The refresh token to use
// - maxRetries: The maximum number of retry attempts
//
// Returns:
// - *ClaudeTokenData: The refreshed token data
// - error: An error if all retry attempts fail
func (o *ClaudeAuth) RefreshTokensWithRetry(ctx context.Context, refreshToken string, maxRetries int) (*ClaudeTokenData, error) {
var lastErr error
for attempt := 0; attempt < maxRetries; attempt++ {
if attempt > 0 {
// Wait before retry
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(time.Duration(attempt) * time.Second):
}
}
tokenData, err := o.RefreshTokens(ctx, refreshToken)
if err == nil {
return tokenData, nil
}
lastErr = err
log.Warnf("Token refresh attempt %d failed: %v", attempt+1, err)
}
return nil, fmt.Errorf("token refresh failed after %d attempts: %w", maxRetries, lastErr)
}
// UpdateTokenStorage updates an existing token storage with new token data.
// This method refreshes the token storage with newly obtained access and refresh tokens,
// updating timestamps and expiration information.
//
// Parameters:
// - storage: The existing token storage to update
// - tokenData: The new token data to apply
func (o *ClaudeAuth) UpdateTokenStorage(storage *ClaudeTokenStorage, tokenData *ClaudeTokenData) {
storage.AccessToken = tokenData.AccessToken
storage.RefreshToken = tokenData.RefreshToken
storage.LastRefresh = time.Now().Format(time.RFC3339)
storage.Email = tokenData.Email
storage.Expire = tokenData.Expire
}

View File

@@ -0,0 +1,167 @@
// Package claude provides authentication and token management functionality
// for Anthropic's Claude AI services. It handles OAuth2 token storage, serialization,
// and retrieval for maintaining authenticated sessions with the Claude API.
package claude
import (
"errors"
"fmt"
"net/http"
)
// OAuthError represents an OAuth-specific error.
type OAuthError struct {
// Code is the OAuth error code.
Code string `json:"error"`
// Description is a human-readable description of the error.
Description string `json:"error_description,omitempty"`
// URI is a URI identifying a human-readable web page with information about the error.
URI string `json:"error_uri,omitempty"`
// StatusCode is the HTTP status code associated with the error.
StatusCode int `json:"-"`
}
// Error returns a string representation of the OAuth error.
func (e *OAuthError) Error() string {
if e.Description != "" {
return fmt.Sprintf("OAuth error %s: %s", e.Code, e.Description)
}
return fmt.Sprintf("OAuth error: %s", e.Code)
}
// NewOAuthError creates a new OAuth error with the specified code, description, and status code.
func NewOAuthError(code, description string, statusCode int) *OAuthError {
return &OAuthError{
Code: code,
Description: description,
StatusCode: statusCode,
}
}
// AuthenticationError represents authentication-related errors.
type AuthenticationError struct {
// Type is the type of authentication error.
Type string `json:"type"`
// Message is a human-readable message describing the error.
Message string `json:"message"`
// Code is the HTTP status code associated with the error.
Code int `json:"code"`
// Cause is the underlying error that caused this authentication error.
Cause error `json:"-"`
}
// Error returns a string representation of the authentication error.
func (e *AuthenticationError) Error() string {
if e.Cause != nil {
return fmt.Sprintf("%s: %s (caused by: %v)", e.Type, e.Message, e.Cause)
}
return fmt.Sprintf("%s: %s", e.Type, e.Message)
}
// Common authentication error types.
var (
// ErrTokenExpired = &AuthenticationError{
// Type: "token_expired",
// Message: "Access token has expired",
// Code: http.StatusUnauthorized,
// }
// ErrInvalidState represents an error for invalid OAuth state parameter.
ErrInvalidState = &AuthenticationError{
Type: "invalid_state",
Message: "OAuth state parameter is invalid",
Code: http.StatusBadRequest,
}
// ErrCodeExchangeFailed represents an error when exchanging authorization code for tokens fails.
ErrCodeExchangeFailed = &AuthenticationError{
Type: "code_exchange_failed",
Message: "Failed to exchange authorization code for tokens",
Code: http.StatusBadRequest,
}
// ErrServerStartFailed represents an error when starting the OAuth callback server fails.
ErrServerStartFailed = &AuthenticationError{
Type: "server_start_failed",
Message: "Failed to start OAuth callback server",
Code: http.StatusInternalServerError,
}
// ErrPortInUse represents an error when the OAuth callback port is already in use.
ErrPortInUse = &AuthenticationError{
Type: "port_in_use",
Message: "OAuth callback port is already in use",
Code: 13, // Special exit code for port-in-use
}
// ErrCallbackTimeout represents an error when waiting for OAuth callback times out.
ErrCallbackTimeout = &AuthenticationError{
Type: "callback_timeout",
Message: "Timeout waiting for OAuth callback",
Code: http.StatusRequestTimeout,
}
)
// NewAuthenticationError creates a new authentication error with a cause based on a base error.
func NewAuthenticationError(baseErr *AuthenticationError, cause error) *AuthenticationError {
return &AuthenticationError{
Type: baseErr.Type,
Message: baseErr.Message,
Code: baseErr.Code,
Cause: cause,
}
}
// IsAuthenticationError checks if an error is an authentication error.
func IsAuthenticationError(err error) bool {
var authenticationError *AuthenticationError
ok := errors.As(err, &authenticationError)
return ok
}
// IsOAuthError checks if an error is an OAuth error.
func IsOAuthError(err error) bool {
var oAuthError *OAuthError
ok := errors.As(err, &oAuthError)
return ok
}
// GetUserFriendlyMessage returns a user-friendly error message based on the error type.
func GetUserFriendlyMessage(err error) string {
switch {
case IsAuthenticationError(err):
var authErr *AuthenticationError
errors.As(err, &authErr)
switch authErr.Type {
case "token_expired":
return "Your authentication has expired. Please log in again."
case "token_invalid":
return "Your authentication is invalid. Please log in again."
case "authentication_required":
return "Please log in to continue."
case "port_in_use":
return "The required port is already in use. Please close any applications using port 3000 and try again."
case "callback_timeout":
return "Authentication timed out. Please try again."
case "browser_open_failed":
return "Could not open your browser automatically. Please copy and paste the URL manually."
default:
return "Authentication failed. Please try again."
}
case IsOAuthError(err):
var oauthErr *OAuthError
errors.As(err, &oauthErr)
switch oauthErr.Code {
case "access_denied":
return "Authentication was cancelled or denied."
case "invalid_request":
return "Invalid authentication request. Please try again."
case "server_error":
return "Authentication server error. Please try again later."
default:
return fmt.Sprintf("Authentication failed: %s", oauthErr.Description)
}
default:
return "An unexpected error occurred. Please try again."
}
}

View File

@@ -0,0 +1,218 @@
// Package claude provides authentication and token management functionality
// for Anthropic's Claude AI services. It handles OAuth2 token storage, serialization,
// and retrieval for maintaining authenticated sessions with the Claude API.
package claude
// LoginSuccessHtml is the HTML template displayed to users after successful OAuth authentication.
// This template provides a user-friendly success page with options to close the window
// or navigate to the Claude platform. It includes automatic window closing functionality
// and keyboard accessibility features.
const LoginSuccessHtml = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Authentication Successful - Claude</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%2310b981'%3E%3Cpath d='M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z'/%3E%3C/svg%3E">
<style>
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 1rem;
}
.container {
text-align: center;
background: white;
padding: 2.5rem;
border-radius: 12px;
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
max-width: 480px;
width: 100%;
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.success-icon {
width: 64px;
height: 64px;
margin: 0 auto 1.5rem;
background: #10b981;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 2rem;
font-weight: bold;
}
h1 {
color: #1f2937;
margin-bottom: 1rem;
font-size: 1.75rem;
font-weight: 600;
}
.subtitle {
color: #6b7280;
margin-bottom: 1.5rem;
font-size: 1rem;
line-height: 1.5;
}
.setup-notice {
background: #fef3c7;
border: 1px solid #f59e0b;
border-radius: 6px;
padding: 1rem;
margin: 1rem 0;
}
.setup-notice h3 {
color: #92400e;
margin: 0 0 0.5rem 0;
font-size: 1rem;
}
.setup-notice p {
color: #92400e;
margin: 0;
font-size: 0.875rem;
}
.setup-notice a {
color: #1d4ed8;
text-decoration: none;
}
.setup-notice a:hover {
text-decoration: underline;
}
.actions {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
margin-top: 2rem;
}
.button {
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 500;
text-decoration: none;
transition: all 0.2s;
cursor: pointer;
border: none;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.button-primary {
background: #3b82f6;
color: white;
}
.button-primary:hover {
background: #2563eb;
transform: translateY(-1px);
}
.button-secondary {
background: #f3f4f6;
color: #374151;
border: 1px solid #d1d5db;
}
.button-secondary:hover {
background: #e5e7eb;
}
.countdown {
color: #9ca3af;
font-size: 0.75rem;
margin-top: 1rem;
}
.footer {
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid #e5e7eb;
color: #9ca3af;
font-size: 0.75rem;
}
.footer a {
color: #3b82f6;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="container">
<div class="success-icon">✓</div>
<h1>Authentication Successful!</h1>
<p class="subtitle">You have successfully authenticated with Claude. You can now close this window and return to your terminal to continue.</p>
{{SETUP_NOTICE}}
<div class="actions">
<button class="button button-primary" onclick="window.close()">
<span>Close Window</span>
</button>
<a href="{{PLATFORM_URL}}" target="_blank" class="button button-secondary">
<span>Open Platform</span>
<span>↗</span>
</a>
</div>
<div class="countdown">
This window will close automatically in <span id="countdown">10</span> seconds
</div>
<div class="footer">
<p>Powered by <a href="https://chatgpt.com" target="_blank">ChatGPT</a></p>
</div>
</div>
<script>
let countdown = 10;
const countdownElement = document.getElementById('countdown');
const timer = setInterval(() => {
countdown--;
countdownElement.textContent = countdown;
if (countdown <= 0) {
clearInterval(timer);
window.close();
}
}, 1000);
// Close window when user presses Escape
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
window.close();
}
});
// Focus the close button for keyboard accessibility
document.querySelector('.button-primary').focus();
</script>
</body>
</html>`
// SetupNoticeHtml is the HTML template for the setup notice section.
// This template is embedded within the success page to inform users about
// additional setup steps required to complete their Claude account configuration.
const SetupNoticeHtml = `
<div class="setup-notice">
<h3>Additional Setup Required</h3>
<p>To complete your setup, please visit the <a href="{{PLATFORM_URL}}" target="_blank">Claude</a> to configure your account.</p>
</div>`

View File

@@ -0,0 +1,320 @@
// Package claude provides authentication and token management functionality
// for Anthropic's Claude AI services. It handles OAuth2 token storage, serialization,
// and retrieval for maintaining authenticated sessions with the Claude API.
package claude
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"strings"
"sync"
"time"
log "github.com/sirupsen/logrus"
)
// OAuthServer handles the local HTTP server for OAuth callbacks.
// It listens for the authorization code response from the OAuth provider
// and captures the necessary parameters to complete the authentication flow.
type OAuthServer struct {
// server is the underlying HTTP server instance
server *http.Server
// port is the port number on which the server listens
port int
// resultChan is a channel for sending OAuth results
resultChan chan *OAuthResult
// errorChan is a channel for sending OAuth errors
errorChan chan error
// mu is a mutex for protecting server state
mu sync.Mutex
// running indicates whether the server is currently running
running bool
}
// OAuthResult contains the result of the OAuth callback.
// It holds either the authorization code and state for successful authentication
// or an error message if the authentication failed.
type OAuthResult struct {
// Code is the authorization code received from the OAuth provider
Code string
// State is the state parameter used to prevent CSRF attacks
State string
// Error contains any error message if the OAuth flow failed
Error string
}
// NewOAuthServer creates a new OAuth callback server.
// It initializes the server with the specified port and creates channels
// for handling OAuth results and errors.
//
// Parameters:
// - port: The port number on which the server should listen
//
// Returns:
// - *OAuthServer: A new OAuthServer instance
func NewOAuthServer(port int) *OAuthServer {
return &OAuthServer{
port: port,
resultChan: make(chan *OAuthResult, 1),
errorChan: make(chan error, 1),
}
}
// Start starts the OAuth callback server.
// It sets up the HTTP handlers for the callback and success endpoints,
// and begins listening on the specified port.
//
// Returns:
// - error: An error if the server fails to start
func (s *OAuthServer) Start() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.running {
return fmt.Errorf("server is already running")
}
// Check if port is available
if !s.isPortAvailable() {
return fmt.Errorf("port %d is already in use", s.port)
}
mux := http.NewServeMux()
mux.HandleFunc("/callback", s.handleCallback)
mux.HandleFunc("/success", s.handleSuccess)
s.server = &http.Server{
Addr: fmt.Sprintf(":%d", s.port),
Handler: mux,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
}
s.running = true
// Start server in goroutine
go func() {
if err := s.server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
s.errorChan <- fmt.Errorf("server failed to start: %w", err)
}
}()
// Give server a moment to start
time.Sleep(100 * time.Millisecond)
return nil
}
// Stop gracefully stops the OAuth callback server.
// It performs a graceful shutdown of the HTTP server with a timeout.
//
// Parameters:
// - ctx: The context for controlling the shutdown process
//
// Returns:
// - error: An error if the server fails to stop gracefully
func (s *OAuthServer) Stop(ctx context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
if !s.running || s.server == nil {
return nil
}
log.Debug("Stopping OAuth callback server")
// Create a context with timeout for shutdown
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
err := s.server.Shutdown(shutdownCtx)
s.running = false
s.server = nil
return err
}
// WaitForCallback waits for the OAuth callback with a timeout.
// It blocks until either an OAuth result is received, an error occurs,
// or the specified timeout is reached.
//
// Parameters:
// - timeout: The maximum time to wait for the callback
//
// Returns:
// - *OAuthResult: The OAuth result if successful
// - error: An error if the callback times out or an error occurs
func (s *OAuthServer) WaitForCallback(timeout time.Duration) (*OAuthResult, error) {
select {
case result := <-s.resultChan:
return result, nil
case err := <-s.errorChan:
return nil, err
case <-time.After(timeout):
return nil, fmt.Errorf("timeout waiting for OAuth callback")
}
}
// handleCallback handles the OAuth callback endpoint.
// It extracts the authorization code and state from the callback URL,
// validates the parameters, and sends the result to the waiting channel.
//
// Parameters:
// - w: The HTTP response writer
// - r: The HTTP request
func (s *OAuthServer) handleCallback(w http.ResponseWriter, r *http.Request) {
log.Debug("Received OAuth callback")
// Validate request method
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Extract parameters
query := r.URL.Query()
code := query.Get("code")
state := query.Get("state")
errorParam := query.Get("error")
// Validate required parameters
if errorParam != "" {
log.Errorf("OAuth error received: %s", errorParam)
result := &OAuthResult{
Error: errorParam,
}
s.sendResult(result)
http.Error(w, fmt.Sprintf("OAuth error: %s", errorParam), http.StatusBadRequest)
return
}
if code == "" {
log.Error("No authorization code received")
result := &OAuthResult{
Error: "no_code",
}
s.sendResult(result)
http.Error(w, "No authorization code received", http.StatusBadRequest)
return
}
if state == "" {
log.Error("No state parameter received")
result := &OAuthResult{
Error: "no_state",
}
s.sendResult(result)
http.Error(w, "No state parameter received", http.StatusBadRequest)
return
}
// Send successful result
result := &OAuthResult{
Code: code,
State: state,
}
s.sendResult(result)
// Redirect to success page
http.Redirect(w, r, "/success", http.StatusFound)
}
// handleSuccess handles the success page endpoint.
// It serves a user-friendly HTML page indicating that authentication was successful.
//
// Parameters:
// - w: The HTTP response writer
// - r: The HTTP request
func (s *OAuthServer) handleSuccess(w http.ResponseWriter, r *http.Request) {
log.Debug("Serving success page")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
// Parse query parameters for customization
query := r.URL.Query()
setupRequired := query.Get("setup_required") == "true"
platformURL := query.Get("platform_url")
if platformURL == "" {
platformURL = "https://console.anthropic.com/"
}
// Generate success page HTML with dynamic content
successHTML := s.generateSuccessHTML(setupRequired, platformURL)
_, err := w.Write([]byte(successHTML))
if err != nil {
log.Errorf("Failed to write success page: %v", err)
}
}
// generateSuccessHTML creates the HTML content for the success page.
// It customizes the page based on whether additional setup is required
// and includes a link to the platform.
//
// Parameters:
// - setupRequired: Whether additional setup is required after authentication
// - platformURL: The URL to the platform for additional setup
//
// Returns:
// - string: The HTML content for the success page
func (s *OAuthServer) generateSuccessHTML(setupRequired bool, platformURL string) string {
html := LoginSuccessHtml
// Replace platform URL placeholder
html = strings.Replace(html, "{{PLATFORM_URL}}", platformURL, -1)
// Add setup notice if required
if setupRequired {
setupNotice := strings.Replace(SetupNoticeHtml, "{{PLATFORM_URL}}", platformURL, -1)
html = strings.Replace(html, "{{SETUP_NOTICE}}", setupNotice, 1)
} else {
html = strings.Replace(html, "{{SETUP_NOTICE}}", "", 1)
}
return html
}
// sendResult sends the OAuth result to the waiting channel.
// It ensures that the result is sent without blocking the handler.
//
// Parameters:
// - result: The OAuth result to send
func (s *OAuthServer) sendResult(result *OAuthResult) {
select {
case s.resultChan <- result:
log.Debug("OAuth result sent to channel")
default:
log.Warn("OAuth result channel is full, result dropped")
}
}
// isPortAvailable checks if the specified port is available.
// It attempts to listen on the port to determine availability.
//
// Returns:
// - bool: True if the port is available, false otherwise
func (s *OAuthServer) isPortAvailable() bool {
addr := fmt.Sprintf(":%d", s.port)
listener, err := net.Listen("tcp", addr)
if err != nil {
return false
}
defer func() {
_ = listener.Close()
}()
return true
}
// IsRunning returns whether the server is currently running.
//
// Returns:
// - bool: True if the server is running, false otherwise
func (s *OAuthServer) IsRunning() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.running
}

View File

@@ -0,0 +1,56 @@
// Package claude provides authentication and token management functionality
// for Anthropic's Claude AI services. It handles OAuth2 token storage, serialization,
// and retrieval for maintaining authenticated sessions with the Claude API.
package claude
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"fmt"
)
// GeneratePKCECodes generates a PKCE code verifier and challenge pair
// following RFC 7636 specifications for OAuth 2.0 PKCE extension.
// This provides additional security for the OAuth flow by ensuring that
// only the client that initiated the request can exchange the authorization code.
//
// Returns:
// - *PKCECodes: A struct containing the code verifier and challenge
// - error: An error if the generation fails, nil otherwise
func GeneratePKCECodes() (*PKCECodes, error) {
// Generate code verifier: 43-128 characters, URL-safe
codeVerifier, err := generateCodeVerifier()
if err != nil {
return nil, fmt.Errorf("failed to generate code verifier: %w", err)
}
// Generate code challenge using S256 method
codeChallenge := generateCodeChallenge(codeVerifier)
return &PKCECodes{
CodeVerifier: codeVerifier,
CodeChallenge: codeChallenge,
}, nil
}
// generateCodeVerifier creates a cryptographically random string
// of 128 characters using URL-safe base64 encoding
func generateCodeVerifier() (string, error) {
// Generate 96 random bytes (will result in 128 base64 characters)
bytes := make([]byte, 96)
_, err := rand.Read(bytes)
if err != nil {
return "", fmt.Errorf("failed to generate random bytes: %w", err)
}
// Encode to URL-safe base64 without padding
return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(bytes), nil
}
// generateCodeChallenge creates a SHA256 hash of the code verifier
// and encodes it using URL-safe base64 encoding without padding
func generateCodeChallenge(codeVerifier string) string {
hash := sha256.Sum256([]byte(codeVerifier))
return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(hash[:])
}

View File

@@ -0,0 +1,73 @@
// Package claude provides authentication and token management functionality
// for Anthropic's Claude AI services. It handles OAuth2 token storage, serialization,
// and retrieval for maintaining authenticated sessions with the Claude API.
package claude
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
)
// ClaudeTokenStorage stores OAuth2 token information for Anthropic Claude API authentication.
// It maintains compatibility with the existing auth system while adding Claude-specific fields
// for managing access tokens, refresh tokens, and user account information.
type ClaudeTokenStorage struct {
// IDToken is the JWT ID token containing user claims and identity information.
IDToken string `json:"id_token"`
// AccessToken is the OAuth2 access token used for authenticating API requests.
AccessToken string `json:"access_token"`
// RefreshToken is used to obtain new access tokens when the current one expires.
RefreshToken string `json:"refresh_token"`
// LastRefresh is the timestamp of the last token refresh operation.
LastRefresh string `json:"last_refresh"`
// Email is the Anthropic account email address associated with this token.
Email string `json:"email"`
// Type indicates the authentication provider type, always "claude" for this storage.
Type string `json:"type"`
// Expire is the timestamp when the current access token expires.
Expire string `json:"expired"`
}
// SaveTokenToFile serializes the Claude token storage to a JSON file.
// This method creates the necessary directory structure and writes the token
// data in JSON format to the specified file path for persistent storage.
//
// Parameters:
// - authFilePath: The full path where the token file should be saved
//
// Returns:
// - error: An error if the operation fails, nil otherwise
func (ts *ClaudeTokenStorage) SaveTokenToFile(authFilePath string) error {
misc.LogSavingCredentials(authFilePath)
ts.Type = "claude"
// Create directory structure if it doesn't exist
if err := os.MkdirAll(filepath.Dir(authFilePath), 0700); err != nil {
return fmt.Errorf("failed to create directory: %v", err)
}
// Create the token file
f, err := os.Create(authFilePath)
if err != nil {
return fmt.Errorf("failed to create token file: %w", err)
}
defer func() {
_ = f.Close()
}()
// Encode and write the token data as JSON
if err = json.NewEncoder(f).Encode(ts); err != nil {
return fmt.Errorf("failed to write token to file: %w", err)
}
return nil
}

View File

@@ -0,0 +1,171 @@
package codex
import (
"errors"
"fmt"
"net/http"
)
// OAuthError represents an OAuth-specific error.
type OAuthError struct {
// Code is the OAuth error code.
Code string `json:"error"`
// Description is a human-readable description of the error.
Description string `json:"error_description,omitempty"`
// URI is a URI identifying a human-readable web page with information about the error.
URI string `json:"error_uri,omitempty"`
// StatusCode is the HTTP status code associated with the error.
StatusCode int `json:"-"`
}
// Error returns a string representation of the OAuth error.
func (e *OAuthError) Error() string {
if e.Description != "" {
return fmt.Sprintf("OAuth error %s: %s", e.Code, e.Description)
}
return fmt.Sprintf("OAuth error: %s", e.Code)
}
// NewOAuthError creates a new OAuth error with the specified code, description, and status code.
func NewOAuthError(code, description string, statusCode int) *OAuthError {
return &OAuthError{
Code: code,
Description: description,
StatusCode: statusCode,
}
}
// AuthenticationError represents authentication-related errors.
type AuthenticationError struct {
// Type is the type of authentication error.
Type string `json:"type"`
// Message is a human-readable message describing the error.
Message string `json:"message"`
// Code is the HTTP status code associated with the error.
Code int `json:"code"`
// Cause is the underlying error that caused this authentication error.
Cause error `json:"-"`
}
// Error returns a string representation of the authentication error.
func (e *AuthenticationError) Error() string {
if e.Cause != nil {
return fmt.Sprintf("%s: %s (caused by: %v)", e.Type, e.Message, e.Cause)
}
return fmt.Sprintf("%s: %s", e.Type, e.Message)
}
// Common authentication error types.
var (
// ErrTokenExpired = &AuthenticationError{
// Type: "token_expired",
// Message: "Access token has expired",
// Code: http.StatusUnauthorized,
// }
// ErrInvalidState represents an error for invalid OAuth state parameter.
ErrInvalidState = &AuthenticationError{
Type: "invalid_state",
Message: "OAuth state parameter is invalid",
Code: http.StatusBadRequest,
}
// ErrCodeExchangeFailed represents an error when exchanging authorization code for tokens fails.
ErrCodeExchangeFailed = &AuthenticationError{
Type: "code_exchange_failed",
Message: "Failed to exchange authorization code for tokens",
Code: http.StatusBadRequest,
}
// ErrServerStartFailed represents an error when starting the OAuth callback server fails.
ErrServerStartFailed = &AuthenticationError{
Type: "server_start_failed",
Message: "Failed to start OAuth callback server",
Code: http.StatusInternalServerError,
}
// ErrPortInUse represents an error when the OAuth callback port is already in use.
ErrPortInUse = &AuthenticationError{
Type: "port_in_use",
Message: "OAuth callback port is already in use",
Code: 13, // Special exit code for port-in-use
}
// ErrCallbackTimeout represents an error when waiting for OAuth callback times out.
ErrCallbackTimeout = &AuthenticationError{
Type: "callback_timeout",
Message: "Timeout waiting for OAuth callback",
Code: http.StatusRequestTimeout,
}
// ErrBrowserOpenFailed represents an error when opening the browser for authentication fails.
ErrBrowserOpenFailed = &AuthenticationError{
Type: "browser_open_failed",
Message: "Failed to open browser for authentication",
Code: http.StatusInternalServerError,
}
)
// NewAuthenticationError creates a new authentication error with a cause based on a base error.
func NewAuthenticationError(baseErr *AuthenticationError, cause error) *AuthenticationError {
return &AuthenticationError{
Type: baseErr.Type,
Message: baseErr.Message,
Code: baseErr.Code,
Cause: cause,
}
}
// IsAuthenticationError checks if an error is an authentication error.
func IsAuthenticationError(err error) bool {
var authenticationError *AuthenticationError
ok := errors.As(err, &authenticationError)
return ok
}
// IsOAuthError checks if an error is an OAuth error.
func IsOAuthError(err error) bool {
var oAuthError *OAuthError
ok := errors.As(err, &oAuthError)
return ok
}
// GetUserFriendlyMessage returns a user-friendly error message based on the error type.
func GetUserFriendlyMessage(err error) string {
switch {
case IsAuthenticationError(err):
var authErr *AuthenticationError
errors.As(err, &authErr)
switch authErr.Type {
case "token_expired":
return "Your authentication has expired. Please log in again."
case "token_invalid":
return "Your authentication is invalid. Please log in again."
case "authentication_required":
return "Please log in to continue."
case "port_in_use":
return "The required port is already in use. Please close any applications using port 3000 and try again."
case "callback_timeout":
return "Authentication timed out. Please try again."
case "browser_open_failed":
return "Could not open your browser automatically. Please copy and paste the URL manually."
default:
return "Authentication failed. Please try again."
}
case IsOAuthError(err):
var oauthErr *OAuthError
errors.As(err, &oauthErr)
switch oauthErr.Code {
case "access_denied":
return "Authentication was cancelled or denied."
case "invalid_request":
return "Invalid authentication request. Please try again."
case "server_error":
return "Authentication server error. Please try again later."
default:
return fmt.Sprintf("Authentication failed: %s", oauthErr.Description)
}
default:
return "An unexpected error occurred. Please try again."
}
}

View File

@@ -0,0 +1,214 @@
package codex
// LoginSuccessHTML is the HTML template for the page shown after a successful
// OAuth2 authentication with Codex. It informs the user that the authentication
// was successful and provides a countdown timer to automatically close the window.
const LoginSuccessHtml = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Authentication Successful - Codex</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%2310b981'%3E%3Cpath d='M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z'/%3E%3C/svg%3E">
<style>
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 1rem;
}
.container {
text-align: center;
background: white;
padding: 2.5rem;
border-radius: 12px;
box-shadow: 0 10px 25px rgba(0,0,0,0.1);
max-width: 480px;
width: 100%;
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.success-icon {
width: 64px;
height: 64px;
margin: 0 auto 1.5rem;
background: #10b981;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 2rem;
font-weight: bold;
}
h1 {
color: #1f2937;
margin-bottom: 1rem;
font-size: 1.75rem;
font-weight: 600;
}
.subtitle {
color: #6b7280;
margin-bottom: 1.5rem;
font-size: 1rem;
line-height: 1.5;
}
.setup-notice {
background: #fef3c7;
border: 1px solid #f59e0b;
border-radius: 6px;
padding: 1rem;
margin: 1rem 0;
}
.setup-notice h3 {
color: #92400e;
margin: 0 0 0.5rem 0;
font-size: 1rem;
}
.setup-notice p {
color: #92400e;
margin: 0;
font-size: 0.875rem;
}
.setup-notice a {
color: #1d4ed8;
text-decoration: none;
}
.setup-notice a:hover {
text-decoration: underline;
}
.actions {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
margin-top: 2rem;
}
.button {
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-size: 0.875rem;
font-weight: 500;
text-decoration: none;
transition: all 0.2s;
cursor: pointer;
border: none;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.button-primary {
background: #3b82f6;
color: white;
}
.button-primary:hover {
background: #2563eb;
transform: translateY(-1px);
}
.button-secondary {
background: #f3f4f6;
color: #374151;
border: 1px solid #d1d5db;
}
.button-secondary:hover {
background: #e5e7eb;
}
.countdown {
color: #9ca3af;
font-size: 0.75rem;
margin-top: 1rem;
}
.footer {
margin-top: 2rem;
padding-top: 1.5rem;
border-top: 1px solid #e5e7eb;
color: #9ca3af;
font-size: 0.75rem;
}
.footer a {
color: #3b82f6;
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="container">
<div class="success-icon">✓</div>
<h1>Authentication Successful!</h1>
<p class="subtitle">You have successfully authenticated with Codex. You can now close this window and return to your terminal to continue.</p>
{{SETUP_NOTICE}}
<div class="actions">
<button class="button button-primary" onclick="window.close()">
<span>Close Window</span>
</button>
<a href="{{PLATFORM_URL}}" target="_blank" class="button button-secondary">
<span>Open Platform</span>
<span>↗</span>
</a>
</div>
<div class="countdown">
This window will close automatically in <span id="countdown">10</span> seconds
</div>
<div class="footer">
<p>Powered by <a href="https://chatgpt.com" target="_blank">ChatGPT</a></p>
</div>
</div>
<script>
let countdown = 10;
const countdownElement = document.getElementById('countdown');
const timer = setInterval(() => {
countdown--;
countdownElement.textContent = countdown;
if (countdown <= 0) {
clearInterval(timer);
window.close();
}
}, 1000);
// Close window when user presses Escape
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
window.close();
}
});
// Focus the close button for keyboard accessibility
document.querySelector('.button-primary').focus();
</script>
</body>
</html>`
// SetupNoticeHTML is the HTML template for the section that provides instructions
// for additional setup. This is displayed on the success page when further actions
// are required from the user.
const SetupNoticeHtml = `
<div class="setup-notice">
<h3>Additional Setup Required</h3>
<p>To complete your setup, please visit the <a href="{{PLATFORM_URL}}" target="_blank">Codex</a> to configure your account.</p>
</div>`

View File

@@ -0,0 +1,102 @@
package codex
import (
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"time"
)
// JWTClaims represents the claims section of a JSON Web Token (JWT).
// It includes standard claims like issuer, subject, and expiration time, as well as
// custom claims specific to OpenAI's authentication.
type JWTClaims struct {
AtHash string `json:"at_hash"`
Aud []string `json:"aud"`
AuthProvider string `json:"auth_provider"`
AuthTime int `json:"auth_time"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Exp int `json:"exp"`
CodexAuthInfo CodexAuthInfo `json:"https://api.openai.com/auth"`
Iat int `json:"iat"`
Iss string `json:"iss"`
Jti string `json:"jti"`
Rat int `json:"rat"`
Sid string `json:"sid"`
Sub string `json:"sub"`
}
// Organizations defines the structure for organization details within the JWT claims.
// It holds information about the user's organization, such as ID, role, and title.
type Organizations struct {
ID string `json:"id"`
IsDefault bool `json:"is_default"`
Role string `json:"role"`
Title string `json:"title"`
}
// CodexAuthInfo contains authentication-related details specific to Codex.
// This includes ChatGPT account information, subscription status, and user/organization IDs.
type CodexAuthInfo struct {
ChatgptAccountID string `json:"chatgpt_account_id"`
ChatgptPlanType string `json:"chatgpt_plan_type"`
ChatgptSubscriptionActiveStart any `json:"chatgpt_subscription_active_start"`
ChatgptSubscriptionActiveUntil any `json:"chatgpt_subscription_active_until"`
ChatgptSubscriptionLastChecked time.Time `json:"chatgpt_subscription_last_checked"`
ChatgptUserID string `json:"chatgpt_user_id"`
Groups []any `json:"groups"`
Organizations []Organizations `json:"organizations"`
UserID string `json:"user_id"`
}
// ParseJWTToken parses a JWT token string and extracts its claims without performing
// cryptographic signature verification. This is useful for introspecting the token's
// contents to retrieve user information from an ID token after it has been validated
// by the authentication server.
func ParseJWTToken(token string) (*JWTClaims, error) {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return nil, fmt.Errorf("invalid JWT token format: expected 3 parts, got %d", len(parts))
}
// Decode the claims (payload) part
claimsData, err := base64URLDecode(parts[1])
if err != nil {
return nil, fmt.Errorf("failed to decode JWT claims: %w", err)
}
var claims JWTClaims
if err = json.Unmarshal(claimsData, &claims); err != nil {
return nil, fmt.Errorf("failed to unmarshal JWT claims: %w", err)
}
return &claims, nil
}
// base64URLDecode decodes a Base64 URL-encoded string, adding padding if necessary.
// JWTs use a URL-safe Base64 alphabet and omit padding, so this function ensures
// correct decoding by re-adding the padding before decoding.
func base64URLDecode(data string) ([]byte, error) {
// Add padding if necessary
switch len(data) % 4 {
case 2:
data += "=="
case 3:
data += "="
}
return base64.URLEncoding.DecodeString(data)
}
// GetUserEmail extracts the user's email address from the JWT claims.
func (c *JWTClaims) GetUserEmail() string {
return c.Email
}
// GetAccountID extracts the user's account ID (subject) from the JWT claims.
// It retrieves the unique identifier for the user's ChatGPT account.
func (c *JWTClaims) GetAccountID() string {
return c.CodexAuthInfo.ChatgptAccountID
}

View File

@@ -0,0 +1,317 @@
package codex
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"strings"
"sync"
"time"
log "github.com/sirupsen/logrus"
)
// OAuthServer handles the local HTTP server for OAuth callbacks.
// It listens for the authorization code response from the OAuth provider
// and captures the necessary parameters to complete the authentication flow.
type OAuthServer struct {
// server is the underlying HTTP server instance
server *http.Server
// port is the port number on which the server listens
port int
// resultChan is a channel for sending OAuth results
resultChan chan *OAuthResult
// errorChan is a channel for sending OAuth errors
errorChan chan error
// mu is a mutex for protecting server state
mu sync.Mutex
// running indicates whether the server is currently running
running bool
}
// OAuthResult contains the result of the OAuth callback.
// It holds either the authorization code and state for successful authentication
// or an error message if the authentication failed.
type OAuthResult struct {
// Code is the authorization code received from the OAuth provider
Code string
// State is the state parameter used to prevent CSRF attacks
State string
// Error contains any error message if the OAuth flow failed
Error string
}
// NewOAuthServer creates a new OAuth callback server.
// It initializes the server with the specified port and creates channels
// for handling OAuth results and errors.
//
// Parameters:
// - port: The port number on which the server should listen
//
// Returns:
// - *OAuthServer: A new OAuthServer instance
func NewOAuthServer(port int) *OAuthServer {
return &OAuthServer{
port: port,
resultChan: make(chan *OAuthResult, 1),
errorChan: make(chan error, 1),
}
}
// Start starts the OAuth callback server.
// It sets up the HTTP handlers for the callback and success endpoints,
// and begins listening on the specified port.
//
// Returns:
// - error: An error if the server fails to start
func (s *OAuthServer) Start() error {
s.mu.Lock()
defer s.mu.Unlock()
if s.running {
return fmt.Errorf("server is already running")
}
// Check if port is available
if !s.isPortAvailable() {
return fmt.Errorf("port %d is already in use", s.port)
}
mux := http.NewServeMux()
mux.HandleFunc("/auth/callback", s.handleCallback)
mux.HandleFunc("/success", s.handleSuccess)
s.server = &http.Server{
Addr: fmt.Sprintf(":%d", s.port),
Handler: mux,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
}
s.running = true
// Start server in goroutine
go func() {
if err := s.server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
s.errorChan <- fmt.Errorf("server failed to start: %w", err)
}
}()
// Give server a moment to start
time.Sleep(100 * time.Millisecond)
return nil
}
// Stop gracefully stops the OAuth callback server.
// It performs a graceful shutdown of the HTTP server with a timeout.
//
// Parameters:
// - ctx: The context for controlling the shutdown process
//
// Returns:
// - error: An error if the server fails to stop gracefully
func (s *OAuthServer) Stop(ctx context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
if !s.running || s.server == nil {
return nil
}
log.Debug("Stopping OAuth callback server")
// Create a context with timeout for shutdown
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
err := s.server.Shutdown(shutdownCtx)
s.running = false
s.server = nil
return err
}
// WaitForCallback waits for the OAuth callback with a timeout.
// It blocks until either an OAuth result is received, an error occurs,
// or the specified timeout is reached.
//
// Parameters:
// - timeout: The maximum time to wait for the callback
//
// Returns:
// - *OAuthResult: The OAuth result if successful
// - error: An error if the callback times out or an error occurs
func (s *OAuthServer) WaitForCallback(timeout time.Duration) (*OAuthResult, error) {
select {
case result := <-s.resultChan:
return result, nil
case err := <-s.errorChan:
return nil, err
case <-time.After(timeout):
return nil, fmt.Errorf("timeout waiting for OAuth callback")
}
}
// handleCallback handles the OAuth callback endpoint.
// It extracts the authorization code and state from the callback URL,
// validates the parameters, and sends the result to the waiting channel.
//
// Parameters:
// - w: The HTTP response writer
// - r: The HTTP request
func (s *OAuthServer) handleCallback(w http.ResponseWriter, r *http.Request) {
log.Debug("Received OAuth callback")
// Validate request method
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Extract parameters
query := r.URL.Query()
code := query.Get("code")
state := query.Get("state")
errorParam := query.Get("error")
// Validate required parameters
if errorParam != "" {
log.Errorf("OAuth error received: %s", errorParam)
result := &OAuthResult{
Error: errorParam,
}
s.sendResult(result)
http.Error(w, fmt.Sprintf("OAuth error: %s", errorParam), http.StatusBadRequest)
return
}
if code == "" {
log.Error("No authorization code received")
result := &OAuthResult{
Error: "no_code",
}
s.sendResult(result)
http.Error(w, "No authorization code received", http.StatusBadRequest)
return
}
if state == "" {
log.Error("No state parameter received")
result := &OAuthResult{
Error: "no_state",
}
s.sendResult(result)
http.Error(w, "No state parameter received", http.StatusBadRequest)
return
}
// Send successful result
result := &OAuthResult{
Code: code,
State: state,
}
s.sendResult(result)
// Redirect to success page
http.Redirect(w, r, "/success", http.StatusFound)
}
// handleSuccess handles the success page endpoint.
// It serves a user-friendly HTML page indicating that authentication was successful.
//
// Parameters:
// - w: The HTTP response writer
// - r: The HTTP request
func (s *OAuthServer) handleSuccess(w http.ResponseWriter, r *http.Request) {
log.Debug("Serving success page")
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
// Parse query parameters for customization
query := r.URL.Query()
setupRequired := query.Get("setup_required") == "true"
platformURL := query.Get("platform_url")
if platformURL == "" {
platformURL = "https://platform.openai.com"
}
// Generate success page HTML with dynamic content
successHTML := s.generateSuccessHTML(setupRequired, platformURL)
_, err := w.Write([]byte(successHTML))
if err != nil {
log.Errorf("Failed to write success page: %v", err)
}
}
// generateSuccessHTML creates the HTML content for the success page.
// It customizes the page based on whether additional setup is required
// and includes a link to the platform.
//
// Parameters:
// - setupRequired: Whether additional setup is required after authentication
// - platformURL: The URL to the platform for additional setup
//
// Returns:
// - string: The HTML content for the success page
func (s *OAuthServer) generateSuccessHTML(setupRequired bool, platformURL string) string {
html := LoginSuccessHtml
// Replace platform URL placeholder
html = strings.Replace(html, "{{PLATFORM_URL}}", platformURL, -1)
// Add setup notice if required
if setupRequired {
setupNotice := strings.Replace(SetupNoticeHtml, "{{PLATFORM_URL}}", platformURL, -1)
html = strings.Replace(html, "{{SETUP_NOTICE}}", setupNotice, 1)
} else {
html = strings.Replace(html, "{{SETUP_NOTICE}}", "", 1)
}
return html
}
// sendResult sends the OAuth result to the waiting channel.
// It ensures that the result is sent without blocking the handler.
//
// Parameters:
// - result: The OAuth result to send
func (s *OAuthServer) sendResult(result *OAuthResult) {
select {
case s.resultChan <- result:
log.Debug("OAuth result sent to channel")
default:
log.Warn("OAuth result channel is full, result dropped")
}
}
// isPortAvailable checks if the specified port is available.
// It attempts to listen on the port to determine availability.
//
// Returns:
// - bool: True if the port is available, false otherwise
func (s *OAuthServer) isPortAvailable() bool {
addr := fmt.Sprintf(":%d", s.port)
listener, err := net.Listen("tcp", addr)
if err != nil {
return false
}
defer func() {
_ = listener.Close()
}()
return true
}
// IsRunning returns whether the server is currently running.
//
// Returns:
// - bool: True if the server is running, false otherwise
func (s *OAuthServer) IsRunning() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.running
}

View File

@@ -0,0 +1,39 @@
package codex
// PKCECodes holds the verification codes for the OAuth2 PKCE (Proof Key for Code Exchange) flow.
// PKCE is an extension to the Authorization Code flow to prevent CSRF and authorization code injection attacks.
type PKCECodes struct {
// CodeVerifier is the cryptographically random string used to correlate
// the authorization request to the token request
CodeVerifier string `json:"code_verifier"`
// CodeChallenge is the SHA256 hash of the code verifier, base64url-encoded
CodeChallenge string `json:"code_challenge"`
}
// CodexTokenData holds the OAuth token information obtained from OpenAI.
// It includes the ID token, access token, refresh token, and associated user details.
type CodexTokenData struct {
// IDToken is the JWT ID token containing user claims
IDToken string `json:"id_token"`
// AccessToken is the OAuth2 access token for API access
AccessToken string `json:"access_token"`
// RefreshToken is used to obtain new access tokens
RefreshToken string `json:"refresh_token"`
// AccountID is the OpenAI account identifier
AccountID string `json:"account_id"`
// Email is the OpenAI account email
Email string `json:"email"`
// Expire is the timestamp of the token expire
Expire string `json:"expired"`
}
// CodexAuthBundle aggregates all authentication-related data after the OAuth flow is complete.
// This includes the API key, token data, and the timestamp of the last refresh.
type CodexAuthBundle struct {
// APIKey is the OpenAI API key obtained from token exchange
APIKey string `json:"api_key"`
// TokenData contains the OAuth tokens from the authentication flow
TokenData CodexTokenData `json:"token_data"`
// LastRefresh is the timestamp of the last token refresh
LastRefresh string `json:"last_refresh"`
}

View File

@@ -0,0 +1,286 @@
// Package codex provides authentication and token management for OpenAI's Codex API.
// It handles the OAuth2 flow, including generating authorization URLs, exchanging
// authorization codes for tokens, and refreshing expired tokens. The package also
// defines data structures for storing and managing Codex authentication credentials.
package codex
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
log "github.com/sirupsen/logrus"
)
const (
openaiAuthURL = "https://auth.openai.com/oauth/authorize"
openaiTokenURL = "https://auth.openai.com/oauth/token"
openaiClientID = "app_EMoamEEZ73f0CkXaXp7hrann"
redirectURI = "http://localhost:1455/auth/callback"
)
// CodexAuth handles the OpenAI OAuth2 authentication flow.
// It manages the HTTP client and provides methods for generating authorization URLs,
// exchanging authorization codes for tokens, and refreshing access tokens.
type CodexAuth struct {
httpClient *http.Client
}
// NewCodexAuth creates a new CodexAuth service instance.
// It initializes an HTTP client with proxy settings from the provided configuration.
func NewCodexAuth(cfg *config.Config) *CodexAuth {
return &CodexAuth{
httpClient: util.SetProxy(&cfg.SDKConfig, &http.Client{}),
}
}
// GenerateAuthURL creates the OAuth authorization URL with PKCE (Proof Key for Code Exchange).
// It constructs the URL with the necessary parameters, including the client ID,
// response type, redirect URI, scopes, and PKCE challenge.
func (o *CodexAuth) GenerateAuthURL(state string, pkceCodes *PKCECodes) (string, error) {
if pkceCodes == nil {
return "", fmt.Errorf("PKCE codes are required")
}
params := url.Values{
"client_id": {openaiClientID},
"response_type": {"code"},
"redirect_uri": {redirectURI},
"scope": {"openid email profile offline_access"},
"state": {state},
"code_challenge": {pkceCodes.CodeChallenge},
"code_challenge_method": {"S256"},
"prompt": {"login"},
"id_token_add_organizations": {"true"},
"codex_cli_simplified_flow": {"true"},
}
authURL := fmt.Sprintf("%s?%s", openaiAuthURL, params.Encode())
return authURL, nil
}
// ExchangeCodeForTokens exchanges an authorization code for access and refresh tokens.
// It performs an HTTP POST request to the OpenAI token endpoint with the provided
// authorization code and PKCE verifier.
func (o *CodexAuth) ExchangeCodeForTokens(ctx context.Context, code string, pkceCodes *PKCECodes) (*CodexAuthBundle, error) {
if pkceCodes == nil {
return nil, fmt.Errorf("PKCE codes are required for token exchange")
}
// Prepare token exchange request
data := url.Values{
"grant_type": {"authorization_code"},
"client_id": {openaiClientID},
"code": {code},
"redirect_uri": {redirectURI},
"code_verifier": {pkceCodes.CodeVerifier},
}
req, err := http.NewRequestWithContext(ctx, "POST", openaiTokenURL, strings.NewReader(data.Encode()))
if err != nil {
return nil, fmt.Errorf("failed to create token request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err := o.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("token exchange request failed: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read token response: %w", err)
}
// log.Debugf("Token response: %s", string(body))
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("token exchange failed with status %d: %s", resp.StatusCode, string(body))
}
// Parse token response
var tokenResp struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
IDToken string `json:"id_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
}
if err = json.Unmarshal(body, &tokenResp); err != nil {
return nil, fmt.Errorf("failed to parse token response: %w", err)
}
// Extract account ID from ID token
claims, err := ParseJWTToken(tokenResp.IDToken)
if err != nil {
log.Warnf("Failed to parse ID token: %v", err)
}
accountID := ""
email := ""
if claims != nil {
accountID = claims.GetAccountID()
email = claims.GetUserEmail()
}
// Create token data
tokenData := CodexTokenData{
IDToken: tokenResp.IDToken,
AccessToken: tokenResp.AccessToken,
RefreshToken: tokenResp.RefreshToken,
AccountID: accountID,
Email: email,
Expire: time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339),
}
// Create auth bundle
bundle := &CodexAuthBundle{
TokenData: tokenData,
LastRefresh: time.Now().Format(time.RFC3339),
}
return bundle, nil
}
// RefreshTokens refreshes an access token using a refresh token.
// This method is called when an access token has expired. It makes a request to the
// token endpoint to obtain a new set of tokens.
func (o *CodexAuth) RefreshTokens(ctx context.Context, refreshToken string) (*CodexTokenData, error) {
if refreshToken == "" {
return nil, fmt.Errorf("refresh token is required")
}
data := url.Values{
"client_id": {openaiClientID},
"grant_type": {"refresh_token"},
"refresh_token": {refreshToken},
"scope": {"openid profile email"},
}
req, err := http.NewRequestWithContext(ctx, "POST", openaiTokenURL, strings.NewReader(data.Encode()))
if err != nil {
return nil, fmt.Errorf("failed to create refresh request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err := o.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("token refresh request failed: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read refresh response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("token refresh failed with status %d: %s", resp.StatusCode, string(body))
}
var tokenResp struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
IDToken string `json:"id_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
}
if err = json.Unmarshal(body, &tokenResp); err != nil {
return nil, fmt.Errorf("failed to parse refresh response: %w", err)
}
// Extract account ID from ID token
claims, err := ParseJWTToken(tokenResp.IDToken)
if err != nil {
log.Warnf("Failed to parse refreshed ID token: %v", err)
}
accountID := ""
email := ""
if claims != nil {
accountID = claims.GetAccountID()
email = claims.Email
}
return &CodexTokenData{
IDToken: tokenResp.IDToken,
AccessToken: tokenResp.AccessToken,
RefreshToken: tokenResp.RefreshToken,
AccountID: accountID,
Email: email,
Expire: time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339),
}, nil
}
// CreateTokenStorage creates a new CodexTokenStorage from a CodexAuthBundle.
// It populates the storage struct with token data, user information, and timestamps.
func (o *CodexAuth) CreateTokenStorage(bundle *CodexAuthBundle) *CodexTokenStorage {
storage := &CodexTokenStorage{
IDToken: bundle.TokenData.IDToken,
AccessToken: bundle.TokenData.AccessToken,
RefreshToken: bundle.TokenData.RefreshToken,
AccountID: bundle.TokenData.AccountID,
LastRefresh: bundle.LastRefresh,
Email: bundle.TokenData.Email,
Expire: bundle.TokenData.Expire,
}
return storage
}
// RefreshTokensWithRetry refreshes tokens with a built-in retry mechanism.
// It attempts to refresh the tokens up to a specified maximum number of retries,
// with an exponential backoff strategy to handle transient network errors.
func (o *CodexAuth) RefreshTokensWithRetry(ctx context.Context, refreshToken string, maxRetries int) (*CodexTokenData, error) {
var lastErr error
for attempt := 0; attempt < maxRetries; attempt++ {
if attempt > 0 {
// Wait before retry
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(time.Duration(attempt) * time.Second):
}
}
tokenData, err := o.RefreshTokens(ctx, refreshToken)
if err == nil {
return tokenData, nil
}
lastErr = err
log.Warnf("Token refresh attempt %d failed: %v", attempt+1, err)
}
return nil, fmt.Errorf("token refresh failed after %d attempts: %w", maxRetries, lastErr)
}
// UpdateTokenStorage updates an existing CodexTokenStorage with new token data.
// This is typically called after a successful token refresh to persist the new credentials.
func (o *CodexAuth) UpdateTokenStorage(storage *CodexTokenStorage, tokenData *CodexTokenData) {
storage.IDToken = tokenData.IDToken
storage.AccessToken = tokenData.AccessToken
storage.RefreshToken = tokenData.RefreshToken
storage.AccountID = tokenData.AccountID
storage.LastRefresh = time.Now().Format(time.RFC3339)
storage.Email = tokenData.Email
storage.Expire = tokenData.Expire
}

View File

@@ -0,0 +1,56 @@
// Package codex provides authentication and token management functionality
// for OpenAI's Codex AI services. It handles OAuth2 PKCE (Proof Key for Code Exchange)
// code generation for secure authentication flows.
package codex
import (
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"fmt"
)
// GeneratePKCECodes generates a new pair of PKCE (Proof Key for Code Exchange) codes.
// It creates a cryptographically random code verifier and its corresponding
// SHA256 code challenge, as specified in RFC 7636. This is a critical security
// feature for the OAuth 2.0 authorization code flow.
func GeneratePKCECodes() (*PKCECodes, error) {
// Generate code verifier: 43-128 characters, URL-safe
codeVerifier, err := generateCodeVerifier()
if err != nil {
return nil, fmt.Errorf("failed to generate code verifier: %w", err)
}
// Generate code challenge using S256 method
codeChallenge := generateCodeChallenge(codeVerifier)
return &PKCECodes{
CodeVerifier: codeVerifier,
CodeChallenge: codeChallenge,
}, nil
}
// generateCodeVerifier creates a cryptographically secure random string to be used
// as the code verifier in the PKCE flow. The verifier is a high-entropy string
// that is later used to prove possession of the client that initiated the
// authorization request.
func generateCodeVerifier() (string, error) {
// Generate 96 random bytes (will result in 128 base64 characters)
bytes := make([]byte, 96)
_, err := rand.Read(bytes)
if err != nil {
return "", fmt.Errorf("failed to generate random bytes: %w", err)
}
// Encode to URL-safe base64 without padding
return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(bytes), nil
}
// generateCodeChallenge creates a code challenge from a given code verifier.
// The challenge is derived by taking the SHA256 hash of the verifier and then
// Base64 URL-encoding the result. This is sent in the initial authorization
// request and later verified against the verifier.
func generateCodeChallenge(codeVerifier string) string {
hash := sha256.Sum256([]byte(codeVerifier))
return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(hash[:])
}

View File

@@ -0,0 +1,66 @@
// Package codex provides authentication and token management functionality
// for OpenAI's Codex AI services. It handles OAuth2 token storage, serialization,
// and retrieval for maintaining authenticated sessions with the Codex API.
package codex
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
)
// CodexTokenStorage stores OAuth2 token information for OpenAI Codex API authentication.
// It maintains compatibility with the existing auth system while adding Codex-specific fields
// for managing access tokens, refresh tokens, and user account information.
type CodexTokenStorage struct {
// IDToken is the JWT ID token containing user claims and identity information.
IDToken string `json:"id_token"`
// AccessToken is the OAuth2 access token used for authenticating API requests.
AccessToken string `json:"access_token"`
// RefreshToken is used to obtain new access tokens when the current one expires.
RefreshToken string `json:"refresh_token"`
// AccountID is the OpenAI account identifier associated with this token.
AccountID string `json:"account_id"`
// LastRefresh is the timestamp of the last token refresh operation.
LastRefresh string `json:"last_refresh"`
// Email is the OpenAI account email address associated with this token.
Email string `json:"email"`
// Type indicates the authentication provider type, always "codex" for this storage.
Type string `json:"type"`
// Expire is the timestamp when the current access token expires.
Expire string `json:"expired"`
}
// SaveTokenToFile serializes the Codex token storage to a JSON file.
// This method creates the necessary directory structure and writes the token
// data in JSON format to the specified file path for persistent storage.
//
// Parameters:
// - authFilePath: The full path where the token file should be saved
//
// Returns:
// - error: An error if the operation fails, nil otherwise
func (ts *CodexTokenStorage) SaveTokenToFile(authFilePath string) error {
misc.LogSavingCredentials(authFilePath)
ts.Type = "codex"
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() {
_ = f.Close()
}()
if err = json.NewEncoder(f).Encode(ts); err != nil {
return fmt.Errorf("failed to write token to file: %w", err)
}
return nil
}

View File

@@ -0,0 +1,26 @@
// Package empty provides a no-operation token storage implementation.
// This package is used when authentication tokens are not required or when
// using API key-based authentication instead of OAuth tokens for any provider.
package empty
// EmptyStorage is a no-operation implementation of the TokenStorage interface.
// It provides empty implementations for scenarios where token storage is not needed,
// such as when using API keys instead of OAuth tokens for authentication.
type EmptyStorage struct {
// Type indicates the authentication provider type, always "empty" for this implementation.
Type string `json:"type"`
}
// SaveTokenToFile is a no-operation implementation that always succeeds.
// This method satisfies the TokenStorage interface but performs no actual file operations
// since empty storage doesn't require persistent token data.
//
// Parameters:
// - _: The file path parameter is ignored in this implementation
//
// Returns:
// - error: Always returns nil (no error)
func (ts *EmptyStorage) SaveTokenToFile(_ string) error {
ts.Type = "empty"
return nil
}

View File

@@ -0,0 +1,64 @@
// 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

@@ -1,4 +1,8 @@
package auth
// Package gemini provides authentication and token management functionality
// for Google's Gemini AI services. It handles OAuth2 authentication flows,
// including obtaining tokens via web-based authorization, storing tokens,
// and refreshing them when they expire.
package gemini
import (
"context"
@@ -11,9 +15,11 @@ import (
"net/url"
"time"
"github.com/luispater/CLIProxyAPI/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex"
"github.com/router-for-me/CLIProxyAPI/v6/internal/browser"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
log "github.com/sirupsen/logrus"
"github.com/skratchdot/open-golang/open"
"github.com/tidwall/gjson"
"golang.org/x/net/proxy"
@@ -22,24 +28,45 @@ import (
)
const (
oauthClientID = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com"
oauthClientSecret = "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"
geminiOauthClientID = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com"
geminiOauthClientSecret = "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"
)
var (
oauthScopes = []string{
geminiOauthScopes = []string{
"https://www.googleapis.com/auth/cloud-platform",
"https://www.googleapis.com/auth/userinfo.email",
"https://www.googleapis.com/auth/userinfo.profile",
}
)
// GeminiAuth provides methods for handling the Gemini OAuth2 authentication flow.
// It encapsulates the logic for obtaining, storing, and refreshing authentication tokens
// for Google's Gemini AI services.
type GeminiAuth struct {
}
// NewGeminiAuth creates a new instance of GeminiAuth.
func NewGeminiAuth() *GeminiAuth {
return &GeminiAuth{}
}
// GetAuthenticatedClient configures and returns an HTTP client ready for making authenticated API calls.
// It manages the entire OAuth2 flow, including handling proxies, loading existing tokens,
// initiating a new web-based OAuth flow if necessary, and refreshing tokens.
func GetAuthenticatedClient(ctx context.Context, ts *TokenStorage, cfg *config.Config) (*http.Client, error) {
//
// Parameters:
// - ctx: The context for the HTTP client
// - ts: The Gemini token storage containing authentication tokens
// - cfg: The configuration containing proxy settings
// - noBrowser: Optional parameter to disable browser opening
//
// Returns:
// - *http.Client: An HTTP client configured with authentication
// - error: An error if the client configuration fails, nil otherwise
func (g *GeminiAuth) GetAuthenticatedClient(ctx context.Context, ts *GeminiTokenStorage, cfg *config.Config, noBrowser ...bool) (*http.Client, error) {
// Configure proxy settings for the HTTP client if a proxy URL is provided.
proxyURL, err := url.Parse(cfg.ProxyUrl)
proxyURL, err := url.Parse(cfg.ProxyURL)
if err == nil {
var transport *http.Transport
if proxyURL.Scheme == "socks5" {
@@ -69,10 +96,10 @@ func GetAuthenticatedClient(ctx context.Context, ts *TokenStorage, cfg *config.C
// Configure the OAuth2 client.
conf := &oauth2.Config{
ClientID: oauthClientID,
ClientSecret: oauthClientSecret,
ClientID: geminiOauthClientID,
ClientSecret: geminiOauthClientSecret,
RedirectURL: "http://localhost:8085/oauth2callback", // This will be used by the local server.
Scopes: oauthScopes,
Scopes: geminiOauthScopes,
Endpoint: google.Endpoint,
}
@@ -80,13 +107,13 @@ func GetAuthenticatedClient(ctx context.Context, ts *TokenStorage, cfg *config.C
// If no token is found in storage, initiate the web-based OAuth flow.
if ts.Token == nil {
log.Info("Could not load token from file, starting OAuth flow.")
token, err = getTokenFromWeb(ctx, conf)
fmt.Printf("Could not load token from file, starting OAuth flow.\n")
token, err = g.getTokenFromWeb(ctx, conf, noBrowser...)
if err != nil {
return nil, fmt.Errorf("failed to get token from web: %w", err)
}
// After getting a new token, create a new token storage object with user info.
newTs, errCreateTokenStorage := createTokenStorage(ctx, conf, token, ts.ProjectID)
newTs, errCreateTokenStorage := g.createTokenStorage(ctx, conf, token, ts.ProjectID)
if errCreateTokenStorage != nil {
log.Errorf("Warning: failed to create token storage: %v", errCreateTokenStorage)
return nil, errCreateTokenStorage
@@ -104,9 +131,19 @@ func GetAuthenticatedClient(ctx context.Context, ts *TokenStorage, cfg *config.C
return conf.Client(ctx, token), nil
}
// createTokenStorage creates a new TokenStorage object. It fetches the user's email
// createTokenStorage creates a new GeminiTokenStorage object. It fetches the user's email
// using the provided token and populates the storage structure.
func createTokenStorage(ctx context.Context, config *oauth2.Config, token *oauth2.Token, projectID string) (*TokenStorage, error) {
//
// Parameters:
// - ctx: The context for the HTTP request
// - config: The OAuth2 configuration
// - token: The OAuth2 token to use for authentication
// - projectID: The Google Cloud Project ID to associate with this token
//
// Returns:
// - *GeminiTokenStorage: A new token storage object with user information
// - error: An error if the token storage creation fails, nil otherwise
func (g *GeminiAuth) createTokenStorage(ctx context.Context, config *oauth2.Config, token *oauth2.Token, projectID string) (*GeminiTokenStorage, error) {
httpClient := config.Client(ctx, token)
req, err := http.NewRequestWithContext(ctx, "GET", "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", nil)
if err != nil {
@@ -132,9 +169,9 @@ func createTokenStorage(ctx context.Context, config *oauth2.Config, token *oauth
emailResult := gjson.GetBytes(bodyBytes, "email")
if emailResult.Exists() && emailResult.Type == gjson.String {
log.Infof("Authenticated user email: %s", emailResult.String())
fmt.Printf("Authenticated user email: %s\n", emailResult.String())
} else {
log.Info("Failed to get user email from token")
fmt.Println("Failed to get user email from token")
}
var ifToken map[string]any
@@ -145,12 +182,12 @@ func createTokenStorage(ctx context.Context, config *oauth2.Config, token *oauth
}
ifToken["token_uri"] = "https://oauth2.googleapis.com/token"
ifToken["client_id"] = oauthClientID
ifToken["client_secret"] = oauthClientSecret
ifToken["scopes"] = oauthScopes
ifToken["client_id"] = geminiOauthClientID
ifToken["client_secret"] = geminiOauthClientSecret
ifToken["scopes"] = geminiOauthScopes
ifToken["universe_domain"] = "googleapis.com"
ts := TokenStorage{
ts := GeminiTokenStorage{
Token: ifToken,
ProjectID: projectID,
Email: emailResult.String(),
@@ -163,7 +200,16 @@ func createTokenStorage(ctx context.Context, config *oauth2.Config, token *oauth
// It starts a local HTTP server to listen for the callback from Google's auth server,
// opens the user's browser to the authorization URL, and exchanges the received
// authorization code for an access token.
func getTokenFromWeb(ctx context.Context, config *oauth2.Config) (*oauth2.Token, error) {
//
// Parameters:
// - ctx: The context for the HTTP client
// - config: The OAuth2 configuration
// - noBrowser: Optional parameter to disable browser opening
//
// Returns:
// - *oauth2.Token: The OAuth2 token obtained from the authorization flow
// - error: An error if the token acquisition fails, nil otherwise
func (g *GeminiAuth) getTokenFromWeb(ctx context.Context, config *oauth2.Config, noBrowser ...bool) (*oauth2.Token, error) {
// Use a channel to pass the authorization code from the HTTP handler to the main function.
codeChan := make(chan string)
errChan := make(chan error)
@@ -198,27 +244,49 @@ func getTokenFromWeb(ctx context.Context, config *oauth2.Config) (*oauth2.Token,
// Open the authorization URL in the user's browser.
authURL := config.AuthCodeURL("state-token", oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("prompt", "consent"))
log.Debugf("CLI login required.\nAttempting to open authentication page in your browser.\nIf it does not open, please navigate to this URL:\n\n%s\n", authURL)
var err error
err = open.Run(authURL)
if err != nil {
log.Errorf("Failed to open browser: %v. Please open the URL manually.", err)
if len(noBrowser) == 1 && !noBrowser[0] {
fmt.Println("Opening browser for authentication...")
// Check if browser is available
if !browser.IsAvailable() {
log.Warn("No browser available on this system")
util.PrintSSHTunnelInstructions(8085)
fmt.Printf("Please manually open this URL in your browser:\n\n%s\n", authURL)
} else {
if err := browser.OpenURL(authURL); err != nil {
authErr := codex.NewAuthenticationError(codex.ErrBrowserOpenFailed, err)
log.Warn(codex.GetUserFriendlyMessage(authErr))
util.PrintSSHTunnelInstructions(8085)
fmt.Printf("Please manually open this URL in your browser:\n\n%s\n", authURL)
// Log platform info for debugging
platformInfo := browser.GetPlatformInfo()
log.Debugf("Browser platform info: %+v", platformInfo)
} else {
log.Debug("Browser opened successfully")
}
}
} else {
util.PrintSSHTunnelInstructions(8085)
fmt.Printf("Please open this URL in your browser:\n\n%s\n", authURL)
}
fmt.Println("Waiting for authentication callback...")
// Wait for the authorization code or an error.
var authCode string
select {
case code := <-codeChan:
authCode = code
case err = <-errChan:
case err := <-errChan:
return nil, err
case <-time.After(5 * time.Minute): // Timeout
return nil, fmt.Errorf("oauth flow timed out")
}
// Shutdown the server.
if err = server.Shutdown(ctx); err != nil {
if err := server.Shutdown(ctx); err != nil {
log.Errorf("Failed to shut down server: %v", err)
}
@@ -228,6 +296,6 @@ func getTokenFromWeb(ctx context.Context, config *oauth2.Config) (*oauth2.Token,
return nil, fmt.Errorf("failed to exchange token: %w", err)
}
log.Info("Authentication successful.")
fmt.Println("Authentication successful.")
return token, nil
}

View File

@@ -0,0 +1,69 @@
// 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"
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
log "github.com/sirupsen/logrus"
)
// GeminiTokenStorage stores OAuth2 token information for Google Gemini API authentication.
// It maintains compatibility with the existing auth system while adding Gemini-specific fields
// for managing access tokens, refresh tokens, and user account information.
type GeminiTokenStorage struct {
// Token holds the raw OAuth2 token data, including access and refresh tokens.
Token any `json:"token"`
// ProjectID is the Google Cloud Project ID associated with this token.
ProjectID string `json:"project_id"`
// Email is the email address of the authenticated user.
Email string `json:"email"`
// Auto indicates if the project ID was automatically selected.
Auto bool `json:"auto"`
// Checked indicates if the associated Cloud AI API has been verified as enabled.
Checked bool `json:"checked"`
// Type indicates the authentication provider type, always "gemini" for this storage.
Type string `json:"type"`
}
// SaveTokenToFile serializes the Gemini token storage to a JSON file.
// This method creates the necessary directory structure and writes the token
// data in JSON format to the specified file path for persistent storage.
//
// Parameters:
// - authFilePath: The full path where the token file should be saved
//
// Returns:
// - error: An error if the operation fails, nil otherwise
func (ts *GeminiTokenStorage) SaveTokenToFile(authFilePath string) error {
misc.LogSavingCredentials(authFilePath)
ts.Type = "gemini"
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

@@ -1,17 +1,17 @@
// Package auth provides authentication functionality for various AI service providers.
// It includes interfaces and implementations for token storage and authentication methods.
package auth
// TokenStorage defines the structure for storing OAuth2 token information,
// along with associated user and project details. This data is typically
// serialized to a JSON file for persistence.
type TokenStorage struct {
// Token holds the raw OAuth2 token data, including access and refresh tokens.
Token any `json:"token"`
// ProjectID is the Google Cloud Project ID associated with this token.
ProjectID string `json:"project_id"`
// Email is the email address of the authenticated user.
Email string `json:"email"`
// Auto indicates if the project ID was automatically selected.
Auto bool `json:"auto"`
// Checked indicates if the associated Cloud AI API has been verified as enabled.
Checked bool `json:"checked"`
// TokenStorage defines the interface for storing authentication tokens.
// Implementations of this interface should provide methods to persist
// authentication tokens to a file system location.
type TokenStorage interface {
// SaveTokenToFile persists authentication tokens to the specified file path.
//
// Parameters:
// - authFilePath: The file path where the authentication tokens should be saved
//
// Returns:
// - error: An error if the save operation fails, nil otherwise
SaveTokenToFile(authFilePath string) error
}

View File

@@ -0,0 +1,359 @@
package qwen
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
log "github.com/sirupsen/logrus"
)
const (
// QwenOAuthDeviceCodeEndpoint is the URL for initiating the OAuth 2.0 device authorization flow.
QwenOAuthDeviceCodeEndpoint = "https://chat.qwen.ai/api/v1/oauth2/device/code"
// QwenOAuthTokenEndpoint is the URL for exchanging device codes or refresh tokens for access tokens.
QwenOAuthTokenEndpoint = "https://chat.qwen.ai/api/v1/oauth2/token"
// QwenOAuthClientID is the client identifier for the Qwen OAuth 2.0 application.
QwenOAuthClientID = "f0304373b74a44d2b584a3fb70ca9e56"
// QwenOAuthScope defines the permissions requested by the application.
QwenOAuthScope = "openid profile email model.completion"
// QwenOAuthGrantType specifies the grant type for the device code flow.
QwenOAuthGrantType = "urn:ietf:params:oauth:grant-type:device_code"
)
// QwenTokenData represents the OAuth credentials, including access and refresh tokens.
type QwenTokenData struct {
AccessToken string `json:"access_token"`
// RefreshToken is used to obtain a new access token when the current one expires.
RefreshToken string `json:"refresh_token,omitempty"`
// TokenType indicates the type of token, typically "Bearer".
TokenType string `json:"token_type"`
// ResourceURL specifies the base URL of the resource server.
ResourceURL string `json:"resource_url,omitempty"`
// Expire indicates the expiration date and time of the access token.
Expire string `json:"expiry_date,omitempty"`
}
// DeviceFlow represents the response from the device authorization endpoint.
type DeviceFlow struct {
// DeviceCode is the code that the client uses to poll for an access token.
DeviceCode string `json:"device_code"`
// UserCode is the code that the user enters at the verification URI.
UserCode string `json:"user_code"`
// VerificationURI is the URL where the user can enter the user code to authorize the device.
VerificationURI string `json:"verification_uri"`
// VerificationURIComplete is a URI that includes the user_code, which can be used to automatically
// fill in the code on the verification page.
VerificationURIComplete string `json:"verification_uri_complete"`
// ExpiresIn is the time in seconds until the device_code and user_code expire.
ExpiresIn int `json:"expires_in"`
// Interval is the minimum time in seconds that the client should wait between polling requests.
Interval int `json:"interval"`
// CodeVerifier is the cryptographically random string used in the PKCE flow.
CodeVerifier string `json:"code_verifier"`
}
// QwenTokenResponse represents the successful token response from the token endpoint.
type QwenTokenResponse struct {
// AccessToken is the token used to access protected resources.
AccessToken string `json:"access_token"`
// RefreshToken is used to obtain a new access token.
RefreshToken string `json:"refresh_token,omitempty"`
// TokenType indicates the type of token, typically "Bearer".
TokenType string `json:"token_type"`
// ResourceURL specifies the base URL of the resource server.
ResourceURL string `json:"resource_url,omitempty"`
// ExpiresIn is the time in seconds until the access token expires.
ExpiresIn int `json:"expires_in"`
}
// QwenAuth manages authentication and token handling for the Qwen API.
type QwenAuth struct {
httpClient *http.Client
}
// NewQwenAuth creates a new QwenAuth instance with a proxy-configured HTTP client.
func NewQwenAuth(cfg *config.Config) *QwenAuth {
return &QwenAuth{
httpClient: util.SetProxy(&cfg.SDKConfig, &http.Client{}),
}
}
// generateCodeVerifier generates a cryptographically random string for the PKCE code verifier.
func (qa *QwenAuth) generateCodeVerifier() (string, error) {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(bytes), nil
}
// generateCodeChallenge creates a SHA-256 hash of the code verifier, used as the PKCE code challenge.
func (qa *QwenAuth) generateCodeChallenge(codeVerifier string) string {
hash := sha256.Sum256([]byte(codeVerifier))
return base64.RawURLEncoding.EncodeToString(hash[:])
}
// generatePKCEPair creates a new code verifier and its corresponding code challenge for PKCE.
func (qa *QwenAuth) generatePKCEPair() (string, string, error) {
codeVerifier, err := qa.generateCodeVerifier()
if err != nil {
return "", "", err
}
codeChallenge := qa.generateCodeChallenge(codeVerifier)
return codeVerifier, codeChallenge, nil
}
// RefreshTokens exchanges a refresh token for a new access token.
func (qa *QwenAuth) RefreshTokens(ctx context.Context, refreshToken string) (*QwenTokenData, error) {
data := url.Values{}
data.Set("grant_type", "refresh_token")
data.Set("refresh_token", refreshToken)
data.Set("client_id", QwenOAuthClientID)
req, err := http.NewRequestWithContext(ctx, "POST", QwenOAuthTokenEndpoint, strings.NewReader(data.Encode()))
if err != nil {
return nil, fmt.Errorf("failed to create token request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err := qa.httpClient.Do(req)
// resp, err := qa.httpClient.PostForm(QwenOAuthTokenEndpoint, data)
if err != nil {
return nil, fmt.Errorf("token refresh request failed: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
if resp.StatusCode != http.StatusOK {
var errorData map[string]interface{}
if err = json.Unmarshal(body, &errorData); err == nil {
return nil, fmt.Errorf("token refresh failed: %v - %v", errorData["error"], errorData["error_description"])
}
return nil, fmt.Errorf("token refresh failed: %s", string(body))
}
var tokenData QwenTokenResponse
if err = json.Unmarshal(body, &tokenData); err != nil {
return nil, fmt.Errorf("failed to parse token response: %w", err)
}
return &QwenTokenData{
AccessToken: tokenData.AccessToken,
TokenType: tokenData.TokenType,
RefreshToken: tokenData.RefreshToken,
ResourceURL: tokenData.ResourceURL,
Expire: time.Now().Add(time.Duration(tokenData.ExpiresIn) * time.Second).Format(time.RFC3339),
}, nil
}
// InitiateDeviceFlow starts the OAuth 2.0 device authorization flow and returns the device flow details.
func (qa *QwenAuth) InitiateDeviceFlow(ctx context.Context) (*DeviceFlow, error) {
// Generate PKCE code verifier and challenge
codeVerifier, codeChallenge, err := qa.generatePKCEPair()
if err != nil {
return nil, fmt.Errorf("failed to generate PKCE pair: %w", err)
}
data := url.Values{}
data.Set("client_id", QwenOAuthClientID)
data.Set("scope", QwenOAuthScope)
data.Set("code_challenge", codeChallenge)
data.Set("code_challenge_method", "S256")
req, err := http.NewRequestWithContext(ctx, "POST", QwenOAuthDeviceCodeEndpoint, strings.NewReader(data.Encode()))
if err != nil {
return nil, fmt.Errorf("failed to create token request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err := qa.httpClient.Do(req)
// resp, err := qa.httpClient.PostForm(QwenOAuthDeviceCodeEndpoint, data)
if err != nil {
return nil, fmt.Errorf("device authorization request failed: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("device authorization failed: %d %s. Response: %s", resp.StatusCode, resp.Status, string(body))
}
var result DeviceFlow
if err = json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("failed to parse device flow response: %w", err)
}
// Check if the response indicates success
if result.DeviceCode == "" {
return nil, fmt.Errorf("device authorization failed: device_code not found in response")
}
// Add the code_verifier to the result so it can be used later for polling
result.CodeVerifier = codeVerifier
return &result, nil
}
// PollForToken polls the token endpoint with the device code to obtain an access token.
func (qa *QwenAuth) PollForToken(deviceCode, codeVerifier string) (*QwenTokenData, error) {
pollInterval := 5 * time.Second
maxAttempts := 60 // 5 minutes max
for attempt := 0; attempt < maxAttempts; attempt++ {
data := url.Values{}
data.Set("grant_type", QwenOAuthGrantType)
data.Set("client_id", QwenOAuthClientID)
data.Set("device_code", deviceCode)
data.Set("code_verifier", codeVerifier)
resp, err := http.PostForm(QwenOAuthTokenEndpoint, data)
if err != nil {
fmt.Printf("Polling attempt %d/%d failed: %v\n", attempt+1, maxAttempts, err)
time.Sleep(pollInterval)
continue
}
body, err := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if err != nil {
fmt.Printf("Polling attempt %d/%d failed: %v\n", attempt+1, maxAttempts, err)
time.Sleep(pollInterval)
continue
}
if resp.StatusCode != http.StatusOK {
// Parse the response as JSON to check for OAuth RFC 8628 standard errors
var errorData map[string]interface{}
if err = json.Unmarshal(body, &errorData); err == nil {
// According to OAuth RFC 8628, handle standard polling responses
if resp.StatusCode == http.StatusBadRequest {
errorType, _ := errorData["error"].(string)
switch errorType {
case "authorization_pending":
// User has not yet approved the authorization request. Continue polling.
fmt.Printf("Polling attempt %d/%d...\n\n", attempt+1, maxAttempts)
time.Sleep(pollInterval)
continue
case "slow_down":
// Client is polling too frequently. Increase poll interval.
pollInterval = time.Duration(float64(pollInterval) * 1.5)
if pollInterval > 10*time.Second {
pollInterval = 10 * time.Second
}
fmt.Printf("Server requested to slow down, increasing poll interval to %v\n\n", pollInterval)
time.Sleep(pollInterval)
continue
case "expired_token":
return nil, fmt.Errorf("device code expired. Please restart the authentication process")
case "access_denied":
return nil, fmt.Errorf("authorization denied by user. Please restart the authentication process")
}
}
// For other errors, return with proper error information
errorType, _ := errorData["error"].(string)
errorDesc, _ := errorData["error_description"].(string)
return nil, fmt.Errorf("device token poll failed: %s - %s", errorType, errorDesc)
}
// If JSON parsing fails, fall back to text response
return nil, fmt.Errorf("device token poll failed: %d %s. Response: %s", resp.StatusCode, resp.Status, string(body))
}
// log.Debugf("%s", string(body))
// Success - parse token data
var response QwenTokenResponse
if err = json.Unmarshal(body, &response); err != nil {
return nil, fmt.Errorf("failed to parse token response: %w", err)
}
// Convert to QwenTokenData format and save
tokenData := &QwenTokenData{
AccessToken: response.AccessToken,
RefreshToken: response.RefreshToken,
TokenType: response.TokenType,
ResourceURL: response.ResourceURL,
Expire: time.Now().Add(time.Duration(response.ExpiresIn) * time.Second).Format(time.RFC3339),
}
return tokenData, nil
}
return nil, fmt.Errorf("authentication timeout. Please restart the authentication process")
}
// RefreshTokensWithRetry attempts to refresh tokens with a specified number of retries upon failure.
func (o *QwenAuth) RefreshTokensWithRetry(ctx context.Context, refreshToken string, maxRetries int) (*QwenTokenData, error) {
var lastErr error
for attempt := 0; attempt < maxRetries; attempt++ {
if attempt > 0 {
// Wait before retry
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(time.Duration(attempt) * time.Second):
}
}
tokenData, err := o.RefreshTokens(ctx, refreshToken)
if err == nil {
return tokenData, nil
}
lastErr = err
log.Warnf("Token refresh attempt %d failed: %v", attempt+1, err)
}
return nil, fmt.Errorf("token refresh failed after %d attempts: %w", maxRetries, lastErr)
}
// CreateTokenStorage creates a QwenTokenStorage object from a QwenTokenData object.
func (o *QwenAuth) CreateTokenStorage(tokenData *QwenTokenData) *QwenTokenStorage {
storage := &QwenTokenStorage{
AccessToken: tokenData.AccessToken,
RefreshToken: tokenData.RefreshToken,
LastRefresh: time.Now().Format(time.RFC3339),
ResourceURL: tokenData.ResourceURL,
Expire: tokenData.Expire,
}
return storage
}
// UpdateTokenStorage updates an existing token storage with new token data
func (o *QwenAuth) UpdateTokenStorage(storage *QwenTokenStorage, tokenData *QwenTokenData) {
storage.AccessToken = tokenData.AccessToken
storage.RefreshToken = tokenData.RefreshToken
storage.LastRefresh = time.Now().Format(time.RFC3339)
storage.ResourceURL = tokenData.ResourceURL
storage.Expire = tokenData.Expire
}

View File

@@ -0,0 +1,63 @@
// Package qwen provides authentication and token management functionality
// for Alibaba's Qwen AI services. It handles OAuth2 token storage, serialization,
// and retrieval for maintaining authenticated sessions with the Qwen API.
package qwen
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
)
// QwenTokenStorage stores OAuth2 token information for Alibaba Qwen API authentication.
// It maintains compatibility with the existing auth system while adding Qwen-specific fields
// for managing access tokens, refresh tokens, and user account information.
type QwenTokenStorage struct {
// AccessToken is the OAuth2 access token used for authenticating API requests.
AccessToken string `json:"access_token"`
// RefreshToken is used to obtain new access tokens when the current one expires.
RefreshToken string `json:"refresh_token"`
// LastRefresh is the timestamp of the last token refresh operation.
LastRefresh string `json:"last_refresh"`
// ResourceURL is the base URL for API requests.
ResourceURL string `json:"resource_url"`
// Email is the Qwen account email address associated with this token.
Email string `json:"email"`
// Type indicates the authentication provider type, always "qwen" for this storage.
Type string `json:"type"`
// Expire is the timestamp when the current access token expires.
Expire string `json:"expired"`
}
// SaveTokenToFile serializes the Qwen token storage to a JSON file.
// This method creates the necessary directory structure and writes the token
// data in JSON format to the specified file path for persistent storage.
//
// Parameters:
// - authFilePath: The full path where the token file should be saved
//
// Returns:
// - error: An error if the operation fails, nil otherwise
func (ts *QwenTokenStorage) SaveTokenToFile(authFilePath string) error {
misc.LogSavingCredentials(authFilePath)
ts.Type = "qwen"
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() {
_ = f.Close()
}()
if err = json.NewEncoder(f).Encode(ts); err != nil {
return fmt.Errorf("failed to write token to file: %w", err)
}
return nil
}

146
internal/browser/browser.go Normal file
View File

@@ -0,0 +1,146 @@
// Package browser provides cross-platform functionality for opening URLs in the default web browser.
// It abstracts the underlying operating system commands and provides a simple interface.
package browser
import (
"fmt"
"os/exec"
"runtime"
log "github.com/sirupsen/logrus"
"github.com/skratchdot/open-golang/open"
)
// OpenURL opens the specified URL in the default web browser.
// It first attempts to use a platform-agnostic library and falls back to
// platform-specific commands if that fails.
//
// Parameters:
// - url: The URL to open.
//
// Returns:
// - An error if the URL cannot be opened, otherwise nil.
func OpenURL(url string) error {
fmt.Printf("Attempting to open URL in browser: %s\n", url)
// Try using the open-golang library first
err := open.Run(url)
if err == nil {
log.Debug("Successfully opened URL using open-golang library")
return nil
}
log.Debugf("open-golang failed: %v, trying platform-specific commands", err)
// Fallback to platform-specific commands
return openURLPlatformSpecific(url)
}
// openURLPlatformSpecific is a helper function that opens a URL using OS-specific commands.
// This serves as a fallback mechanism for OpenURL.
//
// Parameters:
// - url: The URL to open.
//
// Returns:
// - An error if the URL cannot be opened, otherwise nil.
func openURLPlatformSpecific(url string) error {
var cmd *exec.Cmd
switch runtime.GOOS {
case "darwin": // macOS
cmd = exec.Command("open", url)
case "windows":
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
case "linux":
// Try common Linux browsers in order of preference
browsers := []string{"xdg-open", "x-www-browser", "www-browser", "firefox", "chromium", "google-chrome"}
for _, browser := range browsers {
if _, err := exec.LookPath(browser); err == nil {
cmd = exec.Command(browser, url)
break
}
}
if cmd == nil {
return fmt.Errorf("no suitable browser found on Linux system")
}
default:
return fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
}
log.Debugf("Running command: %s %v", cmd.Path, cmd.Args[1:])
err := cmd.Start()
if err != nil {
return fmt.Errorf("failed to start browser command: %w", err)
}
log.Debug("Successfully opened URL using platform-specific command")
return nil
}
// IsAvailable checks if the system has a command available to open a web browser.
// It verifies the presence of necessary commands for the current operating system.
//
// Returns:
// - true if a browser can be opened, false otherwise.
func IsAvailable() bool {
// First check if open-golang can work
testErr := open.Run("about:blank")
if testErr == nil {
return true
}
// Check platform-specific commands
switch runtime.GOOS {
case "darwin":
_, err := exec.LookPath("open")
return err == nil
case "windows":
_, err := exec.LookPath("rundll32")
return err == nil
case "linux":
browsers := []string{"xdg-open", "x-www-browser", "www-browser", "firefox", "chromium", "google-chrome"}
for _, browser := range browsers {
if _, err := exec.LookPath(browser); err == nil {
return true
}
}
return false
default:
return false
}
}
// GetPlatformInfo returns a map containing details about the current platform's
// browser opening capabilities, including the OS, architecture, and available commands.
//
// Returns:
// - A map with platform-specific browser support information.
func GetPlatformInfo() map[string]interface{} {
info := map[string]interface{}{
"os": runtime.GOOS,
"arch": runtime.GOARCH,
"available": IsAvailable(),
}
switch runtime.GOOS {
case "darwin":
info["default_command"] = "open"
case "windows":
info["default_command"] = "rundll32"
case "linux":
browsers := []string{"xdg-open", "x-www-browser", "www-browser", "firefox", "chromium", "google-chrome"}
var availableBrowsers []string
for _, browser := range browsers {
if _, err := exec.LookPath(browser); err == nil {
availableBrowsers = append(availableBrowsers, browser)
}
}
info["available_browsers"] = availableBrowsers
if len(availableBrowsers) > 0 {
info["default_command"] = availableBrowsers[0]
}
}
return info
}

View File

@@ -1,957 +0,0 @@
package client
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
"github.com/luispater/CLIProxyAPI/internal/auth"
"github.com/luispater/CLIProxyAPI/internal/config"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
"golang.org/x/oauth2"
)
const (
codeAssistEndpoint = "https://cloudcode-pa.googleapis.com"
apiVersion = "v1internal"
pluginVersion = "0.1.9"
glEndPoint = "https://generativelanguage.googleapis.com"
glApiVersion = "v1beta"
)
var (
previewModels = map[string][]string{
"gemini-2.5-pro": {"gemini-2.5-pro-preview-05-06", "gemini-2.5-pro-preview-06-05"},
"gemini-2.5-flash": {"gemini-2.5-flash-preview-04-17", "gemini-2.5-flash-preview-05-20"},
}
)
// Client is the main client for interacting with the CLI API.
type Client struct {
httpClient *http.Client
RequestMutex sync.Mutex
tokenStorage *auth.TokenStorage
cfg *config.Config
modelQuotaExceeded map[string]*time.Time
glAPIKey string
}
// NewClient creates a new CLI API client.
func NewClient(httpClient *http.Client, ts *auth.TokenStorage, cfg *config.Config, glAPIKey ...string) *Client {
var glKey string
if len(glAPIKey) > 0 {
glKey = glAPIKey[0]
}
return &Client{
httpClient: httpClient,
tokenStorage: ts,
cfg: cfg,
modelQuotaExceeded: make(map[string]*time.Time),
glAPIKey: glKey,
}
}
func (c *Client) SetProjectID(projectID string) {
c.tokenStorage.ProjectID = projectID
}
func (c *Client) SetIsAuto(auto bool) {
c.tokenStorage.Auto = auto
}
func (c *Client) SetIsChecked(checked bool) {
c.tokenStorage.Checked = checked
}
func (c *Client) IsChecked() bool {
return c.tokenStorage.Checked
}
func (c *Client) IsAuto() bool {
return c.tokenStorage.Auto
}
func (c *Client) GetEmail() string {
return c.tokenStorage.Email
}
func (c *Client) GetProjectID() string {
if c.tokenStorage != nil {
return c.tokenStorage.ProjectID
}
return ""
}
func (c *Client) GetGenerativeLanguageAPIKey() string {
return c.glAPIKey
}
// SetupUser performs the initial user onboarding and setup.
func (c *Client) SetupUser(ctx context.Context, email, projectID string) error {
c.tokenStorage.Email = email
log.Info("Performing user onboarding...")
// 1. LoadCodeAssist
loadAssistReqBody := map[string]interface{}{
"metadata": getClientMetadata(),
}
if projectID != "" {
loadAssistReqBody["cloudaicompanionProject"] = projectID
}
var loadAssistResp map[string]interface{}
err := c.makeAPIRequest(ctx, "loadCodeAssist", "POST", loadAssistReqBody, &loadAssistResp)
if err != nil {
return fmt.Errorf("failed to load code assist: %w", err)
}
// a, _ := json.Marshal(&loadAssistResp)
// log.Debug(string(a))
//
// a, _ = json.Marshal(loadAssistReqBody)
// log.Debug(string(a))
// 2. OnboardUser
var onboardTierID = "legacy-tier"
if tiers, ok := loadAssistResp["allowedTiers"].([]interface{}); ok {
for _, t := range tiers {
if tier, tierOk := t.(map[string]interface{}); tierOk {
if isDefault, isDefaultOk := tier["isDefault"].(bool); isDefaultOk && isDefault {
if id, idOk := tier["id"].(string); idOk {
onboardTierID = id
break
}
}
}
}
}
onboardProjectID := projectID
if p, ok := loadAssistResp["cloudaicompanionProject"].(string); ok && p != "" {
onboardProjectID = p
}
onboardReqBody := map[string]interface{}{
"tierId": onboardTierID,
"metadata": getClientMetadata(),
}
if onboardProjectID != "" {
onboardReqBody["cloudaicompanionProject"] = onboardProjectID
} else {
return fmt.Errorf("failed to start user onboarding, need define a project id")
}
for {
var lroResp map[string]interface{}
err = c.makeAPIRequest(ctx, "onboardUser", "POST", onboardReqBody, &lroResp)
if err != nil {
return fmt.Errorf("failed to start user onboarding: %w", err)
}
// a, _ := json.Marshal(&lroResp)
// log.Debug(string(a))
// 3. Poll Long-Running Operation (LRO)
done, doneOk := lroResp["done"].(bool)
if doneOk && done {
if project, projectOk := lroResp["response"].(map[string]interface{})["cloudaicompanionProject"].(map[string]interface{}); projectOk {
if projectID != "" {
c.tokenStorage.ProjectID = projectID
} else {
c.tokenStorage.ProjectID = project["id"].(string)
}
log.Infof("Onboarding complete. Using Project ID: %s", c.tokenStorage.ProjectID)
return nil
}
} else {
log.Println("Onboarding in progress, waiting 5 seconds...")
time.Sleep(5 * time.Second)
}
}
}
// makeAPIRequest handles making requests to the CLI API endpoints.
func (c *Client) makeAPIRequest(ctx context.Context, endpoint, method string, body interface{}, result interface{}) error {
var reqBody io.Reader
if body != nil {
jsonBody, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("failed to marshal request body: %w", err)
}
reqBody = bytes.NewBuffer(jsonBody)
}
url := fmt.Sprintf("%s/%s:%s", codeAssistEndpoint, apiVersion, endpoint)
if strings.HasPrefix(endpoint, "operations/") {
url = fmt.Sprintf("%s/%s", codeAssistEndpoint, endpoint)
}
req, err := http.NewRequestWithContext(ctx, method, url, reqBody)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
token, err := c.httpClient.Transport.(*oauth2.Transport).Source.Token()
if err != nil {
return fmt.Errorf("failed to get token: %w", err)
}
// Set headers
metadataStr := getClientMetadataString()
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", getUserAgent())
req.Header.Set("X-Goog-Api-Client", "gl-node/22.17.0")
req.Header.Set("Client-Metadata", metadataStr)
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken))
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("failed to execute request: %w", err)
}
defer func() {
if err = resp.Body.Close(); err != nil {
log.Printf("warn: failed to close response body: %v", err)
}
}()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
bodyBytes, _ := io.ReadAll(resp.Body)
return fmt.Errorf("api request failed with status %d: %s", resp.StatusCode, string(bodyBytes))
}
if result != nil {
if err = json.NewDecoder(resp.Body).Decode(result); err != nil {
return fmt.Errorf("failed to decode response body: %w", err)
}
}
return nil
}
// APIRequest handles making requests to the CLI API endpoints.
func (c *Client) APIRequest(ctx context.Context, endpoint string, body interface{}, alt string, stream bool) (io.ReadCloser, *ErrorMessage) {
var jsonBody []byte
var err error
if byteBody, ok := body.([]byte); ok {
jsonBody = byteBody
} else {
jsonBody, err = json.Marshal(body)
if err != nil {
return nil, &ErrorMessage{500, fmt.Errorf("failed to marshal request body: %w", err)}
}
}
var url string
if c.glAPIKey == "" {
// Add alt=sse for streaming
url = fmt.Sprintf("%s/%s:%s", codeAssistEndpoint, apiVersion, endpoint)
if alt == "" && stream {
url = url + "?alt=sse"
} else {
if alt != "" {
url = url + fmt.Sprintf("?$alt=%s", alt)
}
}
} else {
if endpoint == "countTokens" {
modelResult := gjson.GetBytes(jsonBody, "model")
url = fmt.Sprintf("%s/%s/models/%s:%s", glEndPoint, glApiVersion, modelResult.String(), endpoint)
} else {
modelResult := gjson.GetBytes(jsonBody, "model")
url = fmt.Sprintf("%s/%s/models/%s:%s", glEndPoint, glApiVersion, modelResult.String(), endpoint)
if alt == "" && stream {
url = url + "?alt=sse"
} else {
if alt != "" {
url = url + fmt.Sprintf("?$alt=%s", alt)
}
}
jsonBody = []byte(gjson.GetBytes(jsonBody, "request").Raw)
systemInstructionResult := gjson.GetBytes(jsonBody, "systemInstruction")
if systemInstructionResult.Exists() {
jsonBody, _ = sjson.SetRawBytes(jsonBody, "system_instruction", []byte(systemInstructionResult.Raw))
jsonBody, _ = sjson.DeleteBytes(jsonBody, "systemInstruction")
jsonBody, _ = sjson.DeleteBytes(jsonBody, "session_id")
}
}
}
// log.Debug(string(jsonBody))
// log.Debug(url)
reqBody := bytes.NewBuffer(jsonBody)
req, err := http.NewRequestWithContext(ctx, "POST", url, reqBody)
if err != nil {
return nil, &ErrorMessage{500, fmt.Errorf("failed to create request: %v", err)}
}
// Set headers
metadataStr := getClientMetadataString()
req.Header.Set("Content-Type", "application/json")
if c.glAPIKey == "" {
token, errToken := c.httpClient.Transport.(*oauth2.Transport).Source.Token()
if errToken != nil {
return nil, &ErrorMessage{500, fmt.Errorf("failed to get token: %v", errToken)}
}
req.Header.Set("User-Agent", getUserAgent())
req.Header.Set("X-Goog-Api-Client", "gl-node/22.17.0")
req.Header.Set("Client-Metadata", metadataStr)
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken))
} else {
req.Header.Set("x-goog-api-key", c.glAPIKey)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, &ErrorMessage{500, fmt.Errorf("failed to execute request: %v", err)}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
defer func() {
if err = resp.Body.Close(); err != nil {
log.Printf("warn: failed to close response body: %v", err)
}
}()
bodyBytes, _ := io.ReadAll(resp.Body)
// log.Debug(string(jsonBody))
return nil, &ErrorMessage{resp.StatusCode, fmt.Errorf(string(bodyBytes))}
}
return resp.Body, nil
}
// SendMessage handles a single conversational turn, including tool calls.
func (c *Client) SendMessage(ctx context.Context, rawJson []byte, model string, systemInstruction *Content, contents []Content, tools []ToolDeclaration) ([]byte, *ErrorMessage) {
request := GenerateContentRequest{
Contents: contents,
GenerationConfig: GenerationConfig{
ThinkingConfig: GenerationConfigThinkingConfig{
IncludeThoughts: true,
},
},
}
request.SystemInstruction = systemInstruction
request.Tools = tools
requestBody := map[string]interface{}{
"project": c.GetProjectID(), // Assuming ProjectID is available
"request": request,
"model": model,
}
byteRequestBody, _ := json.Marshal(requestBody)
// log.Debug(string(byteRequestBody))
reasoningEffortResult := gjson.GetBytes(rawJson, "reasoning_effort")
if reasoningEffortResult.String() == "none" {
byteRequestBody, _ = sjson.DeleteBytes(byteRequestBody, "request.generationConfig.thinkingConfig.include_thoughts")
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.thinkingConfig.thinkingBudget", 0)
} else if reasoningEffortResult.String() == "auto" {
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.thinkingConfig.thinkingBudget", -1)
} else if reasoningEffortResult.String() == "low" {
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.thinkingConfig.thinkingBudget", 1024)
} else if reasoningEffortResult.String() == "medium" {
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.thinkingConfig.thinkingBudget", 8192)
} else if reasoningEffortResult.String() == "high" {
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.thinkingConfig.thinkingBudget", 24576)
} else {
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.thinkingConfig.thinkingBudget", -1)
}
temperatureResult := gjson.GetBytes(rawJson, "temperature")
if temperatureResult.Exists() && temperatureResult.Type == gjson.Number {
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.temperature", temperatureResult.Num)
}
topPResult := gjson.GetBytes(rawJson, "top_p")
if topPResult.Exists() && topPResult.Type == gjson.Number {
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.topP", topPResult.Num)
}
topKResult := gjson.GetBytes(rawJson, "top_k")
if topKResult.Exists() && topKResult.Type == gjson.Number {
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.topK", topKResult.Num)
}
modelName := model
// log.Debug(string(byteRequestBody))
for {
if c.isModelQuotaExceeded(modelName) {
if c.cfg.QuotaExceeded.SwitchPreviewModel && c.glAPIKey == "" {
modelName = c.getPreviewModel(model)
if modelName != "" {
log.Debugf("Model %s is quota exceeded. Switch to preview model %s", model, modelName)
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "model", modelName)
continue
}
}
return nil, &ErrorMessage{
StatusCode: 429,
Error: fmt.Errorf(`{"error":{"code":429,"message":"All the models of '%s' are quota exceeded","status":"RESOURCE_EXHAUSTED"}}`, model),
}
}
respBody, err := c.APIRequest(ctx, "generateContent", byteRequestBody, "", false)
if err != nil {
if err.StatusCode == 429 {
now := time.Now()
c.modelQuotaExceeded[modelName] = &now
if c.cfg.QuotaExceeded.SwitchPreviewModel && c.glAPIKey == "" {
continue
}
}
return nil, err
}
delete(c.modelQuotaExceeded, modelName)
bodyBytes, errReadAll := io.ReadAll(respBody)
if errReadAll != nil {
return nil, &ErrorMessage{StatusCode: 500, Error: errReadAll}
}
return bodyBytes, nil
}
}
// SendMessageStream handles streaming conversational turns with comprehensive parameter management.
// This function implements a sophisticated streaming system that supports tool calls, reasoning modes,
// quota management, and automatic model fallback. It returns two channels for asynchronous communication:
// one for streaming response data and another for error handling.
func (c *Client) SendMessageStream(ctx context.Context, rawJson []byte, model string, systemInstruction *Content, contents []Content, tools []ToolDeclaration, includeThoughts ...bool) (<-chan []byte, <-chan *ErrorMessage) {
// Define the data prefix used in Server-Sent Events streaming format
dataTag := []byte("data: ")
// Create channels for asynchronous communication
// errChan: delivers error messages during streaming
// dataChan: delivers response data chunks
errChan := make(chan *ErrorMessage)
dataChan := make(chan []byte)
// Launch a goroutine to handle the streaming process asynchronously
// This allows the function to return immediately while processing continues in the background
go func() {
// Ensure channels are properly closed when the goroutine exits
defer close(errChan)
defer close(dataChan)
// Configure thinking/reasoning capabilities
// Default to including thoughts unless explicitly disabled
includeThoughtsFlag := true
if len(includeThoughts) > 0 {
includeThoughtsFlag = includeThoughts[0]
}
// Build the base request structure for the Gemini API
// This includes conversation contents and generation configuration
request := GenerateContentRequest{
Contents: contents,
GenerationConfig: GenerationConfig{
ThinkingConfig: GenerationConfigThinkingConfig{
IncludeThoughts: includeThoughtsFlag,
},
},
}
// Add system instructions if provided
// System instructions guide the AI's behavior and response style
request.SystemInstruction = systemInstruction
// Add available tools for function calling capabilities
// Tools allow the AI to perform actions beyond text generation
request.Tools = tools
// Construct the complete request body with project context
// The project ID is essential for proper API routing and billing
requestBody := map[string]interface{}{
"project": c.GetProjectID(), // Project ID for API routing and quota management
"request": request,
"model": model,
}
// Serialize the request body to JSON for API transmission
byteRequestBody, _ := json.Marshal(requestBody)
// Parse and configure reasoning effort levels from the original request
// This maps Claude-style reasoning effort parameters to Gemini's thinking budget system
reasoningEffortResult := gjson.GetBytes(rawJson, "reasoning_effort")
if reasoningEffortResult.String() == "none" {
// Disable thinking entirely for fastest responses
byteRequestBody, _ = sjson.DeleteBytes(byteRequestBody, "request.generationConfig.thinkingConfig.include_thoughts")
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.thinkingConfig.thinkingBudget", 0)
} else if reasoningEffortResult.String() == "auto" {
// Let the model decide the appropriate thinking budget automatically
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.thinkingConfig.thinkingBudget", -1)
} else if reasoningEffortResult.String() == "low" {
// Minimal thinking for simple tasks (1KB thinking budget)
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.thinkingConfig.thinkingBudget", 1024)
} else if reasoningEffortResult.String() == "medium" {
// Moderate thinking for complex tasks (8KB thinking budget)
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.thinkingConfig.thinkingBudget", 8192)
} else if reasoningEffortResult.String() == "high" {
// Maximum thinking for very complex tasks (24KB thinking budget)
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.thinkingConfig.thinkingBudget", 24576)
} else {
// Default to automatic thinking budget if no specific level is provided
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.thinkingConfig.thinkingBudget", -1)
}
// Configure temperature parameter for response randomness control
// Temperature affects the creativity vs consistency trade-off in responses
temperatureResult := gjson.GetBytes(rawJson, "temperature")
if temperatureResult.Exists() && temperatureResult.Type == gjson.Number {
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.temperature", temperatureResult.Num)
}
// Configure top-p parameter for nucleus sampling
// Controls the cumulative probability threshold for token selection
topPResult := gjson.GetBytes(rawJson, "top_p")
if topPResult.Exists() && topPResult.Type == gjson.Number {
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.topP", topPResult.Num)
}
// Configure top-k parameter for limiting token candidates
// Restricts the model to consider only the top K most likely tokens
topKResult := gjson.GetBytes(rawJson, "top_k")
if topKResult.Exists() && topKResult.Type == gjson.Number {
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "request.generationConfig.topK", topKResult.Num)
}
// Initialize model name for quota management and potential fallback
modelName := model
var stream io.ReadCloser
// Quota management and model fallback loop
// This loop handles quota exceeded scenarios and automatic model switching
for {
// Check if the current model has exceeded its quota
if c.isModelQuotaExceeded(modelName) {
// Attempt to switch to a preview model if configured and using account auth
if c.cfg.QuotaExceeded.SwitchPreviewModel && c.glAPIKey == "" {
modelName = c.getPreviewModel(model)
if modelName != "" {
log.Debugf("Model %s is quota exceeded. Switch to preview model %s", model, modelName)
// Update the request body with the new model name
byteRequestBody, _ = sjson.SetBytes(byteRequestBody, "model", modelName)
continue // Retry with the preview model
}
}
// If no fallback is available, return a quota exceeded error
errChan <- &ErrorMessage{
StatusCode: 429,
Error: fmt.Errorf(`{"error":{"code":429,"message":"All the models of '%s' are quota exceeded","status":"RESOURCE_EXHAUSTED"}}`, model),
}
return
}
// Attempt to establish a streaming connection with the API
var err *ErrorMessage
stream, err = c.APIRequest(ctx, "streamGenerateContent", byteRequestBody, "", true)
if err != nil {
// Handle quota exceeded errors by marking the model and potentially retrying
if err.StatusCode == 429 {
now := time.Now()
c.modelQuotaExceeded[modelName] = &now // Mark model as quota exceeded
// If preview model switching is enabled, retry the loop
if c.cfg.QuotaExceeded.SwitchPreviewModel && c.glAPIKey == "" {
continue
}
}
// Forward other errors to the error channel
errChan <- err
return
}
// Clear any previous quota exceeded status for this model
delete(c.modelQuotaExceeded, modelName)
break // Successfully established connection, exit the retry loop
}
// Process the streaming response using a scanner
// This handles the Server-Sent Events format from the API
scanner := bufio.NewScanner(stream)
for scanner.Scan() {
line := scanner.Bytes()
// Filter and forward only data lines (those prefixed with "data: ")
// This extracts the actual JSON content from the SSE format
if bytes.HasPrefix(line, dataTag) {
dataChan <- line[6:] // Remove "data: " prefix and send the JSON content
}
}
// Handle any scanning errors that occurred during stream processing
if errScanner := scanner.Err(); errScanner != nil {
// Send a 500 Internal Server Error for scanning failures
errChan <- &ErrorMessage{500, errScanner}
_ = stream.Close()
return
}
// Ensure the stream is properly closed to prevent resource leaks
_ = stream.Close()
}()
// Return the channels immediately for asynchronous communication
// The caller can read from these channels while the goroutine processes the request
return dataChan, errChan
}
// SendRawTokenCount handles a token count.
func (c *Client) SendRawTokenCount(ctx context.Context, rawJson []byte, alt string) ([]byte, *ErrorMessage) {
modelResult := gjson.GetBytes(rawJson, "model")
model := modelResult.String()
modelName := model
for {
if c.isModelQuotaExceeded(modelName) {
if c.cfg.QuotaExceeded.SwitchPreviewModel && c.glAPIKey == "" {
modelName = c.getPreviewModel(model)
if modelName != "" {
log.Debugf("Model %s is quota exceeded. Switch to preview model %s", model, modelName)
rawJson, _ = sjson.SetBytes(rawJson, "model", modelName)
continue
}
}
return nil, &ErrorMessage{
StatusCode: 429,
Error: fmt.Errorf(`{"error":{"code":429,"message":"All the models of '%s' are quota exceeded","status":"RESOURCE_EXHAUSTED"}}`, model),
}
}
respBody, err := c.APIRequest(ctx, "countTokens", rawJson, alt, false)
if err != nil {
if err.StatusCode == 429 {
now := time.Now()
c.modelQuotaExceeded[modelName] = &now
if c.cfg.QuotaExceeded.SwitchPreviewModel && c.glAPIKey == "" {
continue
}
}
return nil, err
}
delete(c.modelQuotaExceeded, modelName)
bodyBytes, errReadAll := io.ReadAll(respBody)
if errReadAll != nil {
return nil, &ErrorMessage{StatusCode: 500, Error: errReadAll}
}
return bodyBytes, nil
}
}
// SendRawMessage handles a single conversational turn, including tool calls.
func (c *Client) SendRawMessage(ctx context.Context, rawJson []byte, alt string) ([]byte, *ErrorMessage) {
if c.glAPIKey == "" {
rawJson, _ = sjson.SetBytes(rawJson, "project", c.GetProjectID())
}
modelResult := gjson.GetBytes(rawJson, "model")
model := modelResult.String()
modelName := model
for {
if c.isModelQuotaExceeded(modelName) {
if c.cfg.QuotaExceeded.SwitchPreviewModel && c.glAPIKey == "" {
modelName = c.getPreviewModel(model)
if modelName != "" {
log.Debugf("Model %s is quota exceeded. Switch to preview model %s", model, modelName)
rawJson, _ = sjson.SetBytes(rawJson, "model", modelName)
continue
}
}
return nil, &ErrorMessage{
StatusCode: 429,
Error: fmt.Errorf(`{"error":{"code":429,"message":"All the models of '%s' are quota exceeded","status":"RESOURCE_EXHAUSTED"}}`, model),
}
}
respBody, err := c.APIRequest(ctx, "generateContent", rawJson, alt, false)
if err != nil {
if err.StatusCode == 429 {
now := time.Now()
c.modelQuotaExceeded[modelName] = &now
if c.cfg.QuotaExceeded.SwitchPreviewModel && c.glAPIKey == "" {
continue
}
}
return nil, err
}
delete(c.modelQuotaExceeded, modelName)
bodyBytes, errReadAll := io.ReadAll(respBody)
if errReadAll != nil {
return nil, &ErrorMessage{StatusCode: 500, Error: errReadAll}
}
return bodyBytes, nil
}
}
// SendRawMessageStream handles a single conversational turn, including tool calls.
func (c *Client) SendRawMessageStream(ctx context.Context, rawJson []byte, alt string) (<-chan []byte, <-chan *ErrorMessage) {
dataTag := []byte("data: ")
errChan := make(chan *ErrorMessage)
dataChan := make(chan []byte)
go func() {
defer close(errChan)
defer close(dataChan)
if c.glAPIKey == "" {
rawJson, _ = sjson.SetBytes(rawJson, "project", c.GetProjectID())
}
modelResult := gjson.GetBytes(rawJson, "model")
model := modelResult.String()
modelName := model
var stream io.ReadCloser
for {
if c.isModelQuotaExceeded(modelName) {
if c.cfg.QuotaExceeded.SwitchPreviewModel && c.glAPIKey == "" {
modelName = c.getPreviewModel(model)
if modelName != "" {
log.Debugf("Model %s is quota exceeded. Switch to preview model %s", model, modelName)
rawJson, _ = sjson.SetBytes(rawJson, "model", modelName)
continue
}
}
errChan <- &ErrorMessage{
StatusCode: 429,
Error: fmt.Errorf(`{"error":{"code":429,"message":"All the models of '%s' are quota exceeded","status":"RESOURCE_EXHAUSTED"}}`, model),
}
return
}
var err *ErrorMessage
stream, err = c.APIRequest(ctx, "streamGenerateContent", rawJson, alt, true)
if err != nil {
if err.StatusCode == 429 {
now := time.Now()
c.modelQuotaExceeded[modelName] = &now
if c.cfg.QuotaExceeded.SwitchPreviewModel && c.glAPIKey == "" {
continue
}
}
errChan <- err
return
}
delete(c.modelQuotaExceeded, modelName)
break
}
if alt == "" {
scanner := bufio.NewScanner(stream)
for scanner.Scan() {
line := scanner.Bytes()
if bytes.HasPrefix(line, dataTag) {
dataChan <- line[6:]
}
}
if errScanner := scanner.Err(); errScanner != nil {
errChan <- &ErrorMessage{500, errScanner}
_ = stream.Close()
return
}
} else {
data, err := io.ReadAll(stream)
if err != nil {
errChan <- &ErrorMessage{500, err}
_ = stream.Close()
return
}
dataChan <- data
}
_ = stream.Close()
}()
return dataChan, errChan
}
func (c *Client) isModelQuotaExceeded(model string) bool {
if lastExceededTime, hasKey := c.modelQuotaExceeded[model]; hasKey {
duration := time.Now().Sub(*lastExceededTime)
if duration > 30*time.Minute {
return false
}
return true
}
return false
}
func (c *Client) getPreviewModel(model string) string {
if models, hasKey := previewModels[model]; hasKey {
for i := 0; i < len(models); i++ {
if !c.isModelQuotaExceeded(models[i]) {
return models[i]
}
}
}
return ""
}
func (c *Client) IsModelQuotaExceeded(model string) bool {
if c.isModelQuotaExceeded(model) {
if c.cfg.QuotaExceeded.SwitchPreviewModel {
return c.getPreviewModel(model) == ""
}
return true
}
return false
}
// CheckCloudAPIIsEnabled sends a simple test request to the API to verify
// that the Cloud AI API is enabled for the user's project. It provides
// an activation URL if the API is disabled.
func (c *Client) CheckCloudAPIIsEnabled() (bool, error) {
ctx, cancel := context.WithCancel(context.Background())
defer func() {
c.RequestMutex.Unlock()
cancel()
}()
c.RequestMutex.Lock()
// A simple request to test the API endpoint.
requestBody := fmt.Sprintf(`{"project":"%s","request":{"contents":[{"role":"user","parts":[{"text":"Be concise. What is the capital of France?"}]}],"generationConfig":{"thinkingConfig":{"include_thoughts":false,"thinkingBudget":0}}},"model":"gemini-2.5-flash"}`, c.tokenStorage.ProjectID)
stream, err := c.APIRequest(ctx, "streamGenerateContent", []byte(requestBody), "", true)
if err != nil {
// If a 403 Forbidden error occurs, it likely means the API is not enabled.
if err.StatusCode == 403 {
errJson := err.Error.Error()
// Check for a specific error code and extract the activation URL.
if gjson.Get(errJson, "error.code").Int() == 403 {
activationUrl := gjson.Get(errJson, "error.details.0.metadata.activationUrl").String()
if activationUrl != "" {
log.Warnf(
"\n\nPlease activate your account with this url:\n\n%s\n And execute this command again:\n%s --login --project_id %s",
activationUrl,
os.Args[0],
c.tokenStorage.ProjectID,
)
}
}
log.Warnf("\n\nPlease copy this message and create an issue.\n\n%s\n\n", errJson)
return false, nil
}
return false, err.Error
}
defer func() {
_ = stream.Close()
}()
// We only need to know if the request was successful, so we can drain the stream.
scanner := bufio.NewScanner(stream)
for scanner.Scan() {
// Do nothing, just consume the stream.
}
return scanner.Err() == nil, scanner.Err()
}
// GetProjectList fetches a list of Google Cloud projects accessible by the user.
func (c *Client) GetProjectList(ctx context.Context) (*GCPProject, error) {
token, err := c.httpClient.Transport.(*oauth2.Transport).Source.Token()
if err != nil {
return nil, fmt.Errorf("failed to get token: %w", err)
}
req, err := http.NewRequestWithContext(ctx, "GET", "https://cloudresourcemanager.googleapis.com/v1/projects", nil)
if err != nil {
return nil, fmt.Errorf("could not create project list request: %v", err)
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken))
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to execute project list request: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("project list request failed with status %d: %s", resp.StatusCode, string(bodyBytes))
}
var project GCPProject
if err = json.NewDecoder(resp.Body).Decode(&project); err != nil {
return nil, fmt.Errorf("failed to unmarshal project list: %w", err)
}
return &project, nil
}
// SaveTokenToFile serializes the client's current token storage to a JSON file.
// The filename is constructed from the user's email and project ID.
func (c *Client) SaveTokenToFile() error {
if err := os.MkdirAll(c.cfg.AuthDir, 0700); err != nil {
return fmt.Errorf("failed to create directory: %v", err)
}
fileName := filepath.Join(c.cfg.AuthDir, fmt.Sprintf("%s-%s.json", c.tokenStorage.Email, c.tokenStorage.ProjectID))
log.Infof("Saving credentials to %s", fileName)
f, err := os.Create(fileName)
if err != nil {
return fmt.Errorf("failed to create token file: %w", err)
}
defer func() {
_ = f.Close()
}()
if err = json.NewEncoder(f).Encode(c.tokenStorage); err != nil {
return fmt.Errorf("failed to write token to file: %w", err)
}
return nil
}
// getClientMetadata returns a map of metadata about the client environment,
// such as IDE type, platform, and plugin version.
func getClientMetadata() map[string]string {
return map[string]string{
"ideType": "IDE_UNSPECIFIED",
"platform": "PLATFORM_UNSPECIFIED",
"pluginType": "GEMINI",
// "pluginVersion": pluginVersion,
}
}
// getClientMetadataString returns the client metadata as a single,
// comma-separated string, which is required for the 'Client-Metadata' header.
func getClientMetadataString() string {
md := getClientMetadata()
parts := make([]string, 0, len(md))
for k, v := range md {
parts = append(parts, fmt.Sprintf("%s=%s", k, v))
}
return strings.Join(parts, ",")
}
// getUserAgent constructs the User-Agent string for HTTP requests.
func getUserAgent() string {
// return fmt.Sprintf("GeminiCLI/%s (%s; %s)", pluginVersion, runtime.GOOS, runtime.GOARCH)
return "google-api-nodejs-client/9.15.1"
}
// getPlatform determines the operating system and architecture and formats
// it into a string expected by the backend API.
func getPlatform() string {
goOS := runtime.GOOS
arch := runtime.GOARCH
switch goOS {
case "darwin":
return fmt.Sprintf("DARWIN_%s", strings.ToUpper(arch))
case "linux":
return fmt.Sprintf("LINUX_%s", strings.ToUpper(arch))
case "windows":
return fmt.Sprintf("WINDOWS_%s", strings.ToUpper(arch))
default:
return "PLATFORM_UNSPECIFIED"
}
}

View File

@@ -1,91 +0,0 @@
package client
import "time"
// ErrorMessage encapsulates an error with an associated HTTP status code.
type ErrorMessage struct {
StatusCode int
Error error
}
// GCPProject represents the response structure for a Google Cloud project list request.
type GCPProject struct {
Projects []GCPProjectProjects `json:"projects"`
}
// GCPProjectLabels defines the labels associated with a GCP project.
type GCPProjectLabels struct {
GenerativeLanguage string `json:"generative-language"`
}
// GCPProjectProjects contains details about a single Google Cloud project.
type GCPProjectProjects struct {
ProjectNumber string `json:"projectNumber"`
ProjectID string `json:"projectId"`
LifecycleState string `json:"lifecycleState"`
Name string `json:"name"`
Labels GCPProjectLabels `json:"labels"`
CreateTime time.Time `json:"createTime"`
}
// Content represents a single message in a conversation, with a role and parts.
type Content struct {
Role string `json:"role"`
Parts []Part `json:"parts"`
}
// Part represents a distinct piece of content within a message, which can be
// text, inline data (like an image), a function call, or a function response.
type Part struct {
Text string `json:"text,omitempty"`
InlineData *InlineData `json:"inlineData,omitempty"`
FunctionCall *FunctionCall `json:"functionCall,omitempty"`
FunctionResponse *FunctionResponse `json:"functionResponse,omitempty"`
}
// InlineData represents base64-encoded data with its MIME type.
type InlineData struct {
MimeType string `json:"mime_type,omitempty"`
Data string `json:"data,omitempty"`
}
// FunctionCall represents a tool call requested by the model, including the
// function name and its arguments.
type FunctionCall struct {
Name string `json:"name"`
Args map[string]interface{} `json:"args"`
}
// FunctionResponse represents the result of a tool execution, sent back to the model.
type FunctionResponse struct {
Name string `json:"name"`
Response map[string]interface{} `json:"response"`
}
// GenerateContentRequest is the top-level request structure for the streamGenerateContent endpoint.
type GenerateContentRequest struct {
SystemInstruction *Content `json:"systemInstruction,omitempty"`
Contents []Content `json:"contents"`
Tools []ToolDeclaration `json:"tools,omitempty"`
GenerationConfig `json:"generationConfig"`
}
// GenerationConfig defines parameters that control the model's generation behavior.
type GenerationConfig struct {
ThinkingConfig GenerationConfigThinkingConfig `json:"thinkingConfig,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
TopP float64 `json:"topP,omitempty"`
TopK float64 `json:"topK,omitempty"`
}
// GenerationConfigThinkingConfig specifies configuration for the model's "thinking" process.
type GenerationConfigThinkingConfig struct {
// IncludeThoughts determines whether the model should output its reasoning process.
IncludeThoughts bool `json:"include_thoughts,omitempty"`
}
// ToolDeclaration defines the structure for declaring tools (like functions)
// that the model can call.
type ToolDeclaration struct {
FunctionDeclarations []interface{} `json:"functionDeclarations"`
}

View File

@@ -0,0 +1,54 @@
package cmd
import (
"context"
"errors"
"fmt"
"os"
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/claude"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
log "github.com/sirupsen/logrus"
)
// DoClaudeLogin triggers the Claude OAuth flow through the shared authentication manager.
// It initiates the OAuth authentication process for Anthropic Claude services and saves
// the authentication tokens to the configured auth directory.
//
// Parameters:
// - cfg: The application configuration
// - options: Login options including browser behavior and prompts
func DoClaudeLogin(cfg *config.Config, options *LoginOptions) {
if options == nil {
options = &LoginOptions{}
}
manager := newAuthManager()
authOpts := &sdkAuth.LoginOptions{
NoBrowser: options.NoBrowser,
Metadata: map[string]string{},
Prompt: options.Prompt,
}
_, savedPath, err := manager.Login(context.Background(), "claude", cfg, authOpts)
if err != nil {
var authErr *claude.AuthenticationError
if errors.As(err, &authErr) {
log.Error(claude.GetUserFriendlyMessage(authErr))
if authErr.Type == claude.ErrPortInUse.Type {
os.Exit(claude.ErrPortInUse.Code)
}
return
}
fmt.Printf("Claude authentication failed: %v\n", err)
return
}
if savedPath != "" {
fmt.Printf("Authentication saved to %s\n", savedPath)
}
fmt.Println("Claude authentication successful!")
}

View File

@@ -0,0 +1,22 @@
package cmd
import (
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
)
// newAuthManager creates a new authentication manager instance with all supported
// authenticators and a file-based token store. It initializes authenticators for
// Gemini, Codex, Claude, and Qwen providers.
//
// Returns:
// - *sdkAuth.Manager: A configured authentication manager instance
func newAuthManager() *sdkAuth.Manager {
store := sdkAuth.GetTokenStore()
manager := sdkAuth.NewManager(store,
sdkAuth.NewGeminiAuthenticator(),
sdkAuth.NewCodexAuthenticator(),
sdkAuth.NewClaudeAuthenticator(),
sdkAuth.NewQwenAuthenticator(),
)
return manager
}

View File

@@ -0,0 +1,197 @@
// 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

@@ -1,86 +1,487 @@
// Package cmd provides command-line interface functionality for the CLI Proxy API server.
// It includes authentication flows for various AI service providers, service startup,
// and other command-line operations.
package cmd
import (
"bufio"
"bytes"
"context"
"github.com/luispater/CLIProxyAPI/internal/auth"
"github.com/luispater/CLIProxyAPI/internal/client"
"github.com/luispater/CLIProxyAPI/internal/config"
log "github.com/sirupsen/logrus"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"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/interfaces"
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
)
// DoLogin handles the entire user login and setup process.
// It authenticates the user, sets up the user's project, checks API enablement,
// and saves the token for future use.
func DoLogin(cfg *config.Config, projectID string) {
var err error
var ts auth.TokenStorage
if projectID != "" {
ts.ProjectID = projectID
const (
geminiCLIEndpoint = "https://cloudcode-pa.googleapis.com"
geminiCLIVersion = "v1internal"
geminiCLIUserAgent = "google-api-nodejs-client/9.15.1"
geminiCLIApiClient = "gl-node/22.17.0"
geminiCLIClientMetadata = "ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI"
)
type projectSelectionRequiredError struct{}
func (e *projectSelectionRequiredError) Error() string {
return "gemini cli: project selection required"
}
// DoLogin handles Google Gemini authentication using the shared authentication manager.
// It initiates the OAuth flow for Google Gemini services, performs the legacy CLI user setup,
// and saves the authentication tokens to the configured auth directory.
//
// Parameters:
// - cfg: The application configuration
// - projectID: Optional Google Cloud project ID for Gemini services
// - options: Login options including browser behavior and prompts
func DoLogin(cfg *config.Config, projectID string, options *LoginOptions) {
if options == nil {
options = &LoginOptions{}
}
// Initialize an authenticated HTTP client. This will trigger the OAuth flow if necessary.
clientCtx := context.Background()
log.Info("Initializing authentication...")
httpClient, errGetClient := auth.GetAuthenticatedClient(clientCtx, &ts, cfg)
if errGetClient != nil {
log.Fatalf("failed to get authenticated client: %v", errGetClient)
ctx := context.Background()
loginOpts := &sdkAuth.LoginOptions{
NoBrowser: options.NoBrowser,
ProjectID: strings.TrimSpace(projectID),
Metadata: map[string]string{},
Prompt: options.Prompt,
}
authenticator := sdkAuth.NewGeminiAuthenticator()
record, errLogin := authenticator.Login(ctx, cfg, loginOpts)
if errLogin != nil {
log.Fatalf("Gemini authentication failed: %v", errLogin)
return
}
log.Info("Authentication successful.")
// Initialize the API client.
cliClient := client.NewClient(httpClient, &ts, cfg)
// Perform the user setup process.
err = cliClient.SetupUser(clientCtx, ts.Email, projectID)
if err != nil {
// Handle the specific case where a project ID is required but not provided.
if err.Error() == "failed to start user onboarding, need define a project id" {
log.Error("Failed to start user onboarding: A project ID is required.")
// Fetch and display the user's available projects to help them choose one.
project, errGetProjectList := cliClient.GetProjectList(clientCtx)
if errGetProjectList != nil {
log.Fatalf("Failed to get project list: %v", err)
} else {
log.Infof("Your account %s needs to specify a project ID.", ts.Email)
log.Info("========================================================================")
for _, p := range project.Projects {
log.Infof("Project ID: %s", p.ProjectID)
log.Infof("Project Name: %s", p.Name)
log.Info("------------------------------------------------------------------------")
}
log.Infof("Please run this command to login again with a specific project:\n\n%s --login --project_id <project_id>\n", os.Args[0])
}
} else {
log.Fatalf("Failed to complete user setup: %v", err)
}
return // Exit after handling the error.
storage, okStorage := record.Storage.(*gemini.GeminiTokenStorage)
if !okStorage || storage == nil {
log.Fatal("Gemini authentication failed: unsupported token storage")
return
}
// If setup is successful, proceed to check API status and save the token.
auto := projectID == ""
cliClient.SetIsAuto(auto)
geminiAuth := gemini.NewGeminiAuth()
httpClient, errClient := geminiAuth.GetAuthenticatedClient(ctx, storage, cfg, options.NoBrowser)
if errClient != nil {
log.Fatalf("Gemini authentication failed: %v", errClient)
return
}
// If the project was not automatically selected, check if the Cloud AI API is enabled.
if !cliClient.IsChecked() && !cliClient.IsAuto() {
isChecked, checkErr := cliClient.CheckCloudAPIIsEnabled()
if checkErr != nil {
log.Fatalf("Failed to check if Cloud AI API is enabled: %v", checkErr)
log.Info("Authentication successful.")
projects, errProjects := fetchGCPProjects(ctx, httpClient)
if errProjects != nil {
log.Fatalf("Failed to get project list: %v", errProjects)
return
}
promptFn := options.Prompt
if promptFn == nil {
promptFn = defaultProjectPrompt()
}
selectedProjectID := promptForProjectSelection(projects, strings.TrimSpace(projectID), promptFn)
if strings.TrimSpace(selectedProjectID) == "" {
log.Fatal("No project selected; aborting login.")
return
}
if errSetup := performGeminiCLISetup(ctx, httpClient, storage, selectedProjectID); errSetup != nil {
var projectErr *projectSelectionRequiredError
if errors.As(errSetup, &projectErr) {
log.Error("Failed to start user onboarding: A project ID is required.")
showProjectSelectionHelp(storage.Email, projects)
return
}
cliClient.SetIsChecked(isChecked)
// If the check fails (returns false), the CheckCloudAPIIsEnabled function
// will have already printed instructions, so we can just exit.
log.Fatalf("Failed to complete user setup: %v", errSetup)
return
}
storage.Auto = false
if !storage.Auto && !storage.Checked {
isChecked, errCheck := checkCloudAPIIsEnabled(ctx, httpClient, storage.ProjectID)
if errCheck != nil {
log.Fatalf("Failed to check if Cloud AI API is enabled: %v", errCheck)
return
}
storage.Checked = isChecked
if !isChecked {
log.Fatal("Failed to check if Cloud AI API is enabled. If you encounter an error message, please create an issue.")
return
}
}
// Save the successfully obtained and verified token to a file.
err = cliClient.SaveTokenToFile()
if err != nil {
log.Fatalf("Failed to save token to file: %v", err)
updateAuthRecord(record, storage)
store := sdkAuth.GetTokenStore()
if setter, okSetter := store.(interface{ SetBaseDir(string) }); okSetter && cfg != nil {
setter.SetBaseDir(cfg.AuthDir)
}
savedPath, errSave := store.Save(ctx, record)
if errSave != nil {
log.Fatalf("Failed to save token to file: %v", errSave)
return
}
if savedPath != "" {
fmt.Printf("Authentication saved to %s\n", savedPath)
}
fmt.Println("Gemini authentication successful!")
}
func performGeminiCLISetup(ctx context.Context, httpClient *http.Client, storage *gemini.GeminiTokenStorage, requestedProject string) error {
metadata := map[string]string{
"ideType": "IDE_UNSPECIFIED",
"platform": "PLATFORM_UNSPECIFIED",
"pluginType": "GEMINI",
}
loadReqBody := map[string]any{
"metadata": metadata,
}
if requestedProject != "" {
loadReqBody["cloudaicompanionProject"] = requestedProject
}
var loadResp map[string]any
if errLoad := callGeminiCLI(ctx, httpClient, "loadCodeAssist", loadReqBody, &loadResp); errLoad != nil {
return fmt.Errorf("load code assist: %w", errLoad)
}
tierID := "legacy-tier"
if tiers, okTiers := loadResp["allowedTiers"].([]any); okTiers {
for _, rawTier := range tiers {
tier, okTier := rawTier.(map[string]any)
if !okTier {
continue
}
if isDefault, okDefault := tier["isDefault"].(bool); okDefault && isDefault {
if id, okID := tier["id"].(string); okID && strings.TrimSpace(id) != "" {
tierID = strings.TrimSpace(id)
break
}
}
}
}
projectID := strings.TrimSpace(requestedProject)
if projectID == "" {
if id, okProject := loadResp["cloudaicompanionProject"].(string); okProject {
projectID = strings.TrimSpace(id)
}
}
if projectID == "" {
return &projectSelectionRequiredError{}
}
onboardReqBody := map[string]any{
"tierId": tierID,
"metadata": metadata,
"cloudaicompanionProject": projectID,
}
// Store the requested project as a fallback in case the response omits it.
storage.ProjectID = projectID
for {
var onboardResp map[string]any
if errOnboard := callGeminiCLI(ctx, httpClient, "onboardUser", onboardReqBody, &onboardResp); errOnboard != nil {
return fmt.Errorf("onboard user: %w", errOnboard)
}
if done, okDone := onboardResp["done"].(bool); okDone && done {
if resp, okResp := onboardResp["response"].(map[string]any); okResp {
if project, okProject := resp["cloudaicompanionProject"].(map[string]any); okProject {
if id, okID := project["id"].(string); okID && strings.TrimSpace(id) != "" {
storage.ProjectID = strings.TrimSpace(id)
}
}
}
storage.ProjectID = strings.TrimSpace(storage.ProjectID)
if storage.ProjectID == "" {
storage.ProjectID = projectID
}
if storage.ProjectID == "" {
return fmt.Errorf("onboard user completed without project id")
}
log.Infof("Onboarding complete. Using Project ID: %s", storage.ProjectID)
return nil
}
log.Println("Onboarding in progress, waiting 5 seconds...")
time.Sleep(5 * time.Second)
}
}
func callGeminiCLI(ctx context.Context, httpClient *http.Client, endpoint string, body any, result any) error {
url := fmt.Sprintf("%s/%s:%s", geminiCLIEndpoint, geminiCLIVersion, endpoint)
if strings.HasPrefix(endpoint, "operations/") {
url = fmt.Sprintf("%s/%s", geminiCLIEndpoint, endpoint)
}
var reader io.Reader
if body != nil {
rawBody, errMarshal := json.Marshal(body)
if errMarshal != nil {
return fmt.Errorf("marshal request body: %w", errMarshal)
}
reader = bytes.NewReader(rawBody)
}
req, errRequest := http.NewRequestWithContext(ctx, http.MethodPost, url, reader)
if errRequest != nil {
return fmt.Errorf("create request: %w", errRequest)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", geminiCLIUserAgent)
req.Header.Set("X-Goog-Api-Client", geminiCLIApiClient)
req.Header.Set("Client-Metadata", geminiCLIClientMetadata)
resp, errDo := httpClient.Do(req)
if errDo != nil {
return fmt.Errorf("execute request: %w", errDo)
}
defer func() {
if errClose := resp.Body.Close(); errClose != nil {
log.Errorf("response body close error: %v", errClose)
}
}()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
bodyBytes, _ := io.ReadAll(resp.Body)
return fmt.Errorf("api request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(bodyBytes)))
}
if result == nil {
_, _ = io.Copy(io.Discard, resp.Body)
return nil
}
if errDecode := json.NewDecoder(resp.Body).Decode(result); errDecode != nil {
return fmt.Errorf("decode response body: %w", errDecode)
}
return nil
}
func fetchGCPProjects(ctx context.Context, httpClient *http.Client) ([]interfaces.GCPProjectProjects, error) {
req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, "https://cloudresourcemanager.googleapis.com/v1/projects", nil)
if errRequest != nil {
return nil, fmt.Errorf("could not create project list request: %w", errRequest)
}
resp, errDo := httpClient.Do(req)
if errDo != nil {
return nil, fmt.Errorf("failed to execute project list request: %w", errDo)
}
defer func() {
if errClose := resp.Body.Close(); errClose != nil {
log.Errorf("response body close error: %v", errClose)
}
}()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("project list request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(bodyBytes)))
}
var projects interfaces.GCPProject
if errDecode := json.NewDecoder(resp.Body).Decode(&projects); errDecode != nil {
return nil, fmt.Errorf("failed to unmarshal project list: %w", errDecode)
}
return projects.Projects, nil
}
// promptForProjectSelection prints available projects and returns the chosen project ID.
func promptForProjectSelection(projects []interfaces.GCPProjectProjects, presetID string, promptFn func(string) (string, error)) string {
trimmedPreset := strings.TrimSpace(presetID)
if len(projects) == 0 {
if trimmedPreset != "" {
return trimmedPreset
}
fmt.Println("No Google Cloud projects are available for selection.")
return ""
}
fmt.Println("Available Google Cloud projects:")
defaultIndex := 0
for idx, project := range projects {
fmt.Printf("[%d] %s (%s)\n", idx+1, project.ProjectID, project.Name)
if trimmedPreset != "" && project.ProjectID == trimmedPreset {
defaultIndex = idx
}
}
defaultID := projects[defaultIndex].ProjectID
if trimmedPreset != "" {
for _, project := range projects {
if project.ProjectID == trimmedPreset {
return trimmedPreset
}
}
log.Warnf("Provided project ID %s not found in available projects; please choose from the list.", trimmedPreset)
}
for {
promptMsg := fmt.Sprintf("Enter project ID [%s]: ", defaultID)
answer, errPrompt := promptFn(promptMsg)
if errPrompt != nil {
log.Errorf("Project selection prompt failed: %v", errPrompt)
return defaultID
}
answer = strings.TrimSpace(answer)
if answer == "" {
return defaultID
}
for _, project := range projects {
if project.ProjectID == answer {
return project.ProjectID
}
}
if idx, errAtoi := strconv.Atoi(answer); errAtoi == nil {
if idx >= 1 && idx <= len(projects) {
return projects[idx-1].ProjectID
}
}
fmt.Println("Invalid selection, enter a project ID or a number from the list.")
}
}
func defaultProjectPrompt() func(string) (string, error) {
reader := bufio.NewReader(os.Stdin)
return func(prompt string) (string, error) {
fmt.Print(prompt)
line, errRead := reader.ReadString('\n')
if errRead != nil {
if errors.Is(errRead, io.EOF) {
return strings.TrimSpace(line), nil
}
return "", errRead
}
return strings.TrimSpace(line), nil
}
}
func showProjectSelectionHelp(email string, projects []interfaces.GCPProjectProjects) {
if email != "" {
log.Infof("Your account %s needs to specify a project ID.", email)
} else {
log.Info("You need to specify a project ID.")
}
if len(projects) > 0 {
fmt.Println("========================================================================")
for _, p := range projects {
fmt.Printf("Project ID: %s\n", p.ProjectID)
fmt.Printf("Project Name: %s\n", p.Name)
fmt.Println("------------------------------------------------------------------------")
}
} else {
fmt.Println("No active projects were returned for this account.")
}
fmt.Printf("Please run this command to login again with a specific project:\n\n%s --login --project_id <project_id>\n", os.Args[0])
}
func checkCloudAPIIsEnabled(ctx context.Context, httpClient *http.Client, projectID string) (bool, error) {
serviceUsageURL := "https://serviceusage.googleapis.com"
requiredServices := []string{
// "geminicloudassist.googleapis.com", // Gemini Cloud Assist API
"cloudaicompanion.googleapis.com", // Gemini for Google Cloud API
}
for _, service := range requiredServices {
checkUrl := fmt.Sprintf("%s/v1/projects/%s/services/%s", serviceUsageURL, projectID, service)
req, errRequest := http.NewRequestWithContext(ctx, http.MethodGet, checkUrl, nil)
if errRequest != nil {
return false, fmt.Errorf("failed to create request: %w", errRequest)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", geminiCLIUserAgent)
resp, errDo := httpClient.Do(req)
if errDo != nil {
return false, fmt.Errorf("failed to execute request: %w", errDo)
}
if resp.StatusCode == http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
if gjson.GetBytes(bodyBytes, "state").String() == "ENABLED" {
_ = resp.Body.Close()
continue
}
}
_ = resp.Body.Close()
enableUrl := fmt.Sprintf("%s/v1/projects/%s/services/%s:enable", serviceUsageURL, projectID, service)
req, errRequest = http.NewRequestWithContext(ctx, http.MethodPost, enableUrl, strings.NewReader("{}"))
if errRequest != nil {
return false, fmt.Errorf("failed to create request: %w", errRequest)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", geminiCLIUserAgent)
resp, errDo = httpClient.Do(req)
if errDo != nil {
return false, fmt.Errorf("failed to execute request: %w", errDo)
}
bodyBytes, _ := io.ReadAll(resp.Body)
errMessage := string(bodyBytes)
errMessageResult := gjson.GetBytes(bodyBytes, "error.message")
if errMessageResult.Exists() {
errMessage = errMessageResult.String()
}
if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusCreated {
_ = resp.Body.Close()
continue
} else if resp.StatusCode == http.StatusBadRequest {
_ = resp.Body.Close()
if strings.Contains(strings.ToLower(errMessage), "already enabled") {
continue
}
}
return false, fmt.Errorf("project activation required: %s", errMessage)
}
return true, nil
}
func updateAuthRecord(record *cliproxyauth.Auth, storage *gemini.GeminiTokenStorage) {
if record == nil || storage == nil {
return
}
finalName := fmt.Sprintf("%s-%s.json", storage.Email, storage.ProjectID)
if record.Metadata == nil {
record.Metadata = make(map[string]any)
}
record.Metadata["email"] = storage.Email
record.Metadata["project_id"] = storage.ProjectID
record.Metadata["auto"] = storage.Auto
record.Metadata["checked"] = storage.Checked
record.ID = finalName
record.FileName = finalName
record.Storage = storage
}

View File

@@ -0,0 +1,64 @@
package cmd
import (
"context"
"errors"
"fmt"
"os"
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/codex"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
log "github.com/sirupsen/logrus"
)
// LoginOptions contains options for the login processes.
// It provides configuration for authentication flows including browser behavior
// and interactive prompting capabilities.
type LoginOptions struct {
// NoBrowser indicates whether to skip opening the browser automatically.
NoBrowser bool
// Prompt allows the caller to provide interactive input when needed.
Prompt func(prompt string) (string, error)
}
// DoCodexLogin triggers the Codex OAuth flow through the shared authentication manager.
// It initiates the OAuth authentication process for OpenAI Codex services and saves
// the authentication tokens to the configured auth directory.
//
// Parameters:
// - cfg: The application configuration
// - options: Login options including browser behavior and prompts
func DoCodexLogin(cfg *config.Config, options *LoginOptions) {
if options == nil {
options = &LoginOptions{}
}
manager := newAuthManager()
authOpts := &sdkAuth.LoginOptions{
NoBrowser: options.NoBrowser,
Metadata: map[string]string{},
Prompt: options.Prompt,
}
_, savedPath, err := manager.Login(context.Background(), "codex", cfg, authOpts)
if err != nil {
var authErr *codex.AuthenticationError
if errors.As(err, &authErr) {
log.Error(codex.GetUserFriendlyMessage(authErr))
if authErr.Type == codex.ErrPortInUse.Type {
os.Exit(codex.ErrPortInUse.Code)
}
return
}
fmt.Printf("Codex authentication failed: %v\n", err)
return
}
if savedPath != "" {
fmt.Printf("Authentication saved to %s\n", savedPath)
}
fmt.Println("Codex authentication successful!")
}

View File

@@ -0,0 +1,60 @@
package cmd
import (
"context"
"errors"
"fmt"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
log "github.com/sirupsen/logrus"
)
// DoQwenLogin handles the Qwen device flow using the shared authentication manager.
// It initiates the device-based authentication process for Qwen services and saves
// the authentication tokens to the configured auth directory.
//
// Parameters:
// - cfg: The application configuration
// - options: Login options including browser behavior and prompts
func DoQwenLogin(cfg *config.Config, options *LoginOptions) {
if options == nil {
options = &LoginOptions{}
}
manager := newAuthManager()
promptFn := options.Prompt
if promptFn == nil {
promptFn = func(prompt string) (string, error) {
fmt.Println()
fmt.Println(prompt)
var value string
_, err := fmt.Scanln(&value)
return value, err
}
}
authOpts := &sdkAuth.LoginOptions{
NoBrowser: options.NoBrowser,
Metadata: map[string]string{},
Prompt: promptFn,
}
_, savedPath, err := manager.Login(context.Background(), "qwen", cfg, authOpts)
if err != nil {
var emailErr *sdkAuth.EmailRequiredError
if errors.As(err, &emailErr) {
log.Error(emailErr.Error())
return
}
fmt.Printf("Qwen authentication failed: %v\n", err)
return
}
if savedPath != "" {
fmt.Printf("Authentication saved to %s\n", savedPath)
}
fmt.Println("Qwen authentication successful!")
}

View File

@@ -1,115 +1,55 @@
// Package cmd provides command-line interface functionality for the CLI Proxy API server.
// It includes authentication flows for various AI service providers, service startup,
// and other command-line operations.
package cmd
import (
"context"
"encoding/json"
"github.com/luispater/CLIProxyAPI/internal/api"
"github.com/luispater/CLIProxyAPI/internal/auth"
"github.com/luispater/CLIProxyAPI/internal/client"
"github.com/luispater/CLIProxyAPI/internal/config"
"github.com/luispater/CLIProxyAPI/internal/util"
log "github.com/sirupsen/logrus"
"io/fs"
"net/http"
"os"
"errors"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/router-for-me/CLIProxyAPI/v6/internal/api"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy"
log "github.com/sirupsen/logrus"
)
// StartService initializes and starts the main API proxy service.
// It loads all available authentication tokens, creates a pool of clients,
// starts the API server, and handles graceful shutdown signals.
func StartService(cfg *config.Config) {
// Create a pool of API clients, one for each token file found.
cliClients := make([]*client.Client, 0)
err := filepath.Walk(cfg.AuthDir, func(path string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
// StartService builds and runs the proxy service using the exported SDK.
// It creates a new proxy service instance, sets up signal handling for graceful shutdown,
// and starts the service with the provided configuration.
//
// Parameters:
// - cfg: The application configuration
// - configPath: The path to the configuration file
// - localPassword: Optional password accepted for local management requests
func StartService(cfg *config.Config, configPath string, localPassword string) {
builder := cliproxy.NewBuilder().
WithConfig(cfg).
WithConfigPath(configPath).
WithLocalManagementPassword(localPassword)
// Process only JSON files in the auth directory.
if !info.IsDir() && strings.HasSuffix(info.Name(), ".json") {
log.Debugf("Loading token from: %s", path)
f, errOpen := os.Open(path)
if errOpen != nil {
return errOpen
}
defer func() {
_ = f.Close()
}()
ctxSignal, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
// Decode the token storage file.
var ts auth.TokenStorage
if err = json.NewDecoder(f).Decode(&ts); err == nil {
// For each valid token, create an authenticated client.
clientCtx := context.Background()
log.Info("Initializing authentication for token...")
httpClient, errGetClient := auth.GetAuthenticatedClient(clientCtx, &ts, cfg)
if errGetClient != nil {
// Log fatal will exit, but we return the error for completeness.
log.Fatalf("failed to get authenticated client for token %s: %v", path, errGetClient)
return errGetClient
}
log.Info("Authentication successful.")
runCtx := ctxSignal
if localPassword != "" {
var keepAliveCancel context.CancelFunc
runCtx, keepAliveCancel = context.WithCancel(ctxSignal)
builder = builder.WithServerOptions(api.WithKeepAliveEndpoint(10*time.Second, func() {
log.Warn("keep-alive endpoint idle for 10s, shutting down")
keepAliveCancel()
}))
}
// Add the new client to the pool.
cliClient := client.NewClient(httpClient, &ts, cfg)
cliClients = append(cliClients, cliClient)
}
}
return nil
})
service, err := builder.Build()
if err != nil {
log.Fatalf("Error walking auth directory: %v", err)
log.Fatalf("failed to build proxy service: %v", err)
}
if len(cfg.GlAPIKey) > 0 {
for i := 0; i < len(cfg.GlAPIKey); i++ {
httpClient, errSetProxy := util.SetProxy(cfg, &http.Client{})
if errSetProxy != nil {
log.Fatalf("set proxy failed: %v", errSetProxy)
}
log.Debug("Initializing with Generative Language API key...")
cliClient := client.NewClient(httpClient, nil, cfg, cfg.GlAPIKey[i])
cliClients = append(cliClients, cliClient)
}
}
// Create and start the API server with the pool of clients.
apiServer := api.NewServer(cfg, cliClients)
log.Infof("Starting API server on port %d", cfg.Port)
if err = apiServer.Start(); err != nil {
log.Fatalf("API server failed to start: %v", err)
}
// Set up a channel to listen for OS signals for graceful shutdown.
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
// Main loop to wait for shutdown signal.
for {
select {
case <-sigChan:
log.Debugf("Received shutdown signal. Cleaning up...")
// Create a context with a timeout for the shutdown process.
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
_ = cancel
// Stop the API server gracefully.
if err = apiServer.Stop(ctx); err != nil {
log.Debugf("Error stopping API server: %v", err)
}
log.Debugf("Cleanup completed. Exiting...")
os.Exit(0)
case <-time.After(5 * time.Second):
// This case is currently empty and acts as a periodic check.
// It could be used for periodic tasks in the future.
}
err = service.Run(runCtx)
if err != nil && !errors.Is(err, context.Canceled) {
log.Fatalf("proxy service exited with error: %v", err)
}
}

View File

@@ -1,38 +1,182 @@
// Package config provides configuration management for the CLI Proxy API server.
// It handles loading and parsing YAML configuration files, and provides structured
// access to application settings including server port, authentication directory,
// debug settings, proxy configuration, and API keys.
package config
import (
"fmt"
"gopkg.in/yaml.v3"
"os"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
"golang.org/x/crypto/bcrypt"
"gopkg.in/yaml.v3"
)
// Config represents the application's configuration, loaded from a YAML file.
type Config struct {
config.SDKConfig `yaml:",inline"`
// Port is the network port on which the API server will listen.
Port int `yaml:"port"`
Port int `yaml:"port" json:"-"`
// AuthDir is the directory where authentication token files are stored.
AuthDir string `yaml:"auth-dir"`
AuthDir string `yaml:"auth-dir" json:"-"`
// Debug enables or disables debug-level logging and other debug features.
Debug bool `yaml:"debug"`
// ProxyUrl is the URL of an optional proxy server to use for outbound requests.
ProxyUrl string `yaml:"proxy-url"`
// ApiKeys is a list of keys for authenticating clients to this proxy server.
ApiKeys []string `yaml:"api-keys"`
Debug bool `yaml:"debug" json:"debug"`
// LoggingToFile controls whether application logs are written to rotating files or stdout.
LoggingToFile bool `yaml:"logging-to-file" json:"logging-to-file"`
// UsageStatisticsEnabled toggles in-memory usage aggregation; when false, usage data is discarded.
UsageStatisticsEnabled bool `yaml:"usage-statistics-enabled" json:"usage-statistics-enabled"`
// QuotaExceeded defines the behavior when a quota is exceeded.
QuotaExceeded ConfigQuotaExceeded `yaml:"quota-exceeded"`
QuotaExceeded QuotaExceeded `yaml:"quota-exceeded" json:"quota-exceeded"`
// GlAPIKey is the API key for the generative language API.
GlAPIKey []string `yaml:"generative-language-api-key"`
GlAPIKey []string `yaml:"generative-language-api-key" json:"generative-language-api-key"`
// RequestRetry defines the retry times when the request failed.
RequestRetry int `yaml:"request-retry" json:"request-retry"`
// ClaudeKey defines a list of Claude API key configurations as specified in the YAML configuration file.
ClaudeKey []ClaudeKey `yaml:"claude-api-key" json:"claude-api-key"`
// Codex defines a list of Codex API key configurations as specified in the YAML configuration file.
CodexKey []CodexKey `yaml:"codex-api-key" json:"codex-api-key"`
// OpenAICompatibility defines OpenAI API compatibility configurations for external providers.
OpenAICompatibility []OpenAICompatibility `yaml:"openai-compatibility" json:"openai-compatibility"`
// 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"`
}
type ConfigQuotaExceeded struct {
// GeminiWebConfig nests Gemini Web related options under 'gemini-web'.
type GeminiWebConfig struct {
// Context enables JSON-based conversation reuse.
// Defaults to true if not set in YAML (see LoadConfig).
Context bool `yaml:"context" json:"context"`
// CodeMode, when true, enables coding mode behaviors for Gemini Web:
// - Attach the predefined "Coding partner" Gem
// - Enable XML wrapping hint for tool markup
// - Merge <think> content into visible content for tool-friendly output
CodeMode bool `yaml:"code-mode" json:"code-mode"`
// MaxCharsPerRequest caps the number of characters (runes) sent to
// Gemini Web in a single request. Long prompts will be split into
// multiple requests with a continuation hint, and only the final
// request will carry any files. When unset or <=0, a conservative
// default of 1,000,000 will be used.
MaxCharsPerRequest int `yaml:"max-chars-per-request" json:"max-chars-per-request"`
// DisableContinuationHint, when true, disables the continuation hint for split prompts.
// The hint is enabled by default.
DisableContinuationHint bool `yaml:"disable-continuation-hint,omitempty" json:"disable-continuation-hint,omitempty"`
}
// RemoteManagement holds management API configuration under 'remote-management'.
type RemoteManagement struct {
// AllowRemote toggles remote (non-localhost) access to management API.
AllowRemote bool `yaml:"allow-remote"`
// SecretKey is the management key (plaintext or bcrypt hashed). YAML key intentionally 'secret-key'.
SecretKey string `yaml:"secret-key"`
// DisableControlPanel skips serving and syncing the bundled management UI when true.
DisableControlPanel bool `yaml:"disable-control-panel"`
}
// QuotaExceeded defines the behavior when API quota limits are exceeded.
// It provides configuration options for automatic failover mechanisms.
type QuotaExceeded struct {
// SwitchProject indicates whether to automatically switch to another project when a quota is exceeded.
SwitchProject bool `yaml:"switch-project"`
SwitchProject bool `yaml:"switch-project" json:"switch-project"`
// SwitchPreviewModel indicates whether to automatically switch to a preview model when a quota is exceeded.
SwitchPreviewModel bool `yaml:"switch-preview-model"`
SwitchPreviewModel bool `yaml:"switch-preview-model" json:"switch-preview-model"`
}
// ClaudeKey represents the configuration for a Claude API key,
// including the API key itself and an optional base URL for the API endpoint.
type ClaudeKey struct {
// APIKey is the authentication key for accessing Claude API services.
APIKey string `yaml:"api-key" json:"api-key"`
// BaseURL is the base URL for the Claude API endpoint.
// If empty, the default Claude API URL will be used.
BaseURL string `yaml:"base-url" json:"base-url"`
// ProxyURL overrides the global proxy setting for this API key if provided.
ProxyURL string `yaml:"proxy-url" json:"proxy-url"`
}
// CodexKey represents the configuration for a Codex API key,
// including the API key itself and an optional base URL for the API endpoint.
type CodexKey struct {
// APIKey is the authentication key for accessing Codex API services.
APIKey string `yaml:"api-key" json:"api-key"`
// BaseURL is the base URL for the Codex API endpoint.
// If empty, the default Codex API URL will be used.
BaseURL string `yaml:"base-url" json:"base-url"`
// ProxyURL overrides the global proxy setting for this API key if provided.
ProxyURL string `yaml:"proxy-url" json:"proxy-url"`
}
// OpenAICompatibility represents the configuration for OpenAI API compatibility
// with external providers, allowing model aliases to be routed through OpenAI API format.
type OpenAICompatibility struct {
// Name is the identifier for this OpenAI compatibility configuration.
Name string `yaml:"name" json:"name"`
// BaseURL is the base URL for the external OpenAI-compatible API endpoint.
BaseURL string `yaml:"base-url" json:"base-url"`
// APIKeys are the authentication keys for accessing the external API services.
// Deprecated: Use APIKeyEntries instead to support per-key proxy configuration.
APIKeys []string `yaml:"api-keys,omitempty" json:"api-keys,omitempty"`
// APIKeyEntries defines API keys with optional per-key proxy configuration.
APIKeyEntries []OpenAICompatibilityAPIKey `yaml:"api-key-entries,omitempty" json:"api-key-entries,omitempty"`
// Models defines the model configurations including aliases for routing.
Models []OpenAICompatibilityModel `yaml:"models" json:"models"`
}
// OpenAICompatibilityAPIKey represents an API key configuration with optional proxy setting.
type OpenAICompatibilityAPIKey struct {
// APIKey is the authentication key for accessing the external API services.
APIKey string `yaml:"api-key" json:"api-key"`
// ProxyURL overrides the global proxy setting for this API key if provided.
ProxyURL string `yaml:"proxy-url,omitempty" json:"proxy-url,omitempty"`
}
// OpenAICompatibilityModel represents a model configuration for OpenAI compatibility,
// including the actual model name and its alias for API routing.
type OpenAICompatibilityModel struct {
// Name is the actual model name used by the external provider.
Name string `yaml:"name" json:"name"`
// Alias is the model name alias that clients will use to reference this model.
Alias string `yaml:"alias" json:"alias"`
}
// LoadConfig reads a YAML configuration file from the given path,
// unmarshals it into a Config struct, and returns it.
// unmarshals it into a Config struct, applies environment variable overrides,
// and returns it.
//
// Parameters:
// - configFile: The path to the YAML configuration file
//
// Returns:
// - *Config: The loaded configuration
// - error: An error if the configuration could not be loaded
func LoadConfig(configFile string) (*Config, error) {
// Read the entire configuration file into memory.
data, err := os.ReadFile(configFile)
@@ -41,11 +185,342 @@ func LoadConfig(configFile string) (*Config, error) {
}
// Unmarshal the YAML data into the Config struct.
var config Config
if err = yaml.Unmarshal(data, &config); err != nil {
var cfg Config
// Set defaults before unmarshal so that absent keys keep defaults.
cfg.LoggingToFile = true
cfg.UsageStatisticsEnabled = true
cfg.GeminiWeb.Context = true
if err = yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("failed to parse config file: %w", err)
}
// Hash remote management key if plaintext is detected (nested)
// We consider a value to be already hashed if it looks like a bcrypt hash ($2a$, $2b$, or $2y$ prefix).
if cfg.RemoteManagement.SecretKey != "" && !looksLikeBcrypt(cfg.RemoteManagement.SecretKey) {
hashed, errHash := hashSecret(cfg.RemoteManagement.SecretKey)
if errHash != nil {
return nil, fmt.Errorf("failed to hash remote management key: %w", errHash)
}
cfg.RemoteManagement.SecretKey = hashed
// Persist the hashed value back to the config file to avoid re-hashing on next startup.
// Preserve YAML comments and ordering; update only the nested key.
_ = SaveConfigPreserveCommentsUpdateNestedScalar(configFile, []string{"remote-management", "secret-key"}, hashed)
}
// Sync request authentication providers with inline API keys for backwards compatibility.
syncInlineAccessProvider(&cfg)
// Return the populated configuration struct.
return &config, nil
return &cfg, nil
}
func syncInlineAccessProvider(cfg *Config) {
if cfg == nil {
return
}
if len(cfg.APIKeys) == 0 {
if provider := cfg.ConfigAPIKeyProvider(); provider != nil && len(provider.APIKeys) > 0 {
cfg.APIKeys = append([]string(nil), provider.APIKeys...)
}
}
cfg.Access.Providers = nil
}
// looksLikeBcrypt returns true if the provided string appears to be a bcrypt hash.
func looksLikeBcrypt(s string) bool {
return len(s) > 4 && (s[:4] == "$2a$" || s[:4] == "$2b$" || s[:4] == "$2y$")
}
// hashSecret hashes the given secret using bcrypt.
func hashSecret(secret string) (string, error) {
// Use default cost for simplicity.
hashedBytes, err := bcrypt.GenerateFromPassword([]byte(secret), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hashedBytes), nil
}
// SaveConfigPreserveComments writes the config back to YAML while preserving existing comments
// and key ordering by loading the original file into a yaml.Node tree and updating values in-place.
func SaveConfigPreserveComments(configFile string, cfg *Config) error {
persistCfg := sanitizeConfigForPersist(cfg)
// Load original YAML as a node tree to preserve comments and ordering.
data, err := os.ReadFile(configFile)
if err != nil {
return err
}
var original yaml.Node
if err = yaml.Unmarshal(data, &original); err != nil {
return err
}
if original.Kind != yaml.DocumentNode || len(original.Content) == 0 {
return fmt.Errorf("invalid yaml document structure")
}
if original.Content[0] == nil || original.Content[0].Kind != yaml.MappingNode {
return fmt.Errorf("expected root mapping node")
}
// Marshal the current cfg to YAML, then unmarshal to a yaml.Node we can merge from.
rendered, err := yaml.Marshal(persistCfg)
if err != nil {
return err
}
var generated yaml.Node
if err = yaml.Unmarshal(rendered, &generated); err != nil {
return err
}
if generated.Kind != yaml.DocumentNode || len(generated.Content) == 0 || generated.Content[0] == nil {
return fmt.Errorf("invalid generated yaml structure")
}
if generated.Content[0].Kind != yaml.MappingNode {
return fmt.Errorf("expected generated root mapping node")
}
// Remove deprecated auth block before merging to avoid persisting it again.
removeMapKey(original.Content[0], "auth")
// Merge generated into original in-place, preserving comments/order of existing nodes.
mergeMappingPreserve(original.Content[0], generated.Content[0])
// Write back.
f, err := os.Create(configFile)
if err != nil {
return err
}
defer func() { _ = f.Close() }()
enc := yaml.NewEncoder(f)
enc.SetIndent(2)
if err = enc.Encode(&original); err != nil {
_ = enc.Close()
return err
}
return enc.Close()
}
func sanitizeConfigForPersist(cfg *Config) *Config {
if cfg == nil {
return nil
}
clone := *cfg
clone.SDKConfig = cfg.SDKConfig
clone.SDKConfig.Access = config.AccessConfig{}
return &clone
}
// SaveConfigPreserveCommentsUpdateNestedScalar updates a nested scalar key path like ["a","b"]
// while preserving comments and positions.
func SaveConfigPreserveCommentsUpdateNestedScalar(configFile string, path []string, value string) error {
data, err := os.ReadFile(configFile)
if err != nil {
return err
}
var root yaml.Node
if err = yaml.Unmarshal(data, &root); err != nil {
return err
}
if root.Kind != yaml.DocumentNode || len(root.Content) == 0 {
return fmt.Errorf("invalid yaml document structure")
}
node := root.Content[0]
// descend mapping nodes following path
for i, key := range path {
if i == len(path)-1 {
// set final scalar
v := getOrCreateMapValue(node, key)
v.Kind = yaml.ScalarNode
v.Tag = "!!str"
v.Value = value
} else {
next := getOrCreateMapValue(node, key)
if next.Kind != yaml.MappingNode {
next.Kind = yaml.MappingNode
next.Tag = "!!map"
}
node = next
}
}
f, err := os.Create(configFile)
if err != nil {
return err
}
defer func() { _ = f.Close() }()
enc := yaml.NewEncoder(f)
enc.SetIndent(2)
if err = enc.Encode(&root); err != nil {
_ = enc.Close()
return err
}
return enc.Close()
}
// getOrCreateMapValue finds the value node for a given key in a mapping node.
// If not found, it appends a new key/value pair and returns the new value node.
func getOrCreateMapValue(mapNode *yaml.Node, key string) *yaml.Node {
if mapNode.Kind != yaml.MappingNode {
mapNode.Kind = yaml.MappingNode
mapNode.Tag = "!!map"
mapNode.Content = nil
}
for i := 0; i+1 < len(mapNode.Content); i += 2 {
k := mapNode.Content[i]
if k.Value == key {
return mapNode.Content[i+1]
}
}
// append new key/value
mapNode.Content = append(mapNode.Content, &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key})
val := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: ""}
mapNode.Content = append(mapNode.Content, val)
return val
}
// mergeMappingPreserve merges keys from src into dst mapping node while preserving
// key order and comments of existing keys in dst. Unknown keys from src are appended
// to dst at the end, copying their node structure from src.
func mergeMappingPreserve(dst, src *yaml.Node) {
if dst == nil || src == nil {
return
}
if dst.Kind != yaml.MappingNode || src.Kind != yaml.MappingNode {
// If kinds do not match, prefer replacing dst with src semantics in-place
// but keep dst node object to preserve any attached comments at the parent level.
copyNodeShallow(dst, src)
return
}
// Build a lookup of existing keys in dst
for i := 0; i+1 < len(src.Content); i += 2 {
sk := src.Content[i]
sv := src.Content[i+1]
idx := findMapKeyIndex(dst, sk.Value)
if idx >= 0 {
// Merge into existing value node
dv := dst.Content[idx+1]
mergeNodePreserve(dv, sv)
} else {
// Append new key/value pair by deep-copying from src
dst.Content = append(dst.Content, deepCopyNode(sk), deepCopyNode(sv))
}
}
}
// mergeNodePreserve merges src into dst for scalars, mappings and sequences while
// reusing destination nodes to keep comments and anchors. For sequences, it updates
// in-place by index.
func mergeNodePreserve(dst, src *yaml.Node) {
if dst == nil || src == nil {
return
}
switch src.Kind {
case yaml.MappingNode:
if dst.Kind != yaml.MappingNode {
copyNodeShallow(dst, src)
}
mergeMappingPreserve(dst, src)
case yaml.SequenceNode:
// Preserve explicit null style if dst was null and src is empty sequence
if dst.Kind == yaml.ScalarNode && dst.Tag == "!!null" && len(src.Content) == 0 {
// Keep as null to preserve original style
return
}
if dst.Kind != yaml.SequenceNode {
dst.Kind = yaml.SequenceNode
dst.Tag = "!!seq"
dst.Content = nil
}
// Update elements in place
minContent := len(dst.Content)
if len(src.Content) < minContent {
minContent = len(src.Content)
}
for i := 0; i < minContent; i++ {
if dst.Content[i] == nil {
dst.Content[i] = deepCopyNode(src.Content[i])
continue
}
mergeNodePreserve(dst.Content[i], src.Content[i])
}
// Append any extra items from src
for i := len(dst.Content); i < len(src.Content); i++ {
dst.Content = append(dst.Content, deepCopyNode(src.Content[i]))
}
// Truncate if dst has extra items not in src
if len(src.Content) < len(dst.Content) {
dst.Content = dst.Content[:len(src.Content)]
}
case yaml.ScalarNode, yaml.AliasNode:
// For scalars, update Tag and Value but keep Style from dst to preserve quoting
dst.Kind = src.Kind
dst.Tag = src.Tag
dst.Value = src.Value
// Keep dst.Style as-is intentionally
case 0:
// Unknown/empty kind; do nothing
default:
// Fallback: replace shallowly
copyNodeShallow(dst, src)
}
}
// findMapKeyIndex returns the index of key node in dst mapping (index of key, not value).
// Returns -1 when not found.
func findMapKeyIndex(mapNode *yaml.Node, key string) int {
if mapNode == nil || mapNode.Kind != yaml.MappingNode {
return -1
}
for i := 0; i+1 < len(mapNode.Content); i += 2 {
if mapNode.Content[i] != nil && mapNode.Content[i].Value == key {
return i
}
}
return -1
}
// deepCopyNode creates a deep copy of a yaml.Node graph.
func deepCopyNode(n *yaml.Node) *yaml.Node {
if n == nil {
return nil
}
cp := *n
if len(n.Content) > 0 {
cp.Content = make([]*yaml.Node, len(n.Content))
for i := range n.Content {
cp.Content[i] = deepCopyNode(n.Content[i])
}
}
return &cp
}
// copyNodeShallow copies type/tag/value and resets content to match src, but
// keeps the same destination node pointer to preserve parent relations/comments.
func copyNodeShallow(dst, src *yaml.Node) {
if dst == nil || src == nil {
return
}
dst.Kind = src.Kind
dst.Tag = src.Tag
dst.Value = src.Value
// Replace content with deep copy from src
if len(src.Content) > 0 {
dst.Content = make([]*yaml.Node, len(src.Content))
for i := range src.Content {
dst.Content[i] = deepCopyNode(src.Content[i])
}
} else {
dst.Content = nil
}
}
func removeMapKey(mapNode *yaml.Node, key string) {
if mapNode == nil || mapNode.Kind != yaml.MappingNode || key == "" {
return
}
for i := 0; i+1 < len(mapNode.Content); i += 2 {
if mapNode.Content[i] != nil && mapNode.Content[i].Value == key {
mapNode.Content = append(mapNode.Content[:i], mapNode.Content[i+2:]...)
return
}
}
}

View File

@@ -0,0 +1,27 @@
// Package constant defines provider name constants used throughout the CLI Proxy API.
// These constants identify different AI service providers and their variants,
// ensuring consistent naming across the application.
package constant
const (
// Gemini represents the Google Gemini provider identifier.
Gemini = "gemini"
// 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"
// Claude represents the Anthropic Claude provider identifier.
Claude = "claude"
// OpenAI represents the OpenAI provider identifier.
OpenAI = "openai"
// OpenaiResponse represents the OpenAI response format identifier.
OpenaiResponse = "openai-response"
)

View File

@@ -0,0 +1,17 @@
// Package interfaces defines the core interfaces and shared structures for the CLI Proxy API server.
// These interfaces provide a common contract for different components of the application,
// such as AI service clients, API handlers, and data models.
package interfaces
// APIHandler defines the interface that all API handlers must implement.
// This interface provides methods for identifying handler types and retrieving
// supported models for different AI service endpoints.
type APIHandler interface {
// HandlerType returns the type identifier for this API handler.
// This is used to determine which request/response translators to use.
HandlerType() string
// Models returns a list of supported models for this API handler.
// Each model is represented as a map containing model metadata.
Models() []map[string]any
}

View File

@@ -0,0 +1,150 @@
// Package interfaces defines the core interfaces and shared structures for the CLI Proxy API server.
// These interfaces provide a common contract for different components of the application,
// such as AI service clients, API handlers, and data models.
package interfaces
import (
"time"
)
// GCPProject represents the response structure for a Google Cloud project list request.
// This structure is used when fetching available projects for a Google Cloud account.
type GCPProject struct {
// Projects is a list of Google Cloud projects accessible by the user.
Projects []GCPProjectProjects `json:"projects"`
}
// GCPProjectLabels defines the labels associated with a GCP project.
// These labels can contain metadata about the project's purpose or configuration.
type GCPProjectLabels struct {
// GenerativeLanguage indicates if the project has generative language APIs enabled.
GenerativeLanguage string `json:"generative-language"`
}
// GCPProjectProjects contains details about a single Google Cloud project.
// This includes identifying information, metadata, and configuration details.
type GCPProjectProjects struct {
// ProjectNumber is the unique numeric identifier for the project.
ProjectNumber string `json:"projectNumber"`
// ProjectID is the unique string identifier for the project.
ProjectID string `json:"projectId"`
// LifecycleState indicates the current state of the project (e.g., "ACTIVE").
LifecycleState string `json:"lifecycleState"`
// Name is the human-readable name of the project.
Name string `json:"name"`
// Labels contains metadata labels associated with the project.
Labels GCPProjectLabels `json:"labels"`
// CreateTime is the timestamp when the project was created.
CreateTime time.Time `json:"createTime"`
}
// Content represents a single message in a conversation, with a role and parts.
// This structure models a message exchange between a user and an AI model.
type Content struct {
// Role indicates who sent the message ("user", "model", or "tool").
Role string `json:"role"`
// Parts is a collection of content parts that make up the message.
Parts []Part `json:"parts"`
}
// Part represents a distinct piece of content within a message.
// A part can be text, inline data (like an image), a function call, or a function response.
type Part struct {
// Text contains plain text content.
Text string `json:"text,omitempty"`
// InlineData contains base64-encoded data with its MIME type (e.g., images).
InlineData *InlineData `json:"inlineData,omitempty"`
// FunctionCall represents a tool call requested by the model.
FunctionCall *FunctionCall `json:"functionCall,omitempty"`
// FunctionResponse represents the result of a tool execution.
FunctionResponse *FunctionResponse `json:"functionResponse,omitempty"`
}
// InlineData represents base64-encoded data with its MIME type.
// This is typically used for embedding images or other binary data in requests.
type InlineData struct {
// MimeType specifies the media type of the embedded data (e.g., "image/png").
MimeType string `json:"mime_type,omitempty"`
// Data contains the base64-encoded binary data.
Data string `json:"data,omitempty"`
}
// FunctionCall represents a tool call requested by the model.
// It includes the function name and its arguments that the model wants to execute.
type FunctionCall struct {
// Name is the identifier of the function to be called.
Name string `json:"name"`
// Args contains the arguments to pass to the function.
Args map[string]interface{} `json:"args"`
}
// FunctionResponse represents the result of a tool execution.
// This is sent back to the model after a tool call has been processed.
type FunctionResponse struct {
// Name is the identifier of the function that was called.
Name string `json:"name"`
// Response contains the result data from the function execution.
Response map[string]interface{} `json:"response"`
}
// GenerateContentRequest is the top-level request structure for the streamGenerateContent endpoint.
// This structure defines all the parameters needed for generating content from an AI model.
type GenerateContentRequest struct {
// SystemInstruction provides system-level instructions that guide the model's behavior.
SystemInstruction *Content `json:"systemInstruction,omitempty"`
// Contents is the conversation history between the user and the model.
Contents []Content `json:"contents"`
// Tools defines the available tools/functions that the model can call.
Tools []ToolDeclaration `json:"tools,omitempty"`
// GenerationConfig contains parameters that control the model's generation behavior.
GenerationConfig `json:"generationConfig"`
}
// GenerationConfig defines parameters that control the model's generation behavior.
// These parameters affect the creativity, randomness, and reasoning of the model's responses.
type GenerationConfig struct {
// ThinkingConfig specifies configuration for the model's "thinking" process.
ThinkingConfig GenerationConfigThinkingConfig `json:"thinkingConfig,omitempty"`
// Temperature controls the randomness of the model's responses.
// Values closer to 0 make responses more deterministic, while values closer to 1 increase randomness.
Temperature float64 `json:"temperature,omitempty"`
// TopP controls nucleus sampling, which affects the diversity of responses.
// It limits the model to consider only the top P% of probability mass.
TopP float64 `json:"topP,omitempty"`
// TopK limits the model to consider only the top K most likely tokens.
// This can help control the quality and diversity of generated text.
TopK float64 `json:"topK,omitempty"`
}
// GenerationConfigThinkingConfig specifies configuration for the model's "thinking" process.
// This controls whether the model should output its reasoning process along with the final answer.
type GenerationConfigThinkingConfig struct {
// IncludeThoughts determines whether the model should output its reasoning process.
// When enabled, the model will include its step-by-step thinking in the response.
IncludeThoughts bool `json:"include_thoughts,omitempty"`
}
// ToolDeclaration defines the structure for declaring tools (like functions)
// that the model can call during content generation.
type ToolDeclaration struct {
// FunctionDeclarations is a list of available functions that the model can call.
FunctionDeclarations []interface{} `json:"functionDeclarations"`
}

View File

@@ -0,0 +1,20 @@
// Package interfaces defines the core interfaces and shared structures for the CLI Proxy API server.
// These interfaces provide a common contract for different components of the application,
// such as AI service clients, API handlers, and data models.
package interfaces
import "net/http"
// ErrorMessage encapsulates an error with an associated HTTP status code.
// This structure is used to provide detailed error information including
// both the HTTP status and the underlying error.
type ErrorMessage struct {
// StatusCode is the HTTP status code returned by the API.
StatusCode int
// Error is the underlying error that occurred.
Error error
// Addon contains additional headers to be added to the response.
Addon http.Header
}

View File

@@ -0,0 +1,15 @@
// Package interfaces provides type aliases for backwards compatibility with translator functions.
// It defines common interface types used throughout the CLI Proxy API for request and response
// transformation operations, maintaining compatibility with the SDK translator package.
package interfaces
import sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
// Backwards compatible aliases for translator function types.
type TranslateRequestFunc = sdktranslator.RequestTransform
type TranslateResponseFunc = sdktranslator.ResponseStreamTransform
type TranslateResponseNonStreamFunc = sdktranslator.ResponseNonStreamTransform
type TranslateResponse = sdktranslator.ResponseTransform

View File

@@ -0,0 +1,78 @@
// Package logging provides Gin middleware for HTTP request logging and panic recovery.
// It integrates Gin web framework with logrus for structured logging of HTTP requests,
// responses, and error handling with panic recovery capabilities.
package logging
import (
"fmt"
"net/http"
"runtime/debug"
"time"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
)
// GinLogrusLogger returns a Gin middleware handler that logs HTTP requests and responses
// using logrus. It captures request details including method, path, status code, latency,
// client IP, and any error messages, formatting them in a Gin-style log format.
//
// Returns:
// - gin.HandlerFunc: A middleware handler for request logging
func GinLogrusLogger() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
path := c.Request.URL.Path
raw := c.Request.URL.RawQuery
c.Next()
if raw != "" {
path = path + "?" + raw
}
latency := time.Since(start)
if latency > time.Minute {
latency = latency.Truncate(time.Second)
} else {
latency = latency.Truncate(time.Millisecond)
}
statusCode := c.Writer.Status()
clientIP := c.ClientIP()
method := c.Request.Method
errorMessage := c.Errors.ByType(gin.ErrorTypePrivate).String()
timestamp := time.Now().Format("2006/01/02 - 15:04:05")
logLine := fmt.Sprintf("[GIN] %s | %3d | %13v | %15s | %-7s \"%s\"", timestamp, statusCode, latency, clientIP, method, path)
if errorMessage != "" {
logLine = logLine + " | " + errorMessage
}
switch {
case statusCode >= http.StatusInternalServerError:
log.Error(logLine)
case statusCode >= http.StatusBadRequest:
log.Warn(logLine)
default:
log.Info(logLine)
}
}
}
// GinLogrusRecovery returns a Gin middleware handler that recovers from panics and logs
// them using logrus. When a panic occurs, it captures the panic value, stack trace,
// and request path, then returns a 500 Internal Server Error response to the client.
//
// Returns:
// - gin.HandlerFunc: A middleware handler for panic recovery
func GinLogrusRecovery() gin.HandlerFunc {
return gin.CustomRecovery(func(c *gin.Context, recovered interface{}) {
log.WithFields(log.Fields{
"panic": recovered,
"stack": string(debug.Stack()),
"path": c.Request.URL.Path,
}).Error("recovered from panic")
c.AbortWithStatus(http.StatusInternalServerError)
})
}

View File

@@ -0,0 +1,117 @@
package logging
import (
"bytes"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"github.com/gin-gonic/gin"
log "github.com/sirupsen/logrus"
"gopkg.in/natefinch/lumberjack.v2"
)
var (
setupOnce sync.Once
writerMu sync.Mutex
logWriter *lumberjack.Logger
ginInfoWriter *io.PipeWriter
ginErrorWriter *io.PipeWriter
)
// LogFormatter defines a custom log format for logrus.
// This formatter adds timestamp, level, and source location to each log entry.
type LogFormatter struct{}
// Format renders a single log entry with custom formatting.
func (m *LogFormatter) Format(entry *log.Entry) ([]byte, error) {
var buffer *bytes.Buffer
if entry.Buffer != nil {
buffer = entry.Buffer
} else {
buffer = &bytes.Buffer{}
}
timestamp := entry.Time.Format("2006-01-02 15:04:05")
message := strings.TrimRight(entry.Message, "\r\n")
formatted := fmt.Sprintf("[%s] [%s] [%s:%d] %s\n", timestamp, entry.Level, filepath.Base(entry.Caller.File), entry.Caller.Line, message)
buffer.WriteString(formatted)
return buffer.Bytes(), nil
}
// SetupBaseLogger configures the shared logrus instance and Gin writers.
// It is safe to call multiple times; initialization happens only once.
func SetupBaseLogger() {
setupOnce.Do(func() {
log.SetOutput(os.Stdout)
log.SetReportCaller(true)
log.SetFormatter(&LogFormatter{})
ginInfoWriter = log.StandardLogger().Writer()
gin.DefaultWriter = ginInfoWriter
ginErrorWriter = log.StandardLogger().WriterLevel(log.ErrorLevel)
gin.DefaultErrorWriter = ginErrorWriter
gin.DebugPrintFunc = func(format string, values ...interface{}) {
format = strings.TrimRight(format, "\r\n")
log.StandardLogger().Infof(format, values...)
}
log.RegisterExitHandler(closeLogOutputs)
})
}
// ConfigureLogOutput switches the global log destination between rotating files and stdout.
func ConfigureLogOutput(loggingToFile bool) error {
SetupBaseLogger()
writerMu.Lock()
defer writerMu.Unlock()
if loggingToFile {
const logDir = "logs"
if err := os.MkdirAll(logDir, 0o755); err != nil {
return fmt.Errorf("logging: failed to create log directory: %w", err)
}
if logWriter != nil {
_ = logWriter.Close()
}
logWriter = &lumberjack.Logger{
Filename: filepath.Join(logDir, "main.log"),
MaxSize: 10,
MaxBackups: 0,
MaxAge: 0,
Compress: false,
}
log.SetOutput(logWriter)
return nil
}
if logWriter != nil {
_ = logWriter.Close()
logWriter = nil
}
log.SetOutput(os.Stdout)
return nil
}
func closeLogOutputs() {
writerMu.Lock()
defer writerMu.Unlock()
if logWriter != nil {
_ = logWriter.Close()
logWriter = nil
}
if ginInfoWriter != nil {
_ = ginInfoWriter.Close()
ginInfoWriter = nil
}
if ginErrorWriter != nil {
_ = ginErrorWriter.Close()
ginErrorWriter = nil
}
}

View File

@@ -0,0 +1,612 @@
// Package logging provides request logging functionality for the CLI Proxy API server.
// It handles capturing and storing detailed HTTP request and response data when enabled
// through configuration, supporting both regular and streaming responses.
package logging
import (
"bytes"
"compress/flate"
"compress/gzip"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
)
// RequestLogger defines the interface for logging HTTP requests and responses.
// It provides methods for logging both regular and streaming HTTP request/response cycles.
type RequestLogger interface {
// LogRequest logs a complete non-streaming request/response cycle.
//
// Parameters:
// - url: The request URL
// - method: The HTTP method
// - requestHeaders: The request headers
// - body: The request body
// - statusCode: The response status code
// - responseHeaders: The response headers
// - response: The raw response data
// - apiRequest: The API request data
// - apiResponse: The API response data
//
// Returns:
// - error: An error if logging fails, nil otherwise
LogRequest(url, method string, requestHeaders map[string][]string, body []byte, statusCode int, responseHeaders map[string][]string, response, apiRequest, apiResponse []byte, apiResponseErrors []*interfaces.ErrorMessage) error
// LogStreamingRequest initiates logging for a streaming request and returns a writer for chunks.
//
// Parameters:
// - url: The request URL
// - method: The HTTP method
// - headers: The request headers
// - body: The request body
//
// Returns:
// - StreamingLogWriter: A writer for streaming response chunks
// - error: An error if logging initialization fails, nil otherwise
LogStreamingRequest(url, method string, headers map[string][]string, body []byte) (StreamingLogWriter, error)
// IsEnabled returns whether request logging is currently enabled.
//
// Returns:
// - bool: True if logging is enabled, false otherwise
IsEnabled() bool
}
// StreamingLogWriter handles real-time logging of streaming response chunks.
// It provides methods for writing streaming response data asynchronously.
type StreamingLogWriter interface {
// WriteChunkAsync writes a response chunk asynchronously (non-blocking).
//
// Parameters:
// - chunk: The response chunk to write
WriteChunkAsync(chunk []byte)
// WriteStatus writes the response status and headers to the log.
//
// Parameters:
// - status: The response status code
// - headers: The response headers
//
// Returns:
// - error: An error if writing fails, nil otherwise
WriteStatus(status int, headers map[string][]string) error
// Close finalizes the log file and cleans up resources.
//
// Returns:
// - error: An error if closing fails, nil otherwise
Close() error
}
// FileRequestLogger implements RequestLogger using file-based storage.
// It provides file-based logging functionality for HTTP requests and responses.
type FileRequestLogger struct {
// enabled indicates whether request logging is currently enabled.
enabled bool
// logsDir is the directory where log files are stored.
logsDir string
}
// NewFileRequestLogger creates a new file-based request logger.
//
// Parameters:
// - enabled: Whether request logging should be enabled
// - logsDir: The directory where log files should be stored (can be relative)
// - configDir: The directory of the configuration file; when logsDir is
// relative, it will be resolved relative to this directory
//
// Returns:
// - *FileRequestLogger: A new file-based request logger instance
func NewFileRequestLogger(enabled bool, logsDir string, configDir string) *FileRequestLogger {
// Resolve logsDir relative to the configuration file directory when it's not absolute.
if !filepath.IsAbs(logsDir) {
// If configDir is provided, resolve logsDir relative to it.
if configDir != "" {
logsDir = filepath.Join(configDir, logsDir)
}
}
return &FileRequestLogger{
enabled: enabled,
logsDir: logsDir,
}
}
// IsEnabled returns whether request logging is currently enabled.
//
// Returns:
// - bool: True if logging is enabled, false otherwise
func (l *FileRequestLogger) IsEnabled() bool {
return l.enabled
}
// SetEnabled updates the request logging enabled state.
// This method allows dynamic enabling/disabling of request logging.
//
// Parameters:
// - enabled: Whether request logging should be enabled
func (l *FileRequestLogger) SetEnabled(enabled bool) {
l.enabled = enabled
}
// LogRequest logs a complete non-streaming request/response cycle to a file.
//
// Parameters:
// - url: The request URL
// - method: The HTTP method
// - requestHeaders: The request headers
// - body: The request body
// - statusCode: The response status code
// - responseHeaders: The response headers
// - response: The raw response data
// - apiRequest: The API request data
// - apiResponse: The API response data
//
// Returns:
// - error: An error if logging fails, nil otherwise
func (l *FileRequestLogger) LogRequest(url, method string, requestHeaders map[string][]string, body []byte, statusCode int, responseHeaders map[string][]string, response, apiRequest, apiResponse []byte, apiResponseErrors []*interfaces.ErrorMessage) error {
if !l.enabled {
return nil
}
// Ensure logs directory exists
if err := l.ensureLogsDir(); err != nil {
return fmt.Errorf("failed to create logs directory: %w", err)
}
// Generate filename
filename := l.generateFilename(url)
filePath := filepath.Join(l.logsDir, filename)
// Decompress response if needed
decompressedResponse, err := l.decompressResponse(responseHeaders, response)
if err != nil {
// If decompression fails, log the error but continue with original response
decompressedResponse = append(response, []byte(fmt.Sprintf("\n[DECOMPRESSION ERROR: %v]", err))...)
}
// Create log content
content := l.formatLogContent(url, method, requestHeaders, body, apiRequest, apiResponse, decompressedResponse, statusCode, responseHeaders, apiResponseErrors)
// Write to file
if err = os.WriteFile(filePath, []byte(content), 0644); err != nil {
return fmt.Errorf("failed to write log file: %w", err)
}
return nil
}
// LogStreamingRequest initiates logging for a streaming request.
//
// Parameters:
// - url: The request URL
// - method: The HTTP method
// - headers: The request headers
// - body: The request body
//
// Returns:
// - StreamingLogWriter: A writer for streaming response chunks
// - error: An error if logging initialization fails, nil otherwise
func (l *FileRequestLogger) LogStreamingRequest(url, method string, headers map[string][]string, body []byte) (StreamingLogWriter, error) {
if !l.enabled {
return &NoOpStreamingLogWriter{}, nil
}
// Ensure logs directory exists
if err := l.ensureLogsDir(); err != nil {
return nil, fmt.Errorf("failed to create logs directory: %w", err)
}
// Generate filename
filename := l.generateFilename(url)
filePath := filepath.Join(l.logsDir, filename)
// Create and open file
file, err := os.Create(filePath)
if err != nil {
return nil, fmt.Errorf("failed to create log file: %w", err)
}
// Write initial request information
requestInfo := l.formatRequestInfo(url, method, headers, body)
if _, err = file.WriteString(requestInfo); err != nil {
_ = file.Close()
return nil, fmt.Errorf("failed to write request info: %w", err)
}
// Create streaming writer
writer := &FileStreamingLogWriter{
file: file,
chunkChan: make(chan []byte, 100), // Buffered channel for async writes
closeChan: make(chan struct{}),
errorChan: make(chan error, 1),
}
// Start async writer goroutine
go writer.asyncWriter()
return writer, nil
}
// ensureLogsDir creates the logs directory if it doesn't exist.
//
// Returns:
// - error: An error if directory creation fails, nil otherwise
func (l *FileRequestLogger) ensureLogsDir() error {
if _, err := os.Stat(l.logsDir); os.IsNotExist(err) {
return os.MkdirAll(l.logsDir, 0755)
}
return nil
}
// generateFilename creates a sanitized filename from the URL path and current timestamp.
//
// Parameters:
// - url: The request URL
//
// Returns:
// - string: A sanitized filename for the log file
func (l *FileRequestLogger) generateFilename(url string) string {
// Extract path from URL
path := url
if strings.Contains(url, "?") {
path = strings.Split(url, "?")[0]
}
// Remove leading slash
if strings.HasPrefix(path, "/") {
path = path[1:]
}
// Sanitize path for filename
sanitized := l.sanitizeForFilename(path)
// Add timestamp
timestamp := time.Now().Format("2006-01-02T150405-.000000000")
timestamp = strings.Replace(timestamp, ".", "", -1)
return fmt.Sprintf("%s-%s.log", sanitized, timestamp)
}
// sanitizeForFilename replaces characters that are not safe for filenames.
//
// Parameters:
// - path: The path to sanitize
//
// Returns:
// - string: A sanitized filename
func (l *FileRequestLogger) sanitizeForFilename(path string) string {
// Replace slashes with hyphens
sanitized := strings.ReplaceAll(path, "/", "-")
// Replace colons with hyphens
sanitized = strings.ReplaceAll(sanitized, ":", "-")
// Replace other problematic characters with hyphens
reg := regexp.MustCompile(`[<>:"|?*\s]`)
sanitized = reg.ReplaceAllString(sanitized, "-")
// Remove multiple consecutive hyphens
reg = regexp.MustCompile(`-+`)
sanitized = reg.ReplaceAllString(sanitized, "-")
// Remove leading/trailing hyphens
sanitized = strings.Trim(sanitized, "-")
// Handle empty result
if sanitized == "" {
sanitized = "root"
}
return sanitized
}
// formatLogContent creates the complete log content for non-streaming requests.
//
// Parameters:
// - url: The request URL
// - method: The HTTP method
// - headers: The request headers
// - body: The request body
// - apiRequest: The API request data
// - apiResponse: The API response data
// - response: The raw response data
// - status: The response status code
// - responseHeaders: The response headers
//
// Returns:
// - string: The formatted log content
func (l *FileRequestLogger) formatLogContent(url, method string, headers map[string][]string, body, apiRequest, apiResponse, response []byte, status int, responseHeaders map[string][]string, apiResponseErrors []*interfaces.ErrorMessage) string {
var content strings.Builder
// Request info
content.WriteString(l.formatRequestInfo(url, method, headers, body))
content.WriteString("=== API REQUEST ===\n")
content.Write(apiRequest)
content.WriteString("\n\n")
for i := 0; i < len(apiResponseErrors); i++ {
content.WriteString("=== API ERROR RESPONSE ===\n")
content.WriteString(fmt.Sprintf("HTTP Status: %d\n", apiResponseErrors[i].StatusCode))
content.WriteString(apiResponseErrors[i].Error.Error())
content.WriteString("\n\n")
}
content.WriteString("=== API RESPONSE ===\n")
content.Write(apiResponse)
content.WriteString("\n\n")
// Response section
content.WriteString("=== RESPONSE ===\n")
content.WriteString(fmt.Sprintf("Status: %d\n", status))
if responseHeaders != nil {
for key, values := range responseHeaders {
for _, value := range values {
content.WriteString(fmt.Sprintf("%s: %s\n", key, value))
}
}
}
content.WriteString("\n")
content.Write(response)
content.WriteString("\n")
return content.String()
}
// decompressResponse decompresses response data based on Content-Encoding header.
//
// Parameters:
// - responseHeaders: The response headers
// - response: The response data to decompress
//
// Returns:
// - []byte: The decompressed response data
// - error: An error if decompression fails, nil otherwise
func (l *FileRequestLogger) decompressResponse(responseHeaders map[string][]string, response []byte) ([]byte, error) {
if responseHeaders == nil || len(response) == 0 {
return response, nil
}
// Check Content-Encoding header
var contentEncoding string
for key, values := range responseHeaders {
if strings.ToLower(key) == "content-encoding" && len(values) > 0 {
contentEncoding = strings.ToLower(values[0])
break
}
}
switch contentEncoding {
case "gzip":
return l.decompressGzip(response)
case "deflate":
return l.decompressDeflate(response)
default:
// No compression or unsupported compression
return response, nil
}
}
// decompressGzip decompresses gzip-encoded data.
//
// Parameters:
// - data: The gzip-encoded data to decompress
//
// Returns:
// - []byte: The decompressed data
// - error: An error if decompression fails, nil otherwise
func (l *FileRequestLogger) decompressGzip(data []byte) ([]byte, error) {
reader, err := gzip.NewReader(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("failed to create gzip reader: %w", err)
}
defer func() {
_ = reader.Close()
}()
decompressed, err := io.ReadAll(reader)
if err != nil {
return nil, fmt.Errorf("failed to decompress gzip data: %w", err)
}
return decompressed, nil
}
// decompressDeflate decompresses deflate-encoded data.
//
// Parameters:
// - data: The deflate-encoded data to decompress
//
// Returns:
// - []byte: The decompressed data
// - error: An error if decompression fails, nil otherwise
func (l *FileRequestLogger) decompressDeflate(data []byte) ([]byte, error) {
reader := flate.NewReader(bytes.NewReader(data))
defer func() {
_ = reader.Close()
}()
decompressed, err := io.ReadAll(reader)
if err != nil {
return nil, fmt.Errorf("failed to decompress deflate data: %w", err)
}
return decompressed, nil
}
// formatRequestInfo creates the request information section of the log.
//
// Parameters:
// - url: The request URL
// - method: The HTTP method
// - headers: The request headers
// - body: The request body
//
// Returns:
// - string: The formatted request information
func (l *FileRequestLogger) formatRequestInfo(url, method string, headers map[string][]string, body []byte) string {
var content strings.Builder
content.WriteString("=== REQUEST INFO ===\n")
content.WriteString(fmt.Sprintf("URL: %s\n", url))
content.WriteString(fmt.Sprintf("Method: %s\n", method))
content.WriteString(fmt.Sprintf("Timestamp: %s\n", time.Now().Format(time.RFC3339Nano)))
content.WriteString("\n")
content.WriteString("=== HEADERS ===\n")
for key, values := range headers {
for _, value := range values {
content.WriteString(fmt.Sprintf("%s: %s\n", key, value))
}
}
content.WriteString("\n")
content.WriteString("=== REQUEST BODY ===\n")
content.Write(body)
content.WriteString("\n\n")
return content.String()
}
// FileStreamingLogWriter implements StreamingLogWriter for file-based streaming logs.
// It handles asynchronous writing of streaming response chunks to a file.
type FileStreamingLogWriter struct {
// file is the file where log data is written.
file *os.File
// chunkChan is a channel for receiving response chunks to write.
chunkChan chan []byte
// closeChan is a channel for signaling when the writer is closed.
closeChan chan struct{}
// errorChan is a channel for reporting errors during writing.
errorChan chan error
// statusWritten indicates whether the response status has been written.
statusWritten bool
}
// WriteChunkAsync writes a response chunk asynchronously (non-blocking).
//
// Parameters:
// - chunk: The response chunk to write
func (w *FileStreamingLogWriter) WriteChunkAsync(chunk []byte) {
if w.chunkChan == nil {
return
}
// Make a copy of the chunk to avoid data races
chunkCopy := make([]byte, len(chunk))
copy(chunkCopy, chunk)
// Non-blocking send
select {
case w.chunkChan <- chunkCopy:
default:
// Channel is full, skip this chunk to avoid blocking
}
}
// WriteStatus writes the response status and headers to the log.
//
// Parameters:
// - status: The response status code
// - headers: The response headers
//
// Returns:
// - error: An error if writing fails, nil otherwise
func (w *FileStreamingLogWriter) WriteStatus(status int, headers map[string][]string) error {
if w.file == nil || w.statusWritten {
return nil
}
var content strings.Builder
content.WriteString("========================================\n")
content.WriteString("=== RESPONSE ===\n")
content.WriteString(fmt.Sprintf("Status: %d\n", status))
for key, values := range headers {
for _, value := range values {
content.WriteString(fmt.Sprintf("%s: %s\n", key, value))
}
}
content.WriteString("\n")
_, err := w.file.WriteString(content.String())
if err == nil {
w.statusWritten = true
}
return err
}
// Close finalizes the log file and cleans up resources.
//
// Returns:
// - error: An error if closing fails, nil otherwise
func (w *FileStreamingLogWriter) Close() error {
if w.chunkChan != nil {
close(w.chunkChan)
}
// Wait for async writer to finish
if w.closeChan != nil {
<-w.closeChan
w.chunkChan = nil
}
if w.file != nil {
return w.file.Close()
}
return nil
}
// asyncWriter runs in a goroutine to handle async chunk writing.
// It continuously reads chunks from the channel and writes them to the file.
func (w *FileStreamingLogWriter) asyncWriter() {
defer close(w.closeChan)
for chunk := range w.chunkChan {
if w.file != nil {
_, _ = w.file.Write(chunk)
}
}
}
// NoOpStreamingLogWriter is a no-operation implementation for when logging is disabled.
// It implements the StreamingLogWriter interface but performs no actual logging operations.
type NoOpStreamingLogWriter struct{}
// WriteChunkAsync is a no-op implementation that does nothing.
//
// Parameters:
// - chunk: The response chunk (ignored)
func (w *NoOpStreamingLogWriter) WriteChunkAsync(_ []byte) {}
// WriteStatus is a no-op implementation that does nothing and always returns nil.
//
// Parameters:
// - status: The response status code (ignored)
// - headers: The response headers (ignored)
//
// Returns:
// - error: Always returns nil
func (w *NoOpStreamingLogWriter) WriteStatus(_ int, _ map[string][]string) error {
return nil
}
// Close is a no-op implementation that does nothing and always returns nil.
//
// Returns:
// - error: Always returns nil
func (w *NoOpStreamingLogWriter) Close() error { return nil }

View File

@@ -0,0 +1,256 @@
package managementasset
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
sdkconfig "github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
log "github.com/sirupsen/logrus"
)
const (
managementReleaseURL = "https://api.github.com/repos/router-for-me/Cli-Proxy-API-Management-Center/releases/latest"
managementAssetName = "management.html"
httpUserAgent = "CLIProxyAPI-management-updater"
)
// ManagementFileName exposes the control panel asset filename.
const ManagementFileName = managementAssetName
func newHTTPClient(proxyURL string) *http.Client {
client := &http.Client{Timeout: 15 * time.Second}
sdkCfg := &sdkconfig.SDKConfig{ProxyURL: strings.TrimSpace(proxyURL)}
util.SetProxy(sdkCfg, client)
return client
}
type releaseAsset struct {
Name string `json:"name"`
BrowserDownloadURL string `json:"browser_download_url"`
Digest string `json:"digest"`
}
type releaseResponse struct {
Assets []releaseAsset `json:"assets"`
}
// StaticDir resolves the directory that stores the management control panel asset.
func StaticDir(configFilePath string) string {
configFilePath = strings.TrimSpace(configFilePath)
if configFilePath == "" {
return ""
}
base := filepath.Dir(configFilePath)
return filepath.Join(base, "static")
}
// FilePath resolves the absolute path to the management control panel asset.
func FilePath(configFilePath string) string {
dir := StaticDir(configFilePath)
if dir == "" {
return ""
}
return filepath.Join(dir, ManagementFileName)
}
// 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.
func EnsureLatestManagementHTML(ctx context.Context, staticDir string, proxyURL string) {
if ctx == nil {
ctx = context.Background()
}
staticDir = strings.TrimSpace(staticDir)
if staticDir == "" {
log.Debug("management asset sync skipped: empty static directory")
return
}
if err := os.MkdirAll(staticDir, 0o755); err != nil {
log.WithError(err).Warn("failed to prepare static directory for management asset")
return
}
client := newHTTPClient(proxyURL)
localPath := filepath.Join(staticDir, managementAssetName)
localHash, err := fileSHA256(localPath)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
log.WithError(err).Debug("failed to read local management asset hash")
}
localHash = ""
}
asset, remoteHash, err := fetchLatestAsset(ctx, client)
if err != nil {
log.WithError(err).Warn("failed to fetch latest management release information")
return
}
if remoteHash != "" && localHash != "" && strings.EqualFold(remoteHash, localHash) {
log.Debug("management asset is already up to date")
return
}
data, downloadedHash, err := downloadAsset(ctx, client, asset.BrowserDownloadURL)
if err != nil {
log.WithError(err).Warn("failed to download management asset")
return
}
if remoteHash != "" && !strings.EqualFold(remoteHash, downloadedHash) {
log.Warnf("remote digest mismatch for management asset: expected %s got %s", remoteHash, downloadedHash)
}
if err = atomicWriteFile(localPath, data); err != nil {
log.WithError(err).Warn("failed to update management asset on disk")
return
}
log.Infof("management asset updated successfully (hash=%s)", downloadedHash)
}
func fetchLatestAsset(ctx context.Context, client *http.Client) (*releaseAsset, string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, managementReleaseURL, nil)
if err != nil {
return nil, "", fmt.Errorf("create release request: %w", err)
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", httpUserAgent)
resp, err := client.Do(req)
if err != nil {
return nil, "", fmt.Errorf("execute release request: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return nil, "", fmt.Errorf("unexpected release status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var release releaseResponse
if err = json.NewDecoder(resp.Body).Decode(&release); err != nil {
return nil, "", fmt.Errorf("decode release response: %w", err)
}
for i := range release.Assets {
asset := &release.Assets[i]
if strings.EqualFold(asset.Name, managementAssetName) {
remoteHash := parseDigest(asset.Digest)
return asset, remoteHash, nil
}
}
return nil, "", fmt.Errorf("management asset %s not found in latest release", managementAssetName)
}
func downloadAsset(ctx context.Context, client *http.Client, downloadURL string) ([]byte, string, error) {
if strings.TrimSpace(downloadURL) == "" {
return nil, "", fmt.Errorf("empty download url")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil)
if err != nil {
return nil, "", fmt.Errorf("create download request: %w", err)
}
req.Header.Set("User-Agent", httpUserAgent)
resp, err := client.Do(req)
if err != nil {
return nil, "", fmt.Errorf("execute download request: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return nil, "", fmt.Errorf("unexpected download status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, "", fmt.Errorf("read download body: %w", err)
}
sum := sha256.Sum256(data)
return data, hex.EncodeToString(sum[:]), nil
}
func fileSHA256(path string) (string, error) {
file, err := os.Open(path)
if err != nil {
return "", err
}
defer func() {
_ = file.Close()
}()
h := sha256.New()
if _, err = io.Copy(h, file); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
func atomicWriteFile(path string, data []byte) error {
tmpFile, err := os.CreateTemp(filepath.Dir(path), "management-*.html")
if err != nil {
return err
}
tmpName := tmpFile.Name()
defer func() {
_ = tmpFile.Close()
_ = os.Remove(tmpName)
}()
if _, err = tmpFile.Write(data); err != nil {
return err
}
if err = tmpFile.Chmod(0o644); err != nil {
return err
}
if err = tmpFile.Close(); err != nil {
return err
}
if err = os.Rename(tmpName, path); err != nil {
return err
}
return nil
}
func parseDigest(digest string) string {
digest = strings.TrimSpace(digest)
if digest == "" {
return ""
}
if idx := strings.Index(digest, ":"); idx >= 0 {
digest = digest[idx+1:]
}
return strings.ToLower(strings.TrimSpace(digest))
}

View File

@@ -0,0 +1,13 @@
// Package misc provides miscellaneous utility functions and embedded data for the CLI Proxy API.
// This package contains general-purpose helpers and embedded resources that do not fit into
// more specific domain packages. It includes embedded instructional text for Claude Code-related operations.
package misc
import _ "embed"
// ClaudeCodeInstructions holds the content of the claude_code_instructions.txt file,
// which is embedded into the application binary at compile time. This variable
// contains specific instructions for Claude Code model interactions and code generation guidance.
//
//go:embed claude_code_instructions.txt
var ClaudeCodeInstructions string

View File

@@ -0,0 +1 @@
[{"type":"text","text":"You are Claude Code, Anthropic's official CLI for Claude.","cache_control":{"type":"ephemeral"}}]

View File

@@ -0,0 +1,23 @@
// Package misc provides miscellaneous utility functions and embedded data for the CLI Proxy API.
// This package contains general-purpose helpers and embedded resources that do not fit into
// more specific domain packages. It includes embedded instructional text for Codex-related operations.
package misc
import _ "embed"
// CodexInstructions holds the content of the codex_instructions.txt file,
// which is embedded into the application binary at compile time. This variable
// contains instructional text used for Codex-related operations and model guidance.
//
//go:embed gpt_5_instructions.txt
var GPT5Instructions string
//go:embed gpt_5_codex_instructions.txt
var GPT5CodexInstructions string
func CodexInstructions(modelName string) string {
if modelName == "gpt-5-codex" {
return GPT5CodexInstructions
}
return GPT5Instructions
}

View File

@@ -0,0 +1,26 @@
package misc
import (
"fmt"
"path/filepath"
"strings"
log "github.com/sirupsen/logrus"
)
// Separator used to visually group related log lines.
var credentialSeparator = strings.Repeat("-", 67)
// LogSavingCredentials emits a consistent log message when persisting auth material.
func LogSavingCredentials(path string) {
if path == "" {
return
}
// Use filepath.Clean so logs remain stable even if callers pass redundant separators.
fmt.Printf("Saving credentials to %s\n", filepath.Clean(path))
}
// LogCredentialSeparator adds a visual separator to group auth/key processing logs.
func LogCredentialSeparator() {
log.Debug(credentialSeparator)
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,37 @@
// Package misc provides miscellaneous utility functions for the CLI Proxy API server.
// It includes helper functions for HTTP header manipulation and other common operations
// that don't fit into more specific packages.
package misc
import (
"net/http"
"strings"
)
// EnsureHeader ensures that a header exists in the target header map by checking
// multiple sources in order of priority: source headers, existing target headers,
// and finally the default value. It only sets the header if it's not already present
// and the value is not empty after trimming whitespace.
//
// Parameters:
// - target: The target header map to modify
// - source: The source header map to check first (can be nil)
// - key: The header key to ensure
// - defaultValue: The default value to use if no other source provides a value
func EnsureHeader(target http.Header, source http.Header, key, defaultValue string) {
if target == nil {
return
}
if source != nil {
if val := strings.TrimSpace(source.Get(key)); val != "" {
target.Set(key, val)
return
}
}
if strings.TrimSpace(target.Get(key)) != "" {
return
}
if val := strings.TrimSpace(defaultValue); val != "" {
target.Set(key, val)
}
}

View File

@@ -1,7 +1,12 @@
package translator
// Package misc provides miscellaneous utility functions and embedded data for the CLI Proxy API.
// This package contains general-purpose helpers and embedded resources that do not fit into
// more specific domain packages. It includes a comprehensive MIME type mapping for file operations.
package misc
// MimeTypes is a comprehensive map of file extensions to their corresponding MIME types.
// This is used to identify the type of file being uploaded or processed.
// This map is used to determine the Content-Type header for file uploads and other
// operations where the MIME type needs to be identified from a file extension.
// The list is extensive to cover a wide range of common and uncommon file formats.
var MimeTypes = map[string]string{
"ez": "application/andrew-inset",
"aw": "application/applixware",

21
internal/misc/oauth.go Normal file
View File

@@ -0,0 +1,21 @@
package misc
import (
"crypto/rand"
"encoding/hex"
"fmt"
)
// GenerateRandomState generates a cryptographically secure random state parameter
// for OAuth2 flows to prevent CSRF attacks.
//
// Returns:
// - string: A hexadecimal encoded random state string
// - error: An error if the random generation fails, nil otherwise
func GenerateRandomState() (string, error) {
bytes := make([]byte, 16)
if _, err := rand.Read(bytes); err != nil {
return "", fmt.Errorf("failed to generate random bytes: %w", err)
}
return hex.EncodeToString(bytes), nil
}

View File

@@ -0,0 +1,882 @@
package geminiwebapi
import (
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"regexp"
"strings"
"time"
log "github.com/sirupsen/logrus"
)
// GeminiClient is the async http client interface (Go port)
type GeminiClient struct {
Cookies map[string]string
Proxy string
Running bool
httpClient *http.Client
AccessToken string
Timeout time.Duration
insecure bool
}
// HTTP bootstrap utilities -------------------------------------------------
type httpOptions struct {
ProxyURL string
Insecure bool
FollowRedirects bool
}
func newHTTPClient(opts httpOptions) *http.Client {
transport := &http.Transport{}
if opts.ProxyURL != "" {
if pu, err := url.Parse(opts.ProxyURL); err == nil {
transport.Proxy = http.ProxyURL(pu)
}
}
if opts.Insecure {
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
}
jar, _ := cookiejar.New(nil)
client := &http.Client{Transport: transport, Timeout: 60 * time.Second, Jar: jar}
if !opts.FollowRedirects {
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
}
}
return client
}
func applyHeaders(req *http.Request, headers http.Header) {
for k, v := range headers {
for _, vv := range v {
req.Header.Add(k, vv)
}
}
}
func applyCookies(req *http.Request, cookies map[string]string) {
for k, v := range cookies {
req.AddCookie(&http.Cookie{Name: k, Value: v})
}
}
func sendInitRequest(cookies map[string]string, proxy string, insecure bool) (*http.Response, map[string]string, error) {
client := newHTTPClient(httpOptions{ProxyURL: proxy, Insecure: insecure, FollowRedirects: true})
req, _ := http.NewRequest(http.MethodGet, EndpointInit, nil)
applyHeaders(req, HeadersGemini)
applyCookies(req, cookies)
resp, err := client.Do(req)
if err != nil {
return nil, nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return resp, nil, &AuthError{Msg: resp.Status}
}
outCookies := map[string]string{}
for _, c := range resp.Cookies() {
outCookies[c.Name] = c.Value
}
for k, v := range cookies {
outCookies[k] = v
}
return resp, outCookies, nil
}
func getAccessToken(baseCookies map[string]string, proxy string, verbose bool, insecure bool) (string, map[string]string, error) {
extraCookies := map[string]string{}
{
client := newHTTPClient(httpOptions{ProxyURL: proxy, Insecure: insecure, FollowRedirects: true})
req, _ := http.NewRequest(http.MethodGet, EndpointGoogle, nil)
resp, err := client.Do(req)
if err != nil {
if verbose {
log.Debugf("priming google cookies failed: %v", err)
}
} else if resp != nil {
if u, err := url.Parse(EndpointGoogle); err == nil {
for _, c := range client.Jar.Cookies(u) {
extraCookies[c.Name] = c.Value
}
}
_ = resp.Body.Close()
}
}
trySets := make([]map[string]string, 0, 8)
if v1, ok1 := baseCookies["__Secure-1PSID"]; ok1 {
if v2, ok2 := baseCookies["__Secure-1PSIDTS"]; ok2 {
merged := map[string]string{"__Secure-1PSID": v1, "__Secure-1PSIDTS": v2}
if nid, ok := baseCookies["NID"]; ok {
merged["NID"] = nid
}
trySets = append(trySets, merged)
} else if verbose {
log.Debug("Skipping base cookies: __Secure-1PSIDTS missing")
}
}
if len(extraCookies) > 0 {
trySets = append(trySets, extraCookies)
}
reToken := regexp.MustCompile(`"SNlM0e":"([^"]+)"`)
for _, cookies := range trySets {
resp, mergedCookies, err := sendInitRequest(cookies, proxy, insecure)
if err != nil {
if verbose {
log.Warnf("Failed init request: %v", err)
}
continue
}
body, err := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if err != nil {
return "", nil, err
}
matches := reToken.FindStringSubmatch(string(body))
if len(matches) >= 2 {
token := matches[1]
if verbose {
fmt.Println("Gemini access token acquired.")
}
return token, mergedCookies, nil
}
}
return "", nil, &AuthError{Msg: "Failed to retrieve token."}
}
func rotate1PSIDTS(cookies map[string]string, proxy string, insecure bool) (string, error) {
_, ok := cookies["__Secure-1PSID"]
if !ok {
return "", &AuthError{Msg: "__Secure-1PSID missing"}
}
// Reuse shared HTTP client helper for consistency.
client := newHTTPClient(httpOptions{ProxyURL: proxy, Insecure: insecure, FollowRedirects: true})
req, _ := http.NewRequest(http.MethodPost, EndpointRotateCookies, strings.NewReader("[000,\"-0000000000000000000\"]"))
applyHeaders(req, HeadersRotateCookies)
applyCookies(req, cookies)
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode == http.StatusUnauthorized {
return "", &AuthError{Msg: "unauthorized"}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", errors.New(resp.Status)
}
for _, c := range resp.Cookies() {
if c.Name == "__Secure-1PSIDTS" {
return c.Value, nil
}
}
// Fallback: check cookie jar in case the Set-Cookie was on a redirect hop
if u, err := url.Parse(EndpointRotateCookies); err == nil && client.Jar != nil {
for _, c := range client.Jar.Cookies(u) {
if c.Name == "__Secure-1PSIDTS" && c.Value != "" {
return c.Value, nil
}
}
}
return "", nil
}
// MaskToken28 masks a sensitive token for safe logging. Keep middle partially visible.
func MaskToken28(s string) string {
n := len(s)
if n == 0 {
return ""
}
if n < 20 {
return strings.Repeat("*", n)
}
midStart := n/2 - 2
if midStart < 8 {
midStart = 8
}
if midStart+4 > n-8 {
midStart = n - 8 - 4
if midStart < 8 {
midStart = 8
}
}
prefixByte := s[:8]
middle := s[midStart : midStart+4]
suffix := s[n-8:]
return prefixByte + strings.Repeat("*", 4) + middle + strings.Repeat("*", 4) + suffix
}
var NanoBananaModel = map[string]struct{}{
"gemini-2.5-flash-image-preview": {},
}
// NewGeminiClient creates a client. Pass empty strings to auto-detect via browser cookies (not implemented in Go port).
func NewGeminiClient(secure1psid string, secure1psidts string, proxy string, opts ...func(*GeminiClient)) *GeminiClient {
c := &GeminiClient{
Cookies: map[string]string{},
Proxy: proxy,
Running: false,
Timeout: 300 * time.Second,
insecure: false,
}
if secure1psid != "" {
c.Cookies["__Secure-1PSID"] = secure1psid
if secure1psidts != "" {
c.Cookies["__Secure-1PSIDTS"] = secure1psidts
}
}
for _, f := range opts {
f(c)
}
return c
}
// WithInsecureTLS sets skipping TLS verification (to mirror httpx verify=False)
func WithInsecureTLS(insecure bool) func(*GeminiClient) {
return func(c *GeminiClient) { c.insecure = insecure }
}
// Init initializes the access token and http client.
func (c *GeminiClient) Init(timeoutSec float64, verbose bool) error {
// get access token
token, validCookies, err := getAccessToken(c.Cookies, c.Proxy, verbose, c.insecure)
if err != nil {
c.Close(0)
return err
}
c.AccessToken = token
c.Cookies = validCookies
tr := &http.Transport{}
if c.Proxy != "" {
if pu, errParse := url.Parse(c.Proxy); errParse == nil {
tr.Proxy = http.ProxyURL(pu)
}
}
if c.insecure {
// set via roundtripper in utils_get_access_token for token; here we reuse via default Transport
// intentionally not adding here, as requests rely on endpoints with normal TLS
}
c.httpClient = &http.Client{Transport: tr, Timeout: time.Duration(timeoutSec * float64(time.Second))}
c.Running = true
c.Timeout = time.Duration(timeoutSec * float64(time.Second))
if verbose {
fmt.Println("Gemini client initialized successfully.")
}
return nil
}
func (c *GeminiClient) Close(delaySec float64) {
if delaySec > 0 {
time.Sleep(time.Duration(delaySec * float64(time.Second)))
}
c.Running = false
}
// ensureRunning mirrors the decorator behavior and retries on APIError.
func (c *GeminiClient) ensureRunning() error {
if c.Running {
return nil
}
return c.Init(float64(c.Timeout/time.Second), false)
}
// RotateTS performs a RotateCookies request and returns the new __Secure-1PSIDTS value (if any).
func (c *GeminiClient) RotateTS() (string, error) {
if c == nil {
return "", fmt.Errorf("gemini web client is nil")
}
return rotate1PSIDTS(c.Cookies, c.Proxy, c.insecure)
}
// GenerateContent sends a prompt (with optional files) and parses the response into ModelOutput.
func (c *GeminiClient) GenerateContent(prompt string, files []string, model Model, gem *Gem, chat *ChatSession) (ModelOutput, error) {
var empty ModelOutput
if prompt == "" {
return empty, &ValueError{Msg: "Prompt cannot be empty."}
}
if err := c.ensureRunning(); err != nil {
return empty, err
}
// Retry wrapper similar to decorator (retry=2)
retries := 2
for {
out, err := c.generateOnce(prompt, files, model, gem, chat)
if err == nil {
return out, nil
}
var apiErr *APIError
var imgErr *ImageGenerationError
shouldRetry := false
if errors.As(err, &imgErr) {
if retries > 1 {
retries = 1
} // only once for image generation
shouldRetry = true
} else if errors.As(err, &apiErr) {
shouldRetry = true
}
if shouldRetry && retries > 0 {
time.Sleep(time.Second)
retries--
continue
}
return empty, err
}
}
func ensureAnyLen(slice []any, index int) []any {
if index < len(slice) {
return slice
}
gap := index + 1 - len(slice)
return append(slice, make([]any, gap)...)
}
func (c *GeminiClient) generateOnce(prompt string, files []string, model Model, gem *Gem, chat *ChatSession) (ModelOutput, error) {
var empty ModelOutput
// Build f.req
var uploaded [][]any
for _, fp := range files {
id, err := uploadFile(fp, c.Proxy, c.insecure)
if err != nil {
return empty, err
}
name, err := parseFileName(fp)
if err != nil {
return empty, err
}
uploaded = append(uploaded, []any{[]any{id}, name})
}
var item0 any
if len(uploaded) > 0 {
item0 = []any{prompt, 0, nil, uploaded}
} else {
item0 = []any{prompt}
}
var item2 any = nil
if chat != nil {
item2 = chat.Metadata()
}
inner := []any{item0, nil, item2}
requestedModel := strings.ToLower(model.Name)
if chat != nil && chat.RequestedModel() != "" {
requestedModel = chat.RequestedModel()
}
if _, ok := NanoBananaModel[requestedModel]; ok {
inner = ensureAnyLen(inner, 49)
inner[49] = 14
}
if gem != nil {
// pad with 16 nils then gem ID
for i := 0; i < 16; i++ {
inner = append(inner, nil)
}
inner = append(inner, gem.ID)
}
innerJSON, _ := json.Marshal(inner)
outer := []any{nil, string(innerJSON)}
outerJSON, _ := json.Marshal(outer)
// form
form := url.Values{}
form.Set("at", c.AccessToken)
form.Set("f.req", string(outerJSON))
req, _ := http.NewRequest(http.MethodPost, EndpointGenerate, strings.NewReader(form.Encode()))
applyHeaders(req, HeadersGemini)
applyHeaders(req, model.ModelHeader)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded;charset=utf-8")
applyCookies(req, c.Cookies)
resp, err := c.httpClient.Do(req)
if err != nil {
return empty, &TimeoutError{GeminiError{Msg: "Generate content request timed out."}}
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode == 429 {
// Surface 429 as TemporarilyBlocked to match reference behavior
c.Close(0)
return empty, &TemporarilyBlocked{GeminiError{Msg: "Too many requests. IP temporarily blocked."}}
}
if resp.StatusCode != 200 {
c.Close(0)
return empty, &APIError{Msg: fmt.Sprintf("Failed to generate contents. Status %d", resp.StatusCode)}
}
// Read body and split lines; take the 3rd line (index 2)
b, _ := io.ReadAll(resp.Body)
parts := strings.Split(string(b), "\n")
if len(parts) < 3 {
c.Close(0)
return empty, &APIError{Msg: "Invalid response data received."}
}
var responseJSON []any
if err = json.Unmarshal([]byte(parts[2]), &responseJSON); err != nil {
c.Close(0)
return empty, &APIError{Msg: "Invalid response data received."}
}
// find body where main_part[4] exists
var (
body any
bodyIndex int
)
for i, p := range responseJSON {
arr, ok := p.([]any)
if !ok || len(arr) < 3 {
continue
}
s, ok := arr[2].(string)
if !ok {
continue
}
var mainPart []any
if err = json.Unmarshal([]byte(s), &mainPart); err != nil {
continue
}
if len(mainPart) > 4 && mainPart[4] != nil {
body = mainPart
bodyIndex = i
break
}
}
if body == nil {
// Fallback: scan subsequent lines to locate a data frame with a non-empty body (mainPart[4]).
var lastTop []any
for li := 3; li < len(parts) && body == nil; li++ {
line := strings.TrimSpace(parts[li])
if line == "" {
continue
}
var top []any
if err = json.Unmarshal([]byte(line), &top); err != nil {
continue
}
lastTop = top
for i, p := range top {
arr, ok := p.([]any)
if !ok || len(arr) < 3 {
continue
}
s, ok := arr[2].(string)
if !ok {
continue
}
var mainPart []any
if err = json.Unmarshal([]byte(s), &mainPart); err != nil {
continue
}
if len(mainPart) > 4 && mainPart[4] != nil {
body = mainPart
bodyIndex = i
responseJSON = top
break
}
}
}
// Parse nested error code to align with error mapping
var top []any
// Prefer lastTop from fallback scan; otherwise try parts[2]
if len(lastTop) > 0 {
top = lastTop
} else {
_ = json.Unmarshal([]byte(parts[2]), &top)
}
if len(top) > 0 {
if code, ok := extractErrorCode(top); ok {
switch code {
case ErrorUsageLimitExceeded:
return empty, &UsageLimitExceeded{GeminiError{Msg: fmt.Sprintf("Failed to generate contents. Usage limit of %s has exceeded. Please try switching to another model.", model.Name)}}
case ErrorModelInconsistent:
return empty, &ModelInvalid{GeminiError{Msg: "Selected model is inconsistent or unavailable."}}
case ErrorModelHeaderInvalid:
return empty, &APIError{Msg: "Invalid model header string. Please update the selected model header."}
case ErrorIPTemporarilyBlocked:
return empty, &TemporarilyBlocked{GeminiError{Msg: "Too many requests. IP temporarily blocked."}}
}
}
}
// Debug("Invalid response: control frames only; no body found")
// Close the client to force re-initialization on next request (parity with reference client behavior)
c.Close(0)
return empty, &APIError{Msg: "Failed to generate contents. Invalid response data received."}
}
bodyArr := body.([]any)
// metadata
var metadata []string
if len(bodyArr) > 1 {
if metaArr, ok := bodyArr[1].([]any); ok {
for _, v := range metaArr {
if s, isOk := v.(string); isOk {
metadata = append(metadata, s)
}
}
}
}
// candidates parsing
candContainer, ok := bodyArr[4].([]any)
if !ok {
return empty, &APIError{Msg: "Failed to parse response body."}
}
candidates := make([]Candidate, 0, len(candContainer))
reCard := regexp.MustCompile(`^http://googleusercontent\.com/card_content/\d+`)
reGen := regexp.MustCompile(`http://googleusercontent\.com/image_generation_content/\d+`)
for ci, candAny := range candContainer {
cArr, isOk := candAny.([]any)
if !isOk {
continue
}
// text: cArr[1][0]
var text string
if len(cArr) > 1 {
if sArr, isOk1 := cArr[1].([]any); isOk1 && len(sArr) > 0 {
text, _ = sArr[0].(string)
}
}
if reCard.MatchString(text) {
// candidate[22] and candidate[22][0] or text
if len(cArr) > 22 {
if arr, isOk1 := cArr[22].([]any); isOk1 && len(arr) > 0 {
if s, isOk2 := arr[0].(string); isOk2 {
text = s
}
}
}
}
// thoughts: candidate[37][0][0]
var thoughts *string
if len(cArr) > 37 {
if a, ok1 := cArr[37].([]any); ok1 && len(a) > 0 {
if b1, ok2 := a[0].([]any); ok2 && len(b1) > 0 {
if s, ok3 := b1[0].(string); ok3 {
ss := decodeHTML(s)
thoughts = &ss
}
}
}
}
// web images: candidate[12][1]
var webImages []WebImage
var imgSection any
if len(cArr) > 12 {
imgSection = cArr[12]
}
if arr, ok1 := imgSection.([]any); ok1 && len(arr) > 1 {
if imagesArr, ok2 := arr[1].([]any); ok2 {
for _, wiAny := range imagesArr {
wiArr, ok3 := wiAny.([]any)
if !ok3 {
continue
}
// url: wiArr[0][0][0], title: wiArr[7][0], alt: wiArr[0][4]
var urlStr, title, alt string
if len(wiArr) > 0 {
if a, ok5 := wiArr[0].([]any); ok5 && len(a) > 0 {
if b1, ok6 := a[0].([]any); ok6 && len(b1) > 0 {
urlStr, _ = b1[0].(string)
}
if len(a) > 4 {
if s, ok6 := a[4].(string); ok6 {
alt = s
}
}
}
}
if len(wiArr) > 7 {
if a, ok4 := wiArr[7].([]any); ok4 && len(a) > 0 {
title, _ = a[0].(string)
}
}
webImages = append(webImages, WebImage{Image: Image{URL: urlStr, Title: title, Alt: alt, Proxy: c.Proxy}})
}
}
}
// generated images
var genImages []GeneratedImage
hasGen := false
if arr, ok1 := imgSection.([]any); ok1 && len(arr) > 7 {
if a, ok2 := arr[7].([]any); ok2 && len(a) > 0 && a[0] != nil {
hasGen = true
}
}
if hasGen {
// find img part
var imgBody []any
for pi := bodyIndex; pi < len(responseJSON); pi++ {
part := responseJSON[pi]
arr, ok1 := part.([]any)
if !ok1 || len(arr) < 3 {
continue
}
s, ok1 := arr[2].(string)
if !ok1 {
continue
}
var mp []any
if err = json.Unmarshal([]byte(s), &mp); err != nil {
continue
}
if len(mp) > 4 {
if tt, ok2 := mp[4].([]any); ok2 && len(tt) > ci {
if sec, ok3 := tt[ci].([]any); ok3 && len(sec) > 12 {
if ss, ok4 := sec[12].([]any); ok4 && len(ss) > 7 {
if first, ok5 := ss[7].([]any); ok5 && len(first) > 0 && first[0] != nil {
imgBody = mp
break
}
}
}
}
}
}
if imgBody == nil {
return empty, &ImageGenerationError{APIError{Msg: "Failed to parse generated images."}}
}
imgCand := imgBody[4].([]any)[ci].([]any)
if len(imgCand) > 1 {
if a, ok1 := imgCand[1].([]any); ok1 && len(a) > 0 {
if s, ok2 := a[0].(string); ok2 {
text = strings.TrimSpace(reGen.ReplaceAllString(s, ""))
}
}
}
// images list at imgCand[12][7][0]
if len(imgCand) > 12 {
if s1, ok1 := imgCand[12].([]any); ok1 && len(s1) > 7 {
if s2, ok2 := s1[7].([]any); ok2 && len(s2) > 0 {
if s3, ok3 := s2[0].([]any); ok3 {
for ii, giAny := range s3 {
ga, ok4 := giAny.([]any)
if !ok4 || len(ga) < 4 {
continue
}
// url: ga[0][3][3]
var urlStr, title, alt string
if a, ok5 := ga[0].([]any); ok5 && len(a) > 3 {
if b1, ok6 := a[3].([]any); ok6 && len(b1) > 3 {
urlStr, _ = b1[3].(string)
}
}
// title from ga[3][6]
if len(ga) > 3 {
if a, ok5 := ga[3].([]any); ok5 {
if len(a) > 6 {
if v, ok6 := a[6].(float64); ok6 && v != 0 {
title = fmt.Sprintf("[Generated Image %.0f]", v)
} else {
title = "[Generated Image]"
}
} else {
title = "[Generated Image]"
}
// alt from ga[3][5][ii] fallback
if len(a) > 5 {
if tt, ok6 := a[5].([]any); ok6 {
if ii < len(tt) {
if s, ok7 := tt[ii].(string); ok7 {
alt = s
}
} else if len(tt) > 0 {
if s, ok7 := tt[0].(string); ok7 {
alt = s
}
}
}
}
}
}
genImages = append(genImages, GeneratedImage{Image: Image{URL: urlStr, Title: title, Alt: alt, Proxy: c.Proxy}, Cookies: c.Cookies})
}
}
}
}
}
}
cand := Candidate{
RCID: fmt.Sprintf("%v", cArr[0]),
Text: decodeHTML(text),
Thoughts: thoughts,
WebImages: webImages,
GeneratedImages: genImages,
}
candidates = append(candidates, cand)
}
if len(candidates) == 0 {
return empty, &GeminiError{Msg: "Failed to generate contents. No output data found in response."}
}
output := ModelOutput{Metadata: metadata, Candidates: candidates, Chosen: 0}
if chat != nil {
chat.lastOutput = &output
}
return output, nil
}
// extractErrorCode attempts to navigate the known nested error structure and fetch the integer code.
// Mirrors reference path: response_json[0][5][2][0][1][0]
func extractErrorCode(top []any) (int, bool) {
if len(top) == 0 {
return 0, false
}
a, ok := top[0].([]any)
if !ok || len(a) <= 5 {
return 0, false
}
b, ok := a[5].([]any)
if !ok || len(b) <= 2 {
return 0, false
}
c, ok := b[2].([]any)
if !ok || len(c) == 0 {
return 0, false
}
d, ok := c[0].([]any)
if !ok || len(d) <= 1 {
return 0, false
}
e, ok := d[1].([]any)
if !ok || len(e) == 0 {
return 0, false
}
f, ok := e[0].(float64)
if !ok {
return 0, false
}
return int(f), true
}
// StartChat returns a ChatSession attached to the client
func (c *GeminiClient) StartChat(model Model, gem *Gem, metadata []string) *ChatSession {
return &ChatSession{client: c, metadata: normalizeMeta(metadata), model: model, gem: gem, requestedModel: strings.ToLower(model.Name)}
}
// ChatSession holds conversation metadata
type ChatSession struct {
client *GeminiClient
metadata []string // cid, rid, rcid
lastOutput *ModelOutput
model Model
gem *Gem
requestedModel string
}
func (cs *ChatSession) String() string {
var cid, rid, rcid string
if len(cs.metadata) > 0 {
cid = cs.metadata[0]
}
if len(cs.metadata) > 1 {
rid = cs.metadata[1]
}
if len(cs.metadata) > 2 {
rcid = cs.metadata[2]
}
return fmt.Sprintf("ChatSession(cid='%s', rid='%s', rcid='%s')", cid, rid, rcid)
}
func normalizeMeta(v []string) []string {
out := []string{"", "", ""}
for i := 0; i < len(v) && i < 3; i++ {
out[i] = v[i]
}
return out
}
func (cs *ChatSession) Metadata() []string { return cs.metadata }
func (cs *ChatSession) SetMetadata(v []string) { cs.metadata = normalizeMeta(v) }
func (cs *ChatSession) RequestedModel() string { return cs.requestedModel }
func (cs *ChatSession) SetRequestedModel(name string) {
cs.requestedModel = strings.ToLower(name)
}
func (cs *ChatSession) CID() string {
if len(cs.metadata) > 0 {
return cs.metadata[0]
}
return ""
}
func (cs *ChatSession) RID() string {
if len(cs.metadata) > 1 {
return cs.metadata[1]
}
return ""
}
func (cs *ChatSession) RCID() string {
if len(cs.metadata) > 2 {
return cs.metadata[2]
}
return ""
}
func (cs *ChatSession) setCID(v string) {
if len(cs.metadata) < 1 {
cs.metadata = normalizeMeta(cs.metadata)
}
cs.metadata[0] = v
}
func (cs *ChatSession) setRID(v string) {
if len(cs.metadata) < 2 {
cs.metadata = normalizeMeta(cs.metadata)
}
cs.metadata[1] = v
}
func (cs *ChatSession) setRCID(v string) {
if len(cs.metadata) < 3 {
cs.metadata = normalizeMeta(cs.metadata)
}
cs.metadata[2] = v
}
// SendMessage shortcut to client's GenerateContent
func (cs *ChatSession) SendMessage(prompt string, files []string) (ModelOutput, error) {
out, err := cs.client.GenerateContent(prompt, files, cs.model, cs.gem, cs)
if err == nil {
cs.lastOutput = &out
cs.SetMetadata(out.Metadata)
cs.setRCID(out.RCID())
}
return out, err
}
// ChooseCandidate selects a candidate from last output and updates rcid
func (cs *ChatSession) ChooseCandidate(index int) (ModelOutput, error) {
if cs.lastOutput == nil {
return ModelOutput{}, &ValueError{Msg: "No previous output data found in this chat session."}
}
if index >= len(cs.lastOutput.Candidates) {
return ModelOutput{}, &ValueError{Msg: fmt.Sprintf("Index %d exceeds candidates", index)}
}
cs.lastOutput.Chosen = index
cs.setRCID(cs.lastOutput.RCID())
return *cs.lastOutput, nil
}

View File

@@ -0,0 +1,80 @@
package conversation
import (
"strings"
"sync"
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
)
var (
aliasOnce sync.Once
aliasMap map[string]string
)
// EnsureGeminiWebAliasMap populates the alias map once.
func EnsureGeminiWebAliasMap() {
aliasOnce.Do(func() {
aliasMap = make(map[string]string)
for _, m := range registry.GetGeminiModels() {
if m.ID == "gemini-2.5-flash-lite" {
continue
}
if m.ID == "gemini-2.5-flash" {
aliasMap["gemini-2.5-flash-image-preview"] = "gemini-2.5-flash"
}
alias := AliasFromModelID(m.ID)
aliasMap[strings.ToLower(alias)] = strings.ToLower(m.ID)
}
})
}
// MapAliasToUnderlying normalizes a model alias to its underlying identifier.
func MapAliasToUnderlying(name string) string {
EnsureGeminiWebAliasMap()
n := strings.ToLower(strings.TrimSpace(name))
if n == "" {
return n
}
if u, ok := aliasMap[n]; ok {
return u
}
const suffix = "-web"
if strings.HasSuffix(n, suffix) {
return strings.TrimSuffix(n, suffix)
}
return n
}
// AliasFromModelID mirrors the original helper for deriving alias IDs.
func AliasFromModelID(modelID string) string {
return modelID + "-web"
}
// NormalizeModel returns the canonical identifier used for hashing.
func NormalizeModel(model string) string {
return MapAliasToUnderlying(model)
}
// GetGeminiWebAliasedModels returns alias metadata for registry exposure.
func GetGeminiWebAliasedModels() []*registry.ModelInfo {
EnsureGeminiWebAliasMap()
aliased := make([]*registry.ModelInfo, 0)
for _, m := range registry.GetGeminiModels() {
if m.ID == "gemini-2.5-flash-lite" {
continue
} else if m.ID == "gemini-2.5-flash" {
cpy := *m
cpy.ID = "gemini-2.5-flash-image-preview"
cpy.Name = "gemini-2.5-flash-image-preview"
cpy.DisplayName = "Nano Banana"
cpy.Description = "Gemini 2.5 Flash Preview Image"
aliased = append(aliased, &cpy)
}
cpy := *m
cpy.ID = AliasFromModelID(m.ID)
cpy.Name = cpy.ID
aliased = append(aliased, &cpy)
}
return aliased
}

Some files were not shown because too many files have changed in this diff Show More