Compare commits

..

38 Commits

Author SHA1 Message Date
Luis Pater
3af24597ee docs: remove Amp CLI integration guides and update references 2025-12-15 23:50:56 +08:00
Luis Pater
88b101ebf5 Merge pull request #549 from router-for-me/log
Improve Request Logging Efficiency and Standardize Error Responses
2025-12-15 20:43:12 +08:00
Luis Pater
d9a65745df fix(translator): handle empty item type and string content in OpenAI response parser 2025-12-15 20:35:52 +08:00
hkfires
97ab623d42 fix(api): prevent double logging for streaming responses 2025-12-15 18:00:32 +08:00
hkfires
14aa6cc7e8 fix(api): ensure all response writes are captured for logging
The response writer wrapper has been refactored to more reliably capture response bodies for logging, fixing several edge cases.

- Implements `WriteString` to capture writes from `io.StringWriter`, which were previously missed by the `Write` method override.
- A new `shouldBufferResponseBody` helper centralizes the logic to ensure the body is buffered only when logging is active or for errors when `logOnErrorOnly` is enabled.
- Streaming detection is now more robust. It correctly handles non-streaming error responses (e.g., `application/json`) that are generated for a request that was intended to be streaming.

BREAKING CHANGE: The public methods `Status()`, `Size()`, and `Written()` have been removed from the `ResponseWriterWrapper` as they are no longer required by the new implementation.
2025-12-15 17:45:16 +08:00
hkfires
3bc489254b fix(api): prevent double logging for error responses
The WriteErrorResponse function now caches the error response body in the gin context.

The deferred request logger checks for this cached response. If an error response is found, it bypasses the standard response logging. This prevents scenarios where an error is logged twice or an empty payload log overwrites the original, more detailed error log.
2025-12-15 16:36:01 +08:00
hkfires
4c07ea41c3 feat(api): return structured JSON error responses
The API error handling is updated to return a structured JSON payload
instead of a plain text message. This provides more context and allows
clients to programmatically handle different error types.

The new error response has the following structure:
{
  "error": {
    "message": "...",
    "type": "..."
  }
}

The `type` field is determined by the HTTP status code, such as
`authentication_error`, `rate_limit_error`, or `server_error`.

If the underlying error message from an upstream service is already a
valid JSON string, it will be preserved and returned directly.

BREAKING CHANGE: API error responses are now in a structured JSON
format instead of plain text. Clients expecting plain text error
messages will need to be updated to parse the new JSON body.
2025-12-15 16:19:52 +08:00
Luis Pater
f6720f8dfa Merge pull request #547 from router-for-me/amp
feat(amp): require API key authentication for management routes
2025-12-15 16:14:49 +08:00
Chén Mù
e19ab3a066 Merge pull request #543 from router-for-me/log
feat(auth): add proxy information to debug logs
2025-12-15 15:59:16 +08:00
hkfires
8f1dd69e72 feat(amp): require API key authentication for management routes
All Amp management endpoints (e.g., /api/user, /threads) are now protected by the standard API key authentication middleware. This ensures that all management operations require a valid API key, significantly improving security.

As a result of this change:
- The `restrict-management-to-localhost` setting now defaults to `false`. API key authentication provides a stronger and more flexible security control than IP-based restrictions, improving usability in containerized environments.
- The reverse proxy logic now strips the client's `Authorization` header after authenticating the initial request. It then injects the configured `upstream-api-key` for the request to the upstream Amp service.

BREAKING CHANGE: Amp management endpoints now require a valid API key for authentication. Requests without a valid API key in the `Authorization` header will be rejected with a 401 Unauthorized error.
2025-12-15 13:24:53 +08:00
hkfires
f26da24a2f feat(auth): add proxy information to debug logs 2025-12-15 13:14:55 +08:00
Luis Pater
8e4fbcaa7d Merge pull request #533 from router-for-me/think
refactor(thinking): centralize reasoning effort mapping and normalize budget values
2025-12-15 10:34:41 +08:00
hkfires
09c339953d fix(openai): forward reasoning.effort value
Drop the hardcoded effort mapping in request conversion so
unknown values are preserved instead of being coerced to `auto
2025-12-15 09:16:15 +08:00
hkfires
367a05bdf6 refactor(thinking): export thinking helpers
Expose thinking/effort normalization helpers from the executor package
so conversion tests use production code and stay aligned with runtime
validation behavior.
2025-12-15 09:16:15 +08:00
hkfires
d20b71deb9 fix(thinking): normalize effort mapping
Route OpenAI reasoning effort through ThinkingEffortToBudget for Claude
translators, preserve "minimal" when translating OpenAI Responses, and
treat blank/unknown efforts as no-ops for Gemini thinking configs.

Also map budget -1 to "auto" and expand cross-protocol thinking tests.
2025-12-15 09:16:15 +08:00
hkfires
712ce9f781 fix(thinking): drop unsupported none effort
When budget 0 maps to "none" for models that use thinking levels
but don't support that effort level, strip thinking fields instead
of setting an invalid reasoning_effort value.
Tests now expect removal for this edge case.
2025-12-15 09:16:14 +08:00
hkfires
a4a3274a55 test(thinking): expand conversion edge case coverage 2025-12-15 09:16:14 +08:00
hkfires
716aa71f6e fix(thinking): centralize reasoning_effort mapping
Move OpenAI `reasoning_effort` -> Gemini `thinkingConfig` budget logic into
shared helpers used by Gemini, Gemini CLI, and antigravity translators.

Normalize Claude thinking handling by preferring positive budgets, applying
budget token normalization, and gating by model support.

Always convert Gemini `thinkingBudget` back to OpenAI `reasoning_effort` to
support allowCompat models, and update tests for normalization behavior.
2025-12-15 09:16:14 +08:00
hkfires
e8976f9898 fix(thinking): map budgets to effort for level models 2025-12-15 09:16:14 +08:00
hkfires
8496cc2444 test(thinking): cover openai-compat reasoning passthrough 2025-12-15 09:16:14 +08:00
hkfires
5ef2d59e05 fix(thinking): gate reasoning effort by model support
Only map OpenAI reasoning effort to Claude thinking for models that support
thinking and use budget tokens (not level-based thinking).

Also add "xhigh" effort mapping and adjust minimal/low budgets, with new
raw-payload conversion tests across protocols and models.
2025-12-15 09:16:14 +08:00
Chén Mù
07bb89ae80 Merge pull request #542 from router-for-me/aistudio 2025-12-15 09:13:25 +08:00
hkfires
27a5ad8ec2 Fixed: #534
fix(aistudio): correct JSON string boundary detection for backslash sequences
2025-12-15 09:00:14 +08:00
Luis Pater
707b07c5f5 Merge pull request #537 from sukakcoding/fix/function-response-fallback
fix: handle malformed json in function response parsing
2025-12-15 03:31:09 +08:00
sukakcoding
4a764afd76 refactor: extract parseFunctionResponse helper to reduce duplication 2025-12-15 01:05:36 +08:00
sukakcoding
ecf49d574b fix: handle malformed json in function response parsing 2025-12-15 00:59:46 +08:00
Luis Pater
5a75ef8ffd Merge pull request #536 from AoaoMH/feature/auth-model-check
feat: using Client Model Infos;
2025-12-15 00:29:33 +08:00
Test
07279f8746 feat: using Client Model Infos; 2025-12-15 00:13:05 +08:00
Luis Pater
71f788b13a fix(registry): remove unused ThinkingSupport from DeepSeek-R1 model 2025-12-14 21:30:17 +08:00
Luis Pater
59c62dc580 fix(registry): correct DeepSeek-V3.2 experimental model ID 2025-12-14 21:27:43 +08:00
Luis Pater
d5310a3300 Merge pull request #531 from AoaoMH/feature/auth-model-check
feat: add API endpoint to query models for auth credentials
2025-12-14 16:46:43 +08:00
Luis Pater
f0a3eb574e fix(registry): update DeepSeek model definitions with new IDs and descriptions 2025-12-14 16:17:11 +08:00
Test
bb15855443 feat: add API endpoint to query models for auth credentials 2025-12-14 15:16:26 +08:00
Luis Pater
14ce6aebd1 Merge pull request #449 from sususu98/fix/gemini-cli-429-retry-delay-parsing
fix(gemini-cli): enhance 429 retry delay parsing
2025-12-14 14:04:14 +08:00
Luis Pater
2fe83723f2 Merge pull request #515 from teeverc/fix/response-rewriter-streaming-flush
fix(amp): flush response buffer after each streaming chunk write
2025-12-14 13:26:05 +08:00
teeverc
cd8c86c6fb refactor: only flush stream response on successful write 2025-12-13 13:32:54 -08:00
teeverc
52d5fd1a67 fix: streaming for amp cli 2025-12-13 13:17:53 -08:00
sususu
07d21463ca fix(gemini-cli): enhance 429 retry delay parsing
Add fallback parsing for quota reset delay when RetryInfo is not present:
- Try ErrorInfo.metadata.quotaResetDelay (e.g., "373.801628ms")
- Parse from error.message "Your quota will reset after Xs."

This ensures proper cooldown timing for rate-limited requests.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 09:34:39 +08:00
44 changed files with 1070 additions and 1356 deletions

View File

@@ -59,7 +59,7 @@ CLIProxyAPI includes integrated support for [Amp CLI](https://ampcode.com) and A
- **Model mapping** to route unavailable models to alternatives (e.g., `claude-opus-4.5``claude-sonnet-4`)
- Security-first design with localhost-only management endpoints
**→ [Complete Amp CLI Integration Guide](docs/amp-cli-integration.md)**
**→ [Complete Amp CLI Integration Guide](https://help.router-for.me/agent-client/amp-cli.html)**
## SDK Docs

View File

@@ -57,7 +57,7 @@ CLIProxyAPI 已内置对 [Amp CLI](https://ampcode.com) 和 Amp IDE 扩展的支
- 智能模型回退与自动路由
- 以安全为先的设计,管理端点仅限 localhost
**→ [Amp CLI 完整集成指南](docs/amp-cli-integration_CN.md)**
**→ [Amp CLI 完整集成指南](https://help.router-for.me/cn/agent-client/amp-cli.html)**
## SDK 文档

View File

@@ -136,8 +136,8 @@ ws-auth: false
# upstream-url: "https://ampcode.com"
# # Optional: Override API key for Amp upstream (otherwise uses env or file)
# upstream-api-key: ""
# # Restrict Amp management routes (/api/auth, /api/user, etc.) to localhost only (recommended)
# restrict-management-to-localhost: true
# # Restrict Amp management routes (/api/auth, /api/user, etc.) to localhost only (default: false)
# restrict-management-to-localhost: false
# # Force model mappings to run before checking local API keys (default: false)
# force-model-mappings: false
# # Amp Model Mappings

View File

@@ -1,443 +0,0 @@
# Amp CLI Integration Guide
This guide explains how to use CLIProxyAPI with Amp CLI and Amp IDE extensions, enabling you to use your existing Google/ChatGPT/Claude subscriptions (via OAuth) with Amp's CLI.
## Table of Contents
- [Overview](#overview)
- [Which Providers Should You Authenticate?](#which-providers-should-you-authenticate)
- [Architecture](#architecture)
- [Configuration](#configuration)
- [Model Mapping Configuration](#model-mapping-configuration)
- [Setup](#setup)
- [Usage](#usage)
- [Troubleshooting](#troubleshooting)
## Overview
The Amp CLI integration adds specialized routing to support Amp's API patterns while maintaining full compatibility with all existing CLIProxyAPI features. This allows you to use both traditional CLIProxyAPI features and Amp CLI with the same proxy server.
### Key Features
- **Provider route aliases**: Maps Amp's `/api/provider/{provider}/v1...` patterns to CLIProxyAPI handlers
- **Management proxy**: Forwards OAuth and account management requests to Amp's control plane
- **Smart fallback**: Automatically routes unconfigured models to ampcode.com
- **Model mapping**: Route unavailable models to alternatives you have access to (e.g., `claude-opus-4.5``claude-sonnet-4`)
- **Secret management**: Configurable precedence (config > env > file) with 5-minute caching
- **Security-first**: Management routes restricted to localhost by default
- **Automatic gzip handling**: Decompresses responses from Amp upstream
### What You Can Do
- Use Amp CLI with your Google account (Gemini 3 Pro Preview, Gemini 2.5 Pro, Gemini 2.5 Flash)
- Use Amp CLI with your ChatGPT Plus/Pro subscription (GPT-5, GPT-5 Codex models)
- Use Amp CLI with your Claude Pro/Max subscription (Claude Sonnet 4.5, Opus 4.1)
- Use Amp IDE extensions (VS Code, Cursor, Windsurf, etc.) with the same proxy
- Run multiple CLI tools (Factory + Amp) through one proxy server
- Route unconfigured models automatically through ampcode.com
### Which Providers Should You Authenticate?
**Important**: The providers you need to authenticate depend on which models and features your installed version of Amp currently uses. Amp employs different providers for various agent modes and specialized subagents:
- **Smart mode**: Uses Google/Gemini models (Gemini 3 Pro)
- **Rush mode**: Uses Anthropic/Claude models (Claude Haiku 4.5)
- **Oracle subagent**: Uses OpenAI/GPT models (GPT-5 medium reasoning)
- **Librarian subagent**: Uses Anthropic/Claude models (Claude Sonnet 4.5)
- **Search subagent**: Uses Anthropic/Claude models (Claude Haiku 4.5)
- **Review feature**: Uses Google/Gemini models (Gemini 2.5 Flash-Lite)
For the most current information about which models Amp uses, see the **[Amp Models Documentation](https://ampcode.com/models)**.
#### Fallback Behavior
CLIProxyAPI uses a smart fallback system:
1. **Provider authenticated locally** (`--login`, `--codex-login`, `--claude-login`):
- Requests use **your OAuth subscription** (ChatGPT Plus/Pro, Claude Pro/Max, Google account)
- You benefit from your subscription's included usage quotas
- No Amp credits consumed
2. **Provider NOT authenticated locally**:
- Requests automatically forward to **ampcode.com**
- Uses Amp's backend provider connections
- **Requires Amp credits** if the provider is paid (OpenAI, Anthropic paid tiers)
- May result in errors if Amp credit balance is insufficient
**Recommendation**: Authenticate all providers you have subscriptions for to maximize value and minimize Amp credit usage. If you don't have subscriptions to all providers Amp uses, ensure you have sufficient Amp credits available for fallback requests.
## Architecture
### Request Flow
```
Amp CLI/IDE
├─ Provider API requests (/api/provider/{provider}/v1/...)
│ ↓
│ ├─ Model configured locally?
│ │ YES → Use local OAuth tokens (OpenAI/Claude/Gemini handlers)
│ │ NO ↓
│ │ ├─ Model mapping configured?
│ │ │ YES → Rewrite model → Use local handler (free)
│ │ │ NO → Forward to ampcode.com (uses Amp credits)
│ ↓
│ Response
└─ Management requests (/api/auth, /api/user, /api/threads, ...)
├─ Localhost check (security)
└─ Reverse proxy to ampcode.com
Response (auto-decompressed if gzipped)
```
### Components
The Amp integration is implemented as a modular routing module (`internal/api/modules/amp/`) with these components:
1. **Route Aliases** (`routes.go`): Maps Amp-style paths to standard handlers
2. **Reverse Proxy** (`proxy.go`): Forwards management requests to ampcode.com
3. **Fallback Handler** (`fallback_handlers.go`): Routes unconfigured models to ampcode.com
4. **Secret Management** (`secret.go`): Multi-source API key resolution with caching
5. **Main Module** (`amp.go`): Orchestrates registration and configuration
## Configuration
### Basic Configuration
Add these fields to your `config.yaml`:
```yaml
# Amp upstream control plane (required for management routes)
amp-upstream-url: "https://ampcode.com"
# Optional: Override API key (otherwise uses env or file)
# amp-upstream-api-key: "your-amp-api-key"
# Security: restrict management routes to localhost (recommended)
amp-restrict-management-to-localhost: true
```
### Model Mapping Configuration
When Amp CLI requests a model that you don't have access to, you can configure mappings to route those requests to alternative models that you DO have available. This avoids consuming Amp credits for models you could handle locally.
```yaml
# Route unavailable models to alternatives
amp-model-mappings:
# Example: Route Claude Opus 4.5 requests to Claude Sonnet 4
- from: "claude-opus-4.5"
to: "claude-sonnet-4"
# Example: Route GPT-5 requests to Gemini 2.5 Pro
- from: "gpt-5"
to: "gemini-2.5-pro"
# Example: Map older model names to newer versions
- from: "claude-3-opus-20240229"
to: "claude-3-5-sonnet-20241022"
```
**How it works:**
1. Amp CLI requests a model (e.g., `claude-opus-4.5`)
2. CLIProxyAPI checks if a local provider is available for that model
3. If not available, it checks the model mappings
4. If a mapping exists, the request is rewritten to use the target model
5. The request is then handled locally (free, using your OAuth subscription)
**Benefits:**
- **Save Amp credits**: Use your local subscriptions instead of forwarding to ampcode.com
- **Hot-reload**: Mappings can be updated without restarting the proxy
- **Structured logging**: Clear logs show when mappings are applied
**Routing Decision Logs:**
The proxy logs each routing decision with structured fields:
```
[AMP] Using local provider for model: gemini-2.5-pro # Local provider (free)
[AMP] Model mapped: claude-opus-4.5 -> claude-sonnet-4 # Mapping applied (free)
[AMP] Forwarding to ampcode.com (uses Amp credits) - model_id: gpt-5 # Fallback (costs credits)
```
### Secret Resolution Precedence
The Amp module resolves API keys using this precedence order:
| Source | Key | Priority | Cache |
|--------|-----|----------|-------|
| Config file | `amp-upstream-api-key` | High | No |
| Environment | `AMP_API_KEY` | Medium | No |
| Amp secrets file | `~/.local/share/amp/secrets.json` | Low | 5 min |
**Recommendation**: Use the Amp secrets file (lowest precedence) for normal usage. This file is automatically managed by `amp login`.
### Security Settings
**`amp-restrict-management-to-localhost`** (default: `true`)
When enabled, management routes (`/api/auth`, `/api/user`, `/api/threads`, etc.) only accept connections from localhost (127.0.0.1, ::1). This prevents:
- Drive-by browser attacks
- Remote access to management endpoints
- CORS-based attacks
- Header spoofing attacks (e.g., `X-Forwarded-For: 127.0.0.1`)
#### How It Works
This restriction uses the **actual TCP connection address** (`RemoteAddr`), not HTTP headers like `X-Forwarded-For`. This prevents header spoofing attacks but has important implications:
-**Works for direct connections**: Running CLIProxyAPI directly on your machine or server
- ⚠️ **May not work behind reverse proxies**: If deploying behind nginx, Cloudflare, or other proxies, the connection will appear to come from the proxy's IP, not localhost
#### Reverse Proxy Deployments
If you need to run CLIProxyAPI behind a reverse proxy (nginx, Caddy, Cloudflare Tunnel, etc.):
1. **Disable the localhost restriction**:
```yaml
amp-restrict-management-to-localhost: false
```
2. **Use alternative security measures**:
- Firewall rules restricting access to management routes
- Proxy-level authentication (HTTP Basic Auth, OAuth)
- Network-level isolation (VPN, Tailscale, Cloudflare Access)
- Bind CLIProxyAPI to `127.0.0.1` only and access via SSH tunnel
3. **Example nginx configuration** (blocks external access to management routes):
```nginx
location /api/auth { deny all; }
location /api/user { deny all; }
location /api/threads { deny all; }
location /api/internal { deny all; }
```
**Important**: Only disable `amp-restrict-management-to-localhost` if you understand the security implications and have other protections in place.
## Setup
### 1. Configure CLIProxyAPI
Create or edit `config.yaml`:
```yaml
port: 8317
auth-dir: "~/.cli-proxy-api"
# Amp integration
amp-upstream-url: "https://ampcode.com"
amp-restrict-management-to-localhost: true
# Other standard settings...
debug: false
logging-to-file: true
```
### 2. Authenticate with Providers
Run OAuth login for the providers you want to use:
**Google Account (Gemini 2.5 Pro, Gemini 2.5 Flash, Gemini 3 Pro Preview):**
```bash
./cli-proxy-api --login
```
**ChatGPT Plus/Pro (GPT-5, GPT-5 Codex):**
```bash
./cli-proxy-api --codex-login
```
**Claude Pro/Max (Claude Sonnet 4.5, Opus 4.1):**
```bash
./cli-proxy-api --claude-login
```
Tokens are saved to:
- Gemini: `~/.cli-proxy-api/gemini-<email>.json`
- OpenAI Codex: `~/.cli-proxy-api/codex-<email>.json`
- Claude: `~/.cli-proxy-api/claude-<email>.json`
### 3. Start the Proxy
```bash
./cli-proxy-api --config config.yaml
```
Or run in background with tmux (recommended for remote servers):
```bash
tmux new-session -d -s proxy "./cli-proxy-api --config config.yaml"
```
### 4. Configure Amp CLI
#### Option A: Settings File
Edit `~/.config/amp/settings.json`:
```json
{
"amp.url": "http://localhost:8317"
}
```
#### Option B: Environment Variable
```bash
export AMP_URL=http://localhost:8317
```
### 5. Login and Use Amp
Login through the proxy (proxied to ampcode.com):
```bash
amp login
```
Use Amp as normal:
```bash
amp "Write a hello world program in Python"
```
### 6. (Optional) Configure Amp IDE Extension
The proxy also works with Amp IDE extensions for VS Code, Cursor, Windsurf, etc.
1. Open Amp extension settings in your IDE
2. Set **Amp URL** to `http://localhost:8317`
3. Login with your Amp account
4. Start using Amp in your IDE
Both CLI and IDE can use the proxy simultaneously.
## Usage
### Supported Routes
#### Provider Aliases (Always Available)
These routes work even without `amp-upstream-url` configured:
- `/api/provider/openai/v1/chat/completions`
- `/api/provider/openai/v1/responses`
- `/api/provider/anthropic/v1/messages`
- `/api/provider/google/v1beta/models/:action`
Amp CLI calls these routes with your OAuth-authenticated models configured in CLIProxyAPI.
#### Management Routes (Require `amp-upstream-url`)
These routes are proxied to ampcode.com:
- `/api/auth` - Authentication
- `/api/user` - User profile
- `/api/meta` - Metadata
- `/api/threads` - Conversation threads
- `/api/telemetry` - Usage telemetry
- `/api/internal` - Internal APIs
**Security**: Restricted to localhost by default.
### Model Fallback Behavior
When Amp requests a model:
1. **Check local configuration**: Does CLIProxyAPI have OAuth tokens for this model's provider?
2. **If YES**: Route to local handler (use your OAuth subscription)
3. **If NO**: Check if a model mapping exists
4. **If mapping exists**: Rewrite request to mapped model → Route to local handler (free)
5. **If no mapping**: Forward to ampcode.com (uses Amp credits)
This enables seamless mixed usage:
- Models you've configured (Gemini, ChatGPT, Claude) → Your OAuth subscriptions
- Models with mappings configured → Routed to alternative local models (free)
- Models you haven't configured and have no mapping → Amp's default providers (uses credits)
### Example API Calls
**Chat completion with local OAuth:**
```bash
curl http://localhost:8317/api/provider/openai/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-5",
"messages": [{"role": "user", "content": "Hello"}]
}'
```
**Management endpoint (localhost only):**
```bash
curl http://localhost:8317/api/user
```
## Troubleshooting
### Common Issues
| Symptom | Likely Cause | Fix |
|---------|--------------|-----|
| 404 on `/api/provider/...` | Incorrect route path | Ensure exact path: `/api/provider/{provider}/v1...` |
| 403 on `/api/user` | Non-localhost request | Run from same machine or disable `amp-restrict-management-to-localhost` (not recommended) |
| 401/403 from provider | Missing/expired OAuth | Re-run `--codex-login` or `--claude-login` |
| Amp gzip errors | Response decompression issue | Update to latest build; auto-decompression should handle this |
| Models not using proxy | Wrong Amp URL | Verify `amp.url` setting or `AMP_URL` environment variable |
| CORS errors | Protected management endpoint | Use CLI/terminal, not browser |
### Diagnostics
**Check proxy logs:**
```bash
# If logging-to-file: true
tail -f logs/requests.log
# If running in tmux
tmux attach-session -t proxy
```
**Enable debug mode** (temporarily):
```yaml
debug: true
```
**Test basic connectivity:**
```bash
# Check if proxy is running
curl http://localhost:8317/v1/models
# Check Amp-specific route
curl http://localhost:8317/api/provider/openai/v1/models
```
**Verify Amp configuration:**
```bash
# Check if Amp is using proxy
amp config get amp.url
# Or check environment
echo $AMP_URL
```
### Security Checklist
- ✅ Keep `amp-restrict-management-to-localhost: true` (default)
- ✅ Don't expose proxy publicly (bind to localhost or use firewall/VPN)
- ✅ Use the Amp secrets file (`~/.local/share/amp/secrets.json`) managed by `amp login`
- ✅ Rotate OAuth tokens periodically by re-running login commands
- ✅ Store config and auth-dir on encrypted disk if handling sensitive data
- ✅ Keep proxy binary up to date for security fixes
## Additional Resources
- [CLIProxyAPI Main Documentation](https://help.router-for.me/)
- [Amp CLI Official Manual](https://ampcode.com/manual)
- [Management API Reference](https://help.router-for.me/management/api)
- [SDK Documentation](sdk-usage.md)
## Disclaimer
This integration is for personal/educational use. Using reverse proxies or alternate API bases may violate provider Terms of Service. You are solely responsible for how you use this software. Accounts may be rate-limited, locked, or banned. No warranties. Use at your own risk.

View File

@@ -1,392 +0,0 @@
# Amp CLI 集成指南
本指南说明如何在 Amp CLI 和 Amp IDE 扩展中使用 CLIProxyAPI通过 OAuth 让你能够把已有的 Google/ChatGPT/Claude 订阅与 Amp 的 CLI 一起使用。
## 目录
- [概述](#概述)
- [应该认证哪些服务提供商?](#应该认证哪些服务提供商)
- [架构](#架构)
- [配置](#配置)
- [设置](#设置)
- [用法](#用法)
- [故障排查](#故障排查)
## 概述
Amp CLI 集成为 Amp 的 API 模式添加了专用路由,同时保持与现有 CLIProxyAPI 功能的完全兼容。这样你可以在同一个代理服务器上同时使用传统 CLIProxyAPI 功能和 Amp CLI。
### 主要特性
- **提供者路由别名**:将 Amp 的 `/api/provider/{provider}/v1...` 路径映射到 CLIProxyAPI 处理器
- **管理代理**:将 OAuth 和账号管理请求转发到 Amp 控制平面
- **智能回退**:自动将未配置的模型路由到 ampcode.com
- **密钥管理**:可配置优先级(配置 > 环境变量 > 文件),缓存 5 分钟
- **安全优先**:管理路由默认限制为 localhost
- **自动 gzip 处理**:自动解压来自 Amp 上游的响应
### 你可以做什么
- 使用 Amp CLI 搭配你的 Google 账号Gemini 3 Pro Preview、Gemini 2.5 Pro、Gemini 2.5 Flash
- 使用 Amp CLI 搭配你的 ChatGPT Plus/Pro 订阅GPT-5、GPT-5 Codex 模型)
- 使用 Amp CLI 搭配你的 Claude Pro/Max 订阅Claude Sonnet 4.5、Opus 4.1
- 将 Amp IDE 扩展VS Code、Cursor、Windsurf 等)与同一个代理一起使用
- 通过一个代理同时运行多个 CLI 工具Factory + Amp
- 将未配置的模型自动路由到 ampcode.com
### 应该认证哪些服务提供商?
**重要**:需要认证的提供商取决于你安装的 Amp 版本当前使用的模型和功能。Amp 的不同智能模式和子代理会使用不同的提供商:
- **Smart 模式**:使用 Google/Gemini 模型Gemini 3 Pro
- **Rush 模式**:使用 Anthropic/Claude 模型Claude Haiku 4.5
- **Oracle 子代理**:使用 OpenAI/GPT 模型GPT-5 medium reasoning
- **Librarian 子代理**:使用 Anthropic/Claude 模型Claude Sonnet 4.5
- **Search 子代理**:使用 Anthropic/Claude 模型Claude Haiku 4.5
- **Review 功能**:使用 Google/Gemini 模型Gemini 2.5 Flash-Lite
有关 Amp 当前使用哪些模型的最新信息,请参阅 **[Amp 模型文档](https://ampcode.com/models)**。
#### 回退行为
CLIProxyAPI 采用智能回退机制:
1. **本地已认证提供商**`--login``--codex-login``--claude-login`
- 请求使用**你的 OAuth 订阅**ChatGPT Plus/Pro、Claude Pro/Max、Google 账号)
- 享受订阅自带的额度
- 不消耗 Amp 额度
2. **本地未认证提供商**
- 请求自动转发到 **ampcode.com**
- 使用 Amp 的后端提供商连接
- 如果提供商是付费的OpenAI、Anthropic 付费档),**需要消耗 Amp 额度**
- 若 Amp 额度不足,可能产生错误
**建议**:对你有订阅的所有提供商都进行认证,以最大化价值并尽量减少 Amp 额度消耗。如果没有覆盖 Amp 使用的全部提供商,请确保为回退请求准备足够的 Amp 额度。
## 架构
### 请求流
```
Amp CLI/IDE
├─ Provider API requests (/api/provider/{provider}/v1/...)
│ ↓
│ ├─ Model configured locally?
│ │ YES → Use local OAuth tokens (OpenAI/Claude/Gemini handlers)
│ │ NO → Forward to ampcode.com (reverse proxy)
│ ↓
│ Response
└─ Management requests (/api/auth, /api/user, /api/threads, ...)
├─ Localhost check (security)
└─ Reverse proxy to ampcode.com
Response (auto-decompressed if gzipped)
```
### 组件
Amp 集成以模块化路由模块(`internal/api/modules/amp/`)实现,包含以下组件:
1. **路由别名**`routes.go`):将 Amp 风格的路径映射到标准处理器
2. **反向代理**`proxy.go`):将管理请求转发到 ampcode.com
3. **回退处理器**`fallback_handlers.go`):将未配置的模型路由到 ampcode.com
4. **密钥管理**`secret.go`):多来源 API 密钥解析并带缓存
5. **主模块**`amp.go`):负责注册和配置
## 配置
### 基础配置
`config.yaml` 中新增以下字段:
```yaml
# Amp 上游控制平面(管理路由必需)
amp-upstream-url: "https://ampcode.com"
# 可选:覆盖 API key否则使用环境变量或文件
# amp-upstream-api-key: "your-amp-api-key"
# 安全性:将管理路由限制为 localhost推荐
amp-restrict-management-to-localhost: true
```
### 密钥解析优先级
Amp 模块以如下优先级解析 API key
| 来源 | 键名 | 优先级 | 缓存 |
|------|------|--------|------|
| 配置文件 | `amp-upstream-api-key` | 高 | 无 |
| 环境变量 | `AMP_API_KEY` | 中 | 无 |
| Amp 密钥文件 | `~/.local/share/amp/secrets.json` | 低 | 5 分钟 |
**建议**:日常使用时采用 Amp 密钥文件(最低优先级)。该文件由 `amp login` 自动管理。
### 安全设置
**`amp-restrict-management-to-localhost`**(默认:`true`
启用后,管理路由(`/api/auth``/api/user``/api/threads` 等)只接受来自 localhost127.0.0.1、::1的连接可防止
- 浏览器探测式攻击
- 对管理端点的远程访问
- 基于 CORS 的攻击
- 伪造头攻击(例如 `X-Forwarded-For: 127.0.0.1`
#### 工作原理
此限制使用**实际的 TCP 连接地址**`RemoteAddr`),而非 `X-Forwarded-For` 等 HTTP 头,能防止头部伪造,但有重要影响:
-**直接连接可用**:在本机或服务器直接运行 CLIProxyAPI 时适用
- ⚠️ **可能不适用于反向代理场景**:部署在 nginx、Cloudflare 等代理后,请求源会显示为代理 IP 而非 localhost
#### 反向代理部署
若需要在反向代理nginx、Caddy、Cloudflare Tunnel 等)后运行 CLIProxyAPI
1. **关闭 localhost 限制**
```yaml
amp-restrict-management-to-localhost: false
```
2. **使用替代安全措施**
- 防火墙规则限制管理路由访问
- 代理层认证HTTP Basic Auth、OAuth
- 网络隔离VPN、Tailscale、Cloudflare Access
- 将 CLIProxyAPI 仅绑定 `127.0.0.1`,并通过 SSH 隧道访问
3. **nginx 示例配置**(阻止外部访问管理路由):
```nginx
location /api/auth { deny all; }
location /api/user { deny all; }
location /api/threads { deny all; }
location /api/internal { deny all; }
```
**重要**:只有在理解安全影响并已采取其他防护措施时,才关闭 `amp-restrict-management-to-localhost`。
## 设置
### 1. 配置 CLIProxyAPI
创建或编辑 `config.yaml`
```yaml
port: 8317
auth-dir: "~/.cli-proxy-api"
# Amp 集成
amp-upstream-url: "https://ampcode.com"
amp-restrict-management-to-localhost: true
# 其他常规设置...
debug: false
logging-to-file: true
```
### 2. 认证提供商
为要使用的提供商执行 OAuth 登录:
**Google 账号Gemini 2.5 Pro、Gemini 2.5 Flash、Gemini 3 Pro Preview**
```bash
./cli-proxy-api --login
```
**ChatGPT Plus/ProGPT-5、GPT-5 Codex**
```bash
./cli-proxy-api --codex-login
```
**Claude Pro/MaxClaude Sonnet 4.5、Opus 4.1**
```bash
./cli-proxy-api --claude-login
```
令牌会保存到:
- Gemini: `~/.cli-proxy-api/gemini-<email>.json`
- OpenAI Codex: `~/.cli-proxy-api/codex-<email>.json`
- Claude: `~/.cli-proxy-api/claude-<email>.json`
### 3. 启动代理
```bash
./cli-proxy-api --config config.yaml
```
或使用 tmux 在后台运行(推荐用于远程服务器):
```bash
tmux new-session -d -s proxy "./cli-proxy-api --config config.yaml"
```
### 4. 配置 Amp CLI
#### 方案 A配置文件
编辑 `~/.config/amp/settings.json`
```json
{
"amp.url": "http://localhost:8317"
}
```
#### 方案 B环境变量
```bash
export AMP_URL=http://localhost:8317
```
### 5. 登录并使用 Amp
通过代理登录(请求会被代理到 ampcode.com
```bash
amp login
```
像平常一样使用 Amp
```bash
amp "Write a hello world program in Python"
```
### 6. (可选)配置 Amp IDE 扩展
该代理同样适用于 VS Code、Cursor、Windsurf 等 Amp IDE 扩展。
1. 在 IDE 中打开 Amp 扩展设置
2. 将 **Amp URL** 设置为 `http://localhost:8317`
3. 用你的 Amp 账号登录
4. 在 IDE 中开始使用 Amp
CLI 和 IDE 可同时使用该代理。
## 用法
### 支持的路由
#### 提供商别名(始终可用)
这些路由即使未配置 `amp-upstream-url` 也可使用:
- `/api/provider/openai/v1/chat/completions`
- `/api/provider/openai/v1/responses`
- `/api/provider/anthropic/v1/messages`
- `/api/provider/google/v1beta/models/:action`
Amp CLI 会使用你在 CLIProxyAPI 中通过 OAuth 认证的模型来调用这些路由。
#### 管理路由(需要 `amp-upstream-url`
这些路由会被代理到 ampcode.com
- `/api/auth` - 认证
- `/api/user` - 用户资料
- `/api/meta` - 元数据
- `/api/threads` - 会话线程
- `/api/telemetry` - 使用遥测
- `/api/internal` - 内部 API
**安全性**:默认限制为 localhost。
### 模型回退行为
当 Amp 请求模型时:
1. **检查本地配置**CLIProxyAPI 是否有该模型提供商的 OAuth 令牌?
2. **如果有**:路由到本地处理器(使用你的 OAuth 订阅)
3. **如果没有**:转发到 ampcode.com使用 Amp 的默认路由)
这实现了无缝混用:
- 你已配置的模型Gemini、ChatGPT、Claude→ 你的 OAuth 订阅
- 未配置的模型 → Amp 的默认提供商
### 示例 API 调用
**使用本地 OAuth 的聊天补全:**
```bash
curl http://localhost:8317/api/provider/openai/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-5",
"messages": [{"role": "user", "content": "Hello"}]
}'
```
**管理端点(仅限 localhost**
```bash
curl http://localhost:8317/api/user
```
## 故障排查
### 常见问题
| 症状 | 可能原因 | 解决方案 |
|------|----------|----------|
| `/api/provider/...` 返回 404 | 路径错误 | 确保路径准确:`/api/provider/{provider}/v1...` |
| `/api/user` 返回 403 | 非 localhost 请求 | 在同一机器上访问,或关闭 `amp-restrict-management-to-localhost`(不推荐) |
| 提供商返回 401/403 | OAuth 缺失或过期 | 重新运行 `--codex-login` 或 `--claude-login` |
| Amp gzip 错误 | 响应解压问题 | 更新到最新构建;自动解压应能处理 |
| 模型未走代理 | Amp URL 设置错误 | 检查 `amp.url` 设置或 `AMP_URL` 环境变量 |
| CORS 错误 | 受保护的管理端点 | 使用 CLI/终端而非浏览器 |
### 诊断
**查看代理日志:**
```bash
# 若 logging-to-file: true
tail -f logs/requests.log
# 若运行在 tmux 中
tmux attach-session -t proxy
```
**临时开启调试模式:**
```yaml
debug: true
```
**测试基础连通性:**
```bash
# 检查代理是否运行
curl http://localhost:8317/v1/models
# 检查 Amp 特定路由
curl http://localhost:8317/api/provider/openai/v1/models
```
**验证 Amp 配置:**
```bash
# 检查 Amp 是否使用代理
amp config get amp.url
# 或检查环境变量
echo $AMP_URL
```
### 安全清单
- ✅ 保持 `amp-restrict-management-to-localhost: true`(默认)
- ✅ 不要将代理暴露到公共网络(绑定到 localhost 或使用防火墙/VPN
- ✅ 使用 `amp login` 管理的 Amp 密钥文件(`~/.local/share/amp/secrets.json`
- ✅ 定期重新登录轮换 OAuth 令牌
- ✅ 若处理敏感数据,使用加密磁盘存储配置和 auth-dir
- ✅ 保持代理二进制为最新版本以获取安全修复
## 其他资源
- [CLIProxyAPI 主文档](https://help.router-for.me/)
- [Amp CLI 官方手册](https://ampcode.com/manual)
- [管理 API 参考](https://help.router-for.me/management/api)
- [SDK 文档](sdk-usage.md)
## 免责声明
此集成仅用于个人或教育用途。使用反向代理或替代 API 基址可能违反提供商的服务条款。你需要对自己的使用方式负责。账号可能会被限速、锁定或封禁。软件不附带任何保证,使用风险自负。

View File

@@ -26,6 +26,7 @@ import (
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/qwen"
"github.com/router-for-me/CLIProxyAPI/v6/internal/interfaces"
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
"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"
@@ -266,6 +267,54 @@ func (h *Handler) ListAuthFiles(c *gin.Context) {
c.JSON(200, gin.H{"files": files})
}
// GetAuthFileModels returns the models supported by a specific auth file
func (h *Handler) GetAuthFileModels(c *gin.Context) {
name := c.Query("name")
if name == "" {
c.JSON(400, gin.H{"error": "name is required"})
return
}
// Try to find auth ID via authManager
var authID string
if h.authManager != nil {
auths := h.authManager.List()
for _, auth := range auths {
if auth.FileName == name || auth.ID == name {
authID = auth.ID
break
}
}
}
if authID == "" {
authID = name // fallback to filename as ID
}
// Get models from registry
reg := registry.GetGlobalRegistry()
models := reg.GetModelsForClient(authID)
result := make([]gin.H, 0, len(models))
for _, m := range models {
entry := gin.H{
"id": m.ID,
}
if m.DisplayName != "" {
entry["display_name"] = m.DisplayName
}
if m.Type != "" {
entry["type"] = m.Type
}
if m.OwnedBy != "" {
entry["owned_by"] = m.OwnedBy
}
result = append(result, entry)
}
c.JSON(200, gin.H{"models": result})
}
// List auth files from disk when the auth manager is unavailable.
func (h *Handler) listAuthFilesFromDisk(c *gin.Context) {
entries, err := os.ReadDir(h.cfg.AuthDir)

View File

@@ -71,22 +71,64 @@ func (w *ResponseWriterWrapper) Write(data []byte) (int, error) {
n, err := w.ResponseWriter.Write(data)
// THEN: Handle logging based on response type
if w.isStreaming {
if w.isStreaming && w.chunkChannel != nil {
// 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
}
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
return n, err
}
if w.shouldBufferResponseBody() {
w.body.Write(data)
}
return n, err
}
func (w *ResponseWriterWrapper) shouldBufferResponseBody() bool {
if w.logger != nil && w.logger.IsEnabled() {
return true
}
if !w.logOnErrorOnly {
return false
}
status := w.statusCode
if status == 0 {
if statusWriter, ok := w.ResponseWriter.(interface{ Status() int }); ok && statusWriter != nil {
status = statusWriter.Status()
} else {
status = http.StatusOK
}
}
return status >= http.StatusBadRequest
}
// WriteString wraps the underlying ResponseWriter's WriteString method to capture response data.
// Some handlers (and fmt/io helpers) write via io.StringWriter; without this override, those writes
// bypass Write() and would be missing from request logs.
func (w *ResponseWriterWrapper) WriteString(data string) (int, error) {
w.ensureHeadersCaptured()
// CRITICAL: Write to client first (zero latency)
n, err := w.ResponseWriter.WriteString(data)
// THEN: Capture for logging
if w.isStreaming && w.chunkChannel != nil {
select {
case w.chunkChannel <- []byte(data):
default:
}
return n, err
}
if w.shouldBufferResponseBody() {
w.body.WriteString(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).
@@ -160,12 +202,16 @@ func (w *ResponseWriterWrapper) detectStreaming(contentType string) bool {
return true
}
// Check request body for streaming indicators
if w.requestInfo.Body != nil {
// If a concrete Content-Type is already set (e.g., application/json for error responses),
// treat it as non-streaming instead of inferring from the request payload.
if strings.TrimSpace(contentType) != "" {
return false
}
// Only fall back to request payload hints when Content-Type is not set yet.
if w.requestInfo != nil && len(w.requestInfo.Body) > 0 {
bodyStr := string(w.requestInfo.Body)
if strings.Contains(bodyStr, `"stream": true`) || strings.Contains(bodyStr, `"stream":true`) {
return true
}
return strings.Contains(bodyStr, `"stream": true`) || strings.Contains(bodyStr, `"stream":true`)
}
return false
@@ -221,7 +267,7 @@ func (w *ResponseWriterWrapper) Finalize(c *gin.Context) error {
return nil
}
if w.isStreaming {
if w.isStreaming && w.streamWriter != nil {
if w.chunkChannel != nil {
close(w.chunkChannel)
w.chunkChannel = nil
@@ -233,24 +279,19 @@ func (w *ResponseWriterWrapper) Finalize(c *gin.Context) error {
}
// Write API Request and Response to the streaming log before closing
if w.streamWriter != nil {
apiRequest := w.extractAPIRequest(c)
if len(apiRequest) > 0 {
_ = w.streamWriter.WriteAPIRequest(apiRequest)
}
apiResponse := w.extractAPIResponse(c)
if len(apiResponse) > 0 {
_ = w.streamWriter.WriteAPIResponse(apiResponse)
}
if err := w.streamWriter.Close(); err != nil {
w.streamWriter = nil
return err
}
apiRequest := w.extractAPIRequest(c)
if len(apiRequest) > 0 {
_ = w.streamWriter.WriteAPIRequest(apiRequest)
}
apiResponse := w.extractAPIResponse(c)
if len(apiResponse) > 0 {
_ = w.streamWriter.WriteAPIResponse(apiResponse)
}
if err := w.streamWriter.Close(); err != nil {
w.streamWriter = nil
return err
}
if forceLog {
return w.logRequest(finalStatusCode, w.cloneHeaders(), w.body.Bytes(), w.extractAPIRequest(c), w.extractAPIResponse(c), slicesAPIResponseError, forceLog)
}
w.streamWriter = nil
return nil
}
@@ -335,26 +376,3 @@ func (w *ResponseWriterWrapper) logRequest(statusCode int, headers map[string][]
apiResponseErrors,
)
}
// 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

@@ -137,7 +137,8 @@ func (m *AmpModule) Register(ctx modules.Context) error {
m.registerProviderAliases(ctx.Engine, ctx.BaseHandler, auth)
// Register management proxy routes once; middleware will gate access when upstream is unavailable.
m.registerManagementRoutes(ctx.Engine, ctx.BaseHandler)
// Pass auth middleware to require valid API key for all management routes.
m.registerManagementRoutes(ctx.Engine, ctx.BaseHandler, auth)
// If no upstream URL, skip proxy routes but provider aliases are still available
if upstreamURL == "" {
@@ -187,9 +188,6 @@ func (m *AmpModule) OnConfigUpdated(cfg *config.Config) error {
if oldSettings != nil && oldSettings.RestrictManagementToLocalhost != newSettings.RestrictManagementToLocalhost {
m.setRestrictToLocalhost(newSettings.RestrictManagementToLocalhost)
if !newSettings.RestrictManagementToLocalhost {
log.Warnf("amp management routes now accessible from any IP - this is insecure!")
}
}
newUpstreamURL := strings.TrimSpace(newSettings.UpstreamURL)

View File

@@ -64,7 +64,7 @@ func logAmpRouting(routeType AmpRouteType, requestedModel, resolvedModel, provid
fields["cost"] = "amp_credits"
fields["source"] = "ampcode.com"
fields["model_id"] = requestedModel // Explicit model_id for easy config reference
log.WithFields(fields).Warnf("forwarding to ampcode.com (uses amp credits) - model_id: %s | To use local proxy, add to config: amp-model-mappings: [{from: \"%s\", to: \"<your-local-model>\"}]", requestedModel, requestedModel)
log.WithFields(fields).Warnf("forwarding to ampcode.com (uses amp credits) - model_id: %s | To use local provider, add to config: ampcode.model-mappings: [{from: \"%s\", to: \"<your-local-model>\"}]", requestedModel, requestedModel)
case RouteTypeNoProvider:
fields["cost"] = "none"

View File

@@ -41,6 +41,11 @@ func createReverseProxy(upstreamURL string, secretSource SecretSource) (*httputi
originalDirector(req)
req.Host = parsed.Host
// Remove client's Authorization header - it was only used for CLI Proxy API authentication
// We will set our own Authorization using the configured upstream-api-key
req.Header.Del("Authorization")
req.Header.Del("X-Api-Key")
// Preserve correlation headers for debugging
if req.Header.Get("X-Request-ID") == "" {
// Could generate one here if needed
@@ -50,7 +55,7 @@ func createReverseProxy(upstreamURL string, secretSource SecretSource) (*httputi
// Users going through ampcode.com proxy are paying for the service and should get all features
// including 1M context window (context-1m-2025-08-07)
// Inject API key from secret source (precedence: config > env > file)
// Inject API key from secret source (only uses upstream-api-key from config)
if key, err := secretSource.Get(req.Context()); err == nil && key != "" {
req.Header.Set("X-Api-Key", key)
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", key))

View File

@@ -39,7 +39,13 @@ func (rw *ResponseRewriter) Write(data []byte) (int, error) {
}
if rw.isStreaming {
return rw.ResponseWriter.Write(rw.rewriteStreamChunk(data))
n, err := rw.ResponseWriter.Write(rw.rewriteStreamChunk(data))
if err == nil {
if flusher, ok := rw.ResponseWriter.(http.Flusher); ok {
flusher.Flush()
}
}
return n, err
}
return rw.body.Write(data)
}

View File

@@ -98,7 +98,8 @@ func (m *AmpModule) managementAvailabilityMiddleware() gin.HandlerFunc {
// registerManagementRoutes registers Amp management proxy routes
// These routes proxy through to the Amp control plane for OAuth, user management, etc.
// Uses dynamic middleware and proxy getter for hot-reload support.
func (m *AmpModule) registerManagementRoutes(engine *gin.Engine, baseHandler *handlers.BaseAPIHandler) {
// The auth middleware validates Authorization header against configured API keys.
func (m *AmpModule) registerManagementRoutes(engine *gin.Engine, baseHandler *handlers.BaseAPIHandler, auth gin.HandlerFunc) {
ampAPI := engine.Group("/api")
// Always disable CORS for management routes to prevent browser-based attacks
@@ -107,8 +108,9 @@ func (m *AmpModule) registerManagementRoutes(engine *gin.Engine, baseHandler *ha
// Apply dynamic localhost-only restriction (hot-reloadable via m.IsRestrictedToLocalhost())
ampAPI.Use(m.localhostOnlyMiddleware())
if !m.IsRestrictedToLocalhost() {
log.Warn("amp management routes are NOT restricted to localhost - this is insecure!")
// Apply authentication middleware - requires valid API key in Authorization header
if auth != nil {
ampAPI.Use(auth)
}
// Dynamic proxy handler that uses m.getProxy() for hot-reload support
@@ -154,6 +156,9 @@ func (m *AmpModule) registerManagementRoutes(engine *gin.Engine, baseHandler *ha
// Root-level routes that AMP CLI expects without /api prefix
// These need the same security middleware as the /api/* routes (dynamic for hot-reload)
rootMiddleware := []gin.HandlerFunc{m.managementAvailabilityMiddleware(), noCORSMiddleware(), m.localhostOnlyMiddleware()}
if auth != nil {
rootMiddleware = append(rootMiddleware, auth)
}
engine.GET("/threads/*path", append(rootMiddleware, proxyHandler)...)
engine.GET("/threads.rss", append(rootMiddleware, proxyHandler)...)
engine.GET("/news.rss", append(rootMiddleware, proxyHandler)...)

View File

@@ -568,6 +568,7 @@ func (s *Server) registerManagementRoutes() {
mgmt.DELETE("/oauth-excluded-models", s.mgmt.DeleteOAuthExcludedModels)
mgmt.GET("/auth-files", s.mgmt.ListAuthFiles)
mgmt.GET("/auth-files/models", s.mgmt.GetAuthFileModels)
mgmt.GET("/auth-files/download", s.mgmt.DownloadAuthFile)
mgmt.POST("/auth-files", s.mgmt.UploadAuthFile)
mgmt.DELETE("/auth-files", s.mgmt.DeleteAuthFile)

View File

@@ -139,7 +139,7 @@ type AmpCode struct {
// RestrictManagementToLocalhost restricts Amp management routes (/api/user, /api/threads, etc.)
// to only accept connections from localhost (127.0.0.1, ::1). When true, prevents drive-by
// browser attacks and remote access to management endpoints. Default: true (recommended).
// browser attacks and remote access to management endpoints. Default: false (API key auth is sufficient).
RestrictManagementToLocalhost bool `yaml:"restrict-management-to-localhost" json:"restrict-management-to-localhost"`
// ModelMappings defines model name mappings for Amp CLI requests.
@@ -327,7 +327,7 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
cfg.LoggingToFile = false
cfg.UsageStatisticsEnabled = false
cfg.DisableCooling = false
cfg.AmpCode.RestrictManagementToLocalhost = true // Default to secure: only localhost access
cfg.AmpCode.RestrictManagementToLocalhost = false // Default to false: API key auth is sufficient
if err = yaml.Unmarshal(data, &cfg); err != nil {
if optional {
// In cloud deploy mode, if YAML parsing fails, return empty config instead of error.

View File

@@ -648,10 +648,11 @@ func GetIFlowModels() []*ModelInfo {
{ID: "glm-4.6", DisplayName: "GLM-4.6", Description: "Zhipu GLM 4.6 general model", Created: 1759190400},
{ID: "kimi-k2", DisplayName: "Kimi-K2", Description: "Moonshot Kimi K2 general model", Created: 1752192000},
{ID: "kimi-k2-thinking", DisplayName: "Kimi-K2-Thinking", Description: "Moonshot Kimi K2 thinking model", Created: 1762387200, Thinking: &ThinkingSupport{Levels: []string{"low", "medium", "high"}}},
{ID: "deepseek-v3.2-chat", DisplayName: "DeepSeek-V3.2", Description: "DeepSeek V3.2", Created: 1764576000},
{ID: "deepseek-v3.2-chat", DisplayName: "DeepSeek-V3.2", Description: "DeepSeek V3.2 Chat", Created: 1764576000},
{ID: "deepseek-v3.2-reasoner", DisplayName: "DeepSeek-V3.2", Description: "DeepSeek V3.2 Reasoner", Created: 1764576000},
{ID: "deepseek-v3.2", DisplayName: "DeepSeek-V3.2-Exp", Description: "DeepSeek V3.2 experimental", Created: 1759104000},
{ID: "deepseek-v3.1", DisplayName: "DeepSeek-V3.1-Terminus", Description: "DeepSeek V3.1 Terminus", Created: 1756339200},
{ID: "deepseek-r1", DisplayName: "DeepSeek-R1", Description: "DeepSeek reasoning model R1", Created: 1737331200, Thinking: &ThinkingSupport{Levels: []string{"low", "medium", "high"}}},
{ID: "deepseek-r1", DisplayName: "DeepSeek-R1", Description: "DeepSeek reasoning model R1", Created: 1737331200},
{ID: "deepseek-v3", DisplayName: "DeepSeek-V3-671B", Description: "DeepSeek V3 671B", Created: 1734307200},
{ID: "qwen3-32b", DisplayName: "Qwen3-32B", Description: "Qwen3 32B", Created: 1747094400},
{ID: "qwen3-235b-a22b-thinking-2507", DisplayName: "Qwen3-235B-A22B-Thinking", Description: "Qwen3 235B A22B Thinking (2507)", Created: 1753401600, Thinking: &ThinkingSupport{Levels: []string{"low", "medium", "high"}}},

View File

@@ -90,6 +90,9 @@ type ModelRegistry struct {
models map[string]*ModelRegistration
// clientModels maps client ID to the models it provides
clientModels map[string][]string
// clientModelInfos maps client ID to a map of model ID -> ModelInfo
// This preserves the original model info provided by each client
clientModelInfos map[string]map[string]*ModelInfo
// clientProviders maps client ID to its provider identifier
clientProviders map[string]string
// mutex ensures thread-safe access to the registry
@@ -104,10 +107,11 @@ var registryOnce sync.Once
func GetGlobalRegistry() *ModelRegistry {
registryOnce.Do(func() {
globalRegistry = &ModelRegistry{
models: make(map[string]*ModelRegistration),
clientModels: make(map[string][]string),
clientProviders: make(map[string]string),
mutex: &sync.RWMutex{},
models: make(map[string]*ModelRegistration),
clientModels: make(map[string][]string),
clientModelInfos: make(map[string]map[string]*ModelInfo),
clientProviders: make(map[string]string),
mutex: &sync.RWMutex{},
}
})
return globalRegistry
@@ -144,6 +148,7 @@ func (r *ModelRegistry) RegisterClient(clientID, clientProvider string, models [
// No models supplied; unregister existing client state if present.
r.unregisterClientInternal(clientID)
delete(r.clientModels, clientID)
delete(r.clientModelInfos, clientID)
delete(r.clientProviders, clientID)
misc.LogCredentialSeparator()
return
@@ -152,7 +157,7 @@ func (r *ModelRegistry) RegisterClient(clientID, clientProvider string, models [
now := time.Now()
oldModels, hadExisting := r.clientModels[clientID]
oldProvider, _ := r.clientProviders[clientID]
oldProvider := r.clientProviders[clientID]
providerChanged := oldProvider != provider
if !hadExisting {
// Pure addition path.
@@ -161,6 +166,12 @@ func (r *ModelRegistry) RegisterClient(clientID, clientProvider string, models [
r.addModelRegistration(modelID, provider, model, now)
}
r.clientModels[clientID] = append([]string(nil), rawModelIDs...)
// Store client's own model infos
clientInfos := make(map[string]*ModelInfo, len(newModels))
for id, m := range newModels {
clientInfos[id] = cloneModelInfo(m)
}
r.clientModelInfos[clientID] = clientInfos
if provider != "" {
r.clientProviders[clientID] = provider
} else {
@@ -287,6 +298,12 @@ func (r *ModelRegistry) RegisterClient(clientID, clientProvider string, models [
if len(rawModelIDs) > 0 {
r.clientModels[clientID] = append([]string(nil), rawModelIDs...)
}
// Update client's own model infos
clientInfos := make(map[string]*ModelInfo, len(newModels))
for id, m := range newModels {
clientInfos[id] = cloneModelInfo(m)
}
r.clientModelInfos[clientID] = clientInfos
if provider != "" {
r.clientProviders[clientID] = provider
} else {
@@ -436,6 +453,7 @@ func (r *ModelRegistry) unregisterClientInternal(clientID string) {
}
delete(r.clientModels, clientID)
delete(r.clientModelInfos, clientID)
if hasProvider {
delete(r.clientProviders, clientID)
}
@@ -871,3 +889,44 @@ func (r *ModelRegistry) GetFirstAvailableModel(handlerType string) (string, erro
return "", fmt.Errorf("no available clients for any model in handler type: %s", handlerType)
}
// GetModelsForClient returns the models registered for a specific client.
// Parameters:
// - clientID: The client identifier (typically auth file name or auth ID)
//
// Returns:
// - []*ModelInfo: List of models registered for this client, nil if client not found
func (r *ModelRegistry) GetModelsForClient(clientID string) []*ModelInfo {
r.mutex.RLock()
defer r.mutex.RUnlock()
modelIDs, exists := r.clientModels[clientID]
if !exists || len(modelIDs) == 0 {
return nil
}
// Try to use client-specific model infos first
clientInfos := r.clientModelInfos[clientID]
seen := make(map[string]struct{})
result := make([]*ModelInfo, 0, len(modelIDs))
for _, modelID := range modelIDs {
if _, dup := seen[modelID]; dup {
continue
}
seen[modelID] = struct{}{}
// Prefer client's own model info to preserve original type/owned_by
if clientInfos != nil {
if info, ok := clientInfos[modelID]; ok && info != nil {
result = append(result, info)
continue
}
}
// Fallback to global registry (for backwards compatibility)
if reg, ok := r.models[modelID]; ok && reg.Info != nil {
result = append(result, reg.Info)
}
}
return result
}

View File

@@ -322,7 +322,7 @@ func (e *AIStudioExecutor) translateRequest(req cliproxyexecutor.Request, opts c
from := opts.SourceFormat
to := sdktranslator.FromString("gemini")
payload := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), stream)
payload = applyThinkingMetadata(payload, req.Metadata, req.Model)
payload = ApplyThinkingMetadata(payload, req.Metadata, req.Model)
payload = util.ApplyDefaultThinkingIfNeeded(req.Model, payload)
payload = util.ConvertThinkingLevelToBudget(payload)
payload = util.NormalizeGeminiThinkingBudget(req.Model, payload)
@@ -384,8 +384,16 @@ func ensureColonSpacedJSON(payload []byte) []byte {
for i := 0; i < len(indented); i++ {
ch := indented[i]
if ch == '"' && (i == 0 || indented[i-1] != '\\') {
inString = !inString
if ch == '"' {
// A quote is escaped only when preceded by an odd number of consecutive backslashes.
// For example: "\\\"" keeps the quote inside the string, but "\\\\" closes the string.
backslashes := 0
for j := i - 1; j >= 0 && indented[j] == '\\'; j-- {
backslashes++
}
if backslashes%2 == 0 {
inString = !inString
}
}
if !inString {

View File

@@ -54,9 +54,9 @@ func (e *CodexExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
from := opts.SourceFormat
to := sdktranslator.FromString("codex")
body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
body = applyReasoningEffortMetadata(body, req.Metadata, req.Model, "reasoning.effort", false)
body = normalizeThinkingConfig(body, upstreamModel, false)
if errValidate := validateThinkingConfig(body, upstreamModel); errValidate != nil {
body = ApplyReasoningEffortMetadata(body, req.Metadata, req.Model, "reasoning.effort", false)
body = NormalizeThinkingConfig(body, upstreamModel, false)
if errValidate := ValidateThinkingConfig(body, upstreamModel); errValidate != nil {
return resp, errValidate
}
body = applyPayloadConfig(e.cfg, req.Model, body)
@@ -152,9 +152,9 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
to := sdktranslator.FromString("codex")
body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
body = applyReasoningEffortMetadata(body, req.Metadata, req.Model, "reasoning.effort", false)
body = normalizeThinkingConfig(body, upstreamModel, false)
if errValidate := validateThinkingConfig(body, upstreamModel); errValidate != nil {
body = ApplyReasoningEffortMetadata(body, req.Metadata, req.Model, "reasoning.effort", false)
body = NormalizeThinkingConfig(body, upstreamModel, false)
if errValidate := ValidateThinkingConfig(body, upstreamModel); errValidate != nil {
return nil, errValidate
}
body = applyPayloadConfig(e.cfg, req.Model, body)
@@ -254,7 +254,7 @@ func (e *CodexExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth
modelForCounting := req.Model
body = applyReasoningEffortMetadata(body, req.Metadata, req.Model, "reasoning.effort", false)
body = ApplyReasoningEffortMetadata(body, req.Metadata, req.Model, "reasoning.effort", false)
body, _ = sjson.SetBytes(body, "model", upstreamModel)
body, _ = sjson.DeleteBytes(body, "previous_response_id")
body, _ = sjson.SetBytes(body, "stream", false)

View File

@@ -11,6 +11,8 @@ import (
"fmt"
"io"
"net/http"
"regexp"
"strconv"
"strings"
"time"
@@ -784,20 +786,45 @@ func parseRetryDelay(errorBody []byte) (*time.Duration, error) {
// Try to parse the retryDelay from the error response
// Format: error.details[].retryDelay where @type == "type.googleapis.com/google.rpc.RetryInfo"
details := gjson.GetBytes(errorBody, "error.details")
if !details.Exists() || !details.IsArray() {
return nil, fmt.Errorf("no error.details found")
if details.Exists() && details.IsArray() {
for _, detail := range details.Array() {
typeVal := detail.Get("@type").String()
if typeVal == "type.googleapis.com/google.rpc.RetryInfo" {
retryDelay := detail.Get("retryDelay").String()
if retryDelay != "" {
// Parse duration string like "0.847655010s"
duration, err := time.ParseDuration(retryDelay)
if err != nil {
return nil, fmt.Errorf("failed to parse duration")
}
return &duration, nil
}
}
}
// Fallback: try ErrorInfo.metadata.quotaResetDelay (e.g., "373.801628ms")
for _, detail := range details.Array() {
typeVal := detail.Get("@type").String()
if typeVal == "type.googleapis.com/google.rpc.ErrorInfo" {
quotaResetDelay := detail.Get("metadata.quotaResetDelay").String()
if quotaResetDelay != "" {
duration, err := time.ParseDuration(quotaResetDelay)
if err == nil {
return &duration, nil
}
}
}
}
}
for _, detail := range details.Array() {
typeVal := detail.Get("@type").String()
if typeVal == "type.googleapis.com/google.rpc.RetryInfo" {
retryDelay := detail.Get("retryDelay").String()
if retryDelay != "" {
// Parse duration string like "0.847655010s"
duration, err := time.ParseDuration(retryDelay)
if err != nil {
return nil, fmt.Errorf("failed to parse duration")
}
// Fallback: parse from error.message "Your quota will reset after Xs."
message := gjson.GetBytes(errorBody, "error.message").String()
if message != "" {
re := regexp.MustCompile(`after\s+(\d+)s\.?`)
if matches := re.FindStringSubmatch(message); len(matches) > 1 {
seconds, err := strconv.Atoi(matches[1])
if err == nil {
duration := time.Duration(seconds) * time.Second
return &duration, nil
}
}

View File

@@ -83,7 +83,7 @@ func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
from := opts.SourceFormat
to := sdktranslator.FromString("gemini")
body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
body = applyThinkingMetadata(body, req.Metadata, req.Model)
body = ApplyThinkingMetadata(body, req.Metadata, req.Model)
body = util.ApplyDefaultThinkingIfNeeded(req.Model, body)
body = util.NormalizeGeminiThinkingBudget(req.Model, body)
body = util.StripThinkingConfigIfUnsupported(req.Model, body)
@@ -178,7 +178,7 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
from := opts.SourceFormat
to := sdktranslator.FromString("gemini")
body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
body = applyThinkingMetadata(body, req.Metadata, req.Model)
body = ApplyThinkingMetadata(body, req.Metadata, req.Model)
body = util.ApplyDefaultThinkingIfNeeded(req.Model, body)
body = util.NormalizeGeminiThinkingBudget(req.Model, body)
body = util.StripThinkingConfigIfUnsupported(req.Model, body)
@@ -290,7 +290,7 @@ func (e *GeminiExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut
from := opts.SourceFormat
to := sdktranslator.FromString("gemini")
translatedReq := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
translatedReq = applyThinkingMetadata(translatedReq, req.Metadata, req.Model)
translatedReq = ApplyThinkingMetadata(translatedReq, req.Metadata, req.Model)
translatedReq = util.StripThinkingConfigIfUnsupported(req.Model, translatedReq)
translatedReq = fixGeminiImageAspectRatio(req.Model, translatedReq)
respCtx := context.WithValue(ctx, "alt", opts.Alt)

View File

@@ -57,13 +57,13 @@ func (e *IFlowExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
from := opts.SourceFormat
to := sdktranslator.FromString("openai")
body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
body = applyReasoningEffortMetadata(body, req.Metadata, req.Model, "reasoning_effort", false)
body = ApplyReasoningEffortMetadata(body, req.Metadata, req.Model, "reasoning_effort", false)
upstreamModel := util.ResolveOriginalModel(req.Model, req.Metadata)
if upstreamModel != "" {
body, _ = sjson.SetBytes(body, "model", upstreamModel)
}
body = normalizeThinkingConfig(body, upstreamModel, false)
if errValidate := validateThinkingConfig(body, upstreamModel); errValidate != nil {
body = NormalizeThinkingConfig(body, upstreamModel, false)
if errValidate := ValidateThinkingConfig(body, upstreamModel); errValidate != nil {
return resp, errValidate
}
body = applyPayloadConfig(e.cfg, req.Model, body)
@@ -148,13 +148,13 @@ func (e *IFlowExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
to := sdktranslator.FromString("openai")
body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
body = applyReasoningEffortMetadata(body, req.Metadata, req.Model, "reasoning_effort", false)
body = ApplyReasoningEffortMetadata(body, req.Metadata, req.Model, "reasoning_effort", false)
upstreamModel := util.ResolveOriginalModel(req.Model, req.Metadata)
if upstreamModel != "" {
body, _ = sjson.SetBytes(body, "model", upstreamModel)
}
body = normalizeThinkingConfig(body, upstreamModel, false)
if errValidate := validateThinkingConfig(body, upstreamModel); errValidate != nil {
body = NormalizeThinkingConfig(body, upstreamModel, false)
if errValidate := ValidateThinkingConfig(body, upstreamModel); errValidate != nil {
return nil, errValidate
}
// Ensure tools array exists to avoid provider quirks similar to Qwen's behaviour.

View File

@@ -60,13 +60,13 @@ func (e *OpenAICompatExecutor) Execute(ctx context.Context, auth *cliproxyauth.A
}
translated = applyPayloadConfigWithRoot(e.cfg, req.Model, to.String(), "", translated)
allowCompat := e.allowCompatReasoningEffort(req.Model, auth)
translated = applyReasoningEffortMetadata(translated, req.Metadata, req.Model, "reasoning_effort", allowCompat)
translated = ApplyReasoningEffortMetadata(translated, req.Metadata, req.Model, "reasoning_effort", allowCompat)
upstreamModel := util.ResolveOriginalModel(req.Model, req.Metadata)
if upstreamModel != "" && modelOverride == "" {
translated, _ = sjson.SetBytes(translated, "model", upstreamModel)
}
translated = normalizeThinkingConfig(translated, upstreamModel, allowCompat)
if errValidate := validateThinkingConfig(translated, upstreamModel); errValidate != nil {
translated = NormalizeThinkingConfig(translated, upstreamModel, allowCompat)
if errValidate := ValidateThinkingConfig(translated, upstreamModel); errValidate != nil {
return resp, errValidate
}
@@ -156,13 +156,13 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy
}
translated = applyPayloadConfigWithRoot(e.cfg, req.Model, to.String(), "", translated)
allowCompat := e.allowCompatReasoningEffort(req.Model, auth)
translated = applyReasoningEffortMetadata(translated, req.Metadata, req.Model, "reasoning_effort", allowCompat)
translated = ApplyReasoningEffortMetadata(translated, req.Metadata, req.Model, "reasoning_effort", allowCompat)
upstreamModel := util.ResolveOriginalModel(req.Model, req.Metadata)
if upstreamModel != "" && modelOverride == "" {
translated, _ = sjson.SetBytes(translated, "model", upstreamModel)
}
translated = normalizeThinkingConfig(translated, upstreamModel, allowCompat)
if errValidate := validateThinkingConfig(translated, upstreamModel); errValidate != nil {
translated = NormalizeThinkingConfig(translated, upstreamModel, allowCompat)
if errValidate := ValidateThinkingConfig(translated, upstreamModel); errValidate != nil {
return nil, errValidate
}

View File

@@ -11,9 +11,9 @@ import (
"github.com/tidwall/sjson"
)
// applyThinkingMetadata applies thinking config from model suffix metadata (e.g., (high), (8192))
// ApplyThinkingMetadata applies thinking config from model suffix metadata (e.g., (high), (8192))
// for standard Gemini format payloads. It normalizes the budget when the model supports thinking.
func applyThinkingMetadata(payload []byte, metadata map[string]any, model string) []byte {
func ApplyThinkingMetadata(payload []byte, metadata map[string]any, model string) []byte {
budgetOverride, includeOverride, ok := util.ResolveThinkingConfigFromMetadata(model, metadata)
if !ok || (budgetOverride == nil && includeOverride == nil) {
return payload
@@ -45,10 +45,10 @@ func applyThinkingMetadataCLI(payload []byte, metadata map[string]any, model str
return util.ApplyGeminiCLIThinkingConfig(payload, budgetOverride, includeOverride)
}
// applyReasoningEffortMetadata applies reasoning effort overrides from metadata to the given JSON path.
// ApplyReasoningEffortMetadata applies reasoning effort overrides from metadata to the given JSON path.
// Metadata values take precedence over any existing field when the model supports thinking, intentionally
// overwriting caller-provided values to honor suffix/default metadata priority.
func applyReasoningEffortMetadata(payload []byte, metadata map[string]any, model, field string, allowCompat bool) []byte {
func ApplyReasoningEffortMetadata(payload []byte, metadata map[string]any, model, field string, allowCompat bool) []byte {
if len(metadata) == 0 {
return payload
}
@@ -73,6 +73,12 @@ func applyReasoningEffortMetadata(payload []byte, metadata map[string]any, model
if util.ModelUsesThinkingLevels(baseModel) || allowCompat {
if budget, _, _, matched := util.ThinkingFromMetadata(metadata); matched && budget != nil {
if effort, ok := util.OpenAIThinkingBudgetToEffort(baseModel, *budget); ok && effort != "" {
if *budget == 0 && effort == "none" && util.ModelUsesThinkingLevels(baseModel) {
if _, supported := util.NormalizeReasoningEffortLevel(baseModel, effort); !supported {
return StripThinkingFields(payload, false)
}
}
if updated, err := sjson.SetBytes(payload, field, effort); err == nil {
return updated
}
@@ -232,12 +238,12 @@ func matchModelPattern(pattern, model string) bool {
return pi == len(pattern)
}
// normalizeThinkingConfig normalizes thinking-related fields in the payload
// NormalizeThinkingConfig normalizes thinking-related fields in the payload
// based on model capabilities. For models without thinking support, it strips
// reasoning fields. For models with level-based thinking, it validates and
// normalizes the reasoning effort level. For models with numeric budget thinking,
// it strips the effort string fields.
func normalizeThinkingConfig(payload []byte, model string, allowCompat bool) []byte {
func NormalizeThinkingConfig(payload []byte, model string, allowCompat bool) []byte {
if len(payload) == 0 || model == "" {
return payload
}
@@ -246,22 +252,22 @@ func normalizeThinkingConfig(payload []byte, model string, allowCompat bool) []b
if allowCompat {
return payload
}
return stripThinkingFields(payload, false)
return StripThinkingFields(payload, false)
}
if util.ModelUsesThinkingLevels(model) {
return normalizeReasoningEffortLevel(payload, model)
return NormalizeReasoningEffortLevel(payload, model)
}
// Model supports thinking but uses numeric budgets, not levels.
// Strip effort string fields since they are not applicable.
return stripThinkingFields(payload, true)
return StripThinkingFields(payload, true)
}
// stripThinkingFields removes thinking-related fields from the payload for
// StripThinkingFields removes thinking-related fields from the payload for
// models that do not support thinking. If effortOnly is true, only removes
// effort string fields (for models using numeric budgets).
func stripThinkingFields(payload []byte, effortOnly bool) []byte {
func StripThinkingFields(payload []byte, effortOnly bool) []byte {
fieldsToRemove := []string{
"reasoning_effort",
"reasoning.effort",
@@ -278,9 +284,9 @@ func stripThinkingFields(payload []byte, effortOnly bool) []byte {
return out
}
// normalizeReasoningEffortLevel validates and normalizes the reasoning_effort
// NormalizeReasoningEffortLevel validates and normalizes the reasoning_effort
// or reasoning.effort field for level-based thinking models.
func normalizeReasoningEffortLevel(payload []byte, model string) []byte {
func NormalizeReasoningEffortLevel(payload []byte, model string) []byte {
out := payload
if effort := gjson.GetBytes(out, "reasoning_effort"); effort.Exists() {
@@ -298,10 +304,10 @@ func normalizeReasoningEffortLevel(payload []byte, model string) []byte {
return out
}
// validateThinkingConfig checks for unsupported reasoning levels on level-based models.
// ValidateThinkingConfig checks for unsupported reasoning levels on level-based models.
// Returns a statusErr with 400 when an unsupported level is supplied to avoid silently
// downgrading requests.
func validateThinkingConfig(payload []byte, model string) error {
func ValidateThinkingConfig(payload []byte, model string) error {
if len(payload) == 0 || model == "" {
return nil
}

View File

@@ -51,13 +51,13 @@ func (e *QwenExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req
from := opts.SourceFormat
to := sdktranslator.FromString("openai")
body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
body = applyReasoningEffortMetadata(body, req.Metadata, req.Model, "reasoning_effort", false)
body = ApplyReasoningEffortMetadata(body, req.Metadata, req.Model, "reasoning_effort", false)
upstreamModel := util.ResolveOriginalModel(req.Model, req.Metadata)
if upstreamModel != "" {
body, _ = sjson.SetBytes(body, "model", upstreamModel)
}
body = normalizeThinkingConfig(body, upstreamModel, false)
if errValidate := validateThinkingConfig(body, upstreamModel); errValidate != nil {
body = NormalizeThinkingConfig(body, upstreamModel, false)
if errValidate := ValidateThinkingConfig(body, upstreamModel); errValidate != nil {
return resp, errValidate
}
body = applyPayloadConfig(e.cfg, req.Model, body)
@@ -131,13 +131,13 @@ func (e *QwenExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut
to := sdktranslator.FromString("openai")
body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
body = applyReasoningEffortMetadata(body, req.Metadata, req.Model, "reasoning_effort", false)
body = ApplyReasoningEffortMetadata(body, req.Metadata, req.Model, "reasoning_effort", false)
upstreamModel := util.ResolveOriginalModel(req.Model, req.Metadata)
if upstreamModel != "" {
body, _ = sjson.SetBytes(body, "model", upstreamModel)
}
body = normalizeThinkingConfig(body, upstreamModel, false)
if errValidate := validateThinkingConfig(body, upstreamModel); errValidate != nil {
body = NormalizeThinkingConfig(body, upstreamModel, false)
if errValidate := ValidateThinkingConfig(body, upstreamModel); errValidate != nil {
return nil, errValidate
}
toolsResult := gjson.GetBytes(body, "tools")

View File

@@ -122,6 +122,38 @@ type FunctionCallGroup struct {
ResponsesNeeded int
}
// parseFunctionResponse attempts to unmarshal a function response part.
// Falls back to gjson extraction if standard json.Unmarshal fails.
func parseFunctionResponse(response gjson.Result) map[string]interface{} {
var responseMap map[string]interface{}
err := json.Unmarshal([]byte(response.Raw), &responseMap)
if err == nil {
return responseMap
}
log.Debugf("unmarshal function response failed, using fallback: %v", err)
funcResp := response.Get("functionResponse")
if funcResp.Exists() {
fr := map[string]interface{}{
"name": funcResp.Get("name").String(),
"response": map[string]interface{}{
"result": funcResp.Get("response").String(),
},
}
if id := funcResp.Get("id").String(); id != "" {
fr["id"] = id
}
return map[string]interface{}{"functionResponse": fr}
}
return map[string]interface{}{
"functionResponse": map[string]interface{}{
"name": "unknown",
"response": map[string]interface{}{"result": response.String()},
},
}
}
// 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.
@@ -180,13 +212,7 @@ func fixCLIToolResponse(input string) (string, error) {
// 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)
responseParts = append(responseParts, parseFunctionResponse(response))
}
if len(responseParts) > 0 {
@@ -265,13 +291,7 @@ func fixCLIToolResponse(input string) (string, error) {
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)
responseParts = append(responseParts, parseFunctionResponse(response))
}
if len(responseParts) > 0 {

View File

@@ -39,31 +39,13 @@ func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _
// Note: OpenAI official fields take precedence over extra_body.google.thinking_config
re := gjson.GetBytes(rawJSON, "reasoning_effort")
hasOfficialThinking := re.Exists()
if hasOfficialThinking && util.ModelSupportsThinking(modelName) {
switch re.String() {
case "none":
out, _ = sjson.DeleteBytes(out, "request.generationConfig.thinkingConfig.include_thoughts")
out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.thinkingBudget", 0)
case "auto":
out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.thinkingBudget", -1)
out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.include_thoughts", true)
case "low":
out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.thinkingBudget", 1024)
out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.include_thoughts", true)
case "medium":
out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.thinkingBudget", 8192)
out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.include_thoughts", true)
case "high":
out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.thinkingBudget", 32768)
out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.include_thoughts", true)
default:
out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.thinkingBudget", -1)
out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.include_thoughts", true)
}
if hasOfficialThinking && util.ModelSupportsThinking(modelName) && !util.ModelUsesThinkingLevels(modelName) {
out = util.ApplyReasoningEffortToGeminiCLI(out, re.String())
}
// Cherry Studio extension extra_body.google.thinking_config (effective only when official fields are absent)
if !hasOfficialThinking && util.ModelSupportsThinking(modelName) {
// Only apply for models that use numeric budgets, not discrete levels.
if !hasOfficialThinking && util.ModelSupportsThinking(modelName) && !util.ModelUsesThinkingLevels(modelName) {
if tc := gjson.GetBytes(rawJSON, "extra_body.google.thinking_config"); tc.Exists() && tc.IsObject() {
var setBudget bool
var budget int

View File

@@ -114,14 +114,16 @@ func ConvertGeminiRequestToClaude(modelName string, inputRawJSON []byte, stream
}
}
// Include thoughts configuration for reasoning process visibility
if thinkingConfig := genConfig.Get("thinkingConfig"); thinkingConfig.Exists() && thinkingConfig.IsObject() {
if includeThoughts := thinkingConfig.Get("include_thoughts"); includeThoughts.Exists() {
if includeThoughts.Type == gjson.True {
out, _ = sjson.Set(out, "thinking.type", "enabled")
if thinkingBudget := thinkingConfig.Get("thinkingBudget"); thinkingBudget.Exists() {
out, _ = sjson.Set(out, "thinking.budget_tokens", thinkingBudget.Int())
}
}
// Only apply for models that support thinking and use numeric budgets, not discrete levels.
if thinkingConfig := genConfig.Get("thinkingConfig"); thinkingConfig.Exists() && thinkingConfig.IsObject() && util.ModelSupportsThinking(modelName) && !util.ModelUsesThinkingLevels(modelName) {
// Check for thinkingBudget first - if present, enable thinking with budget
if thinkingBudget := thinkingConfig.Get("thinkingBudget"); thinkingBudget.Exists() && thinkingBudget.Int() > 0 {
out, _ = sjson.Set(out, "thinking.type", "enabled")
normalizedBudget := util.NormalizeThinkingBudget(modelName, int(thinkingBudget.Int()))
out, _ = sjson.Set(out, "thinking.budget_tokens", normalizedBudget)
} else if includeThoughts := thinkingConfig.Get("include_thoughts"); includeThoughts.Exists() && includeThoughts.Type == gjson.True {
// Fallback to include_thoughts if no budget specified
out, _ = sjson.Set(out, "thinking.type", "enabled")
}
}
}

View File

@@ -16,6 +16,7 @@ import (
"strings"
"github.com/google/uuid"
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
@@ -65,18 +66,23 @@ func ConvertOpenAIRequestToClaude(modelName string, inputRawJSON []byte, stream
root := gjson.ParseBytes(rawJSON)
if v := root.Get("reasoning_effort"); v.Exists() {
out, _ = sjson.Set(out, "thinking.type", "enabled")
switch v.String() {
case "none":
out, _ = sjson.Set(out, "thinking.type", "disabled")
case "low":
out, _ = sjson.Set(out, "thinking.budget_tokens", 1024)
case "medium":
out, _ = sjson.Set(out, "thinking.budget_tokens", 8192)
case "high":
out, _ = sjson.Set(out, "thinking.budget_tokens", 24576)
if v := root.Get("reasoning_effort"); v.Exists() && util.ModelSupportsThinking(modelName) && !util.ModelUsesThinkingLevels(modelName) {
effort := strings.ToLower(strings.TrimSpace(v.String()))
if effort != "" {
budget, ok := util.ThinkingEffortToBudget(modelName, effort)
if ok {
switch budget {
case 0:
out, _ = sjson.Set(out, "thinking.type", "disabled")
case -1:
out, _ = sjson.Set(out, "thinking.type", "enabled")
default:
if budget > 0 {
out, _ = sjson.Set(out, "thinking.type", "enabled")
out, _ = sjson.Set(out, "thinking.budget_tokens", budget)
}
}
}
}
}

View File

@@ -10,6 +10,7 @@ import (
"strings"
"github.com/google/uuid"
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
@@ -52,20 +53,23 @@ func ConvertOpenAIResponsesRequestToClaude(modelName string, inputRawJSON []byte
root := gjson.ParseBytes(rawJSON)
if v := root.Get("reasoning.effort"); v.Exists() {
out, _ = sjson.Set(out, "thinking.type", "enabled")
switch v.String() {
case "none":
out, _ = sjson.Set(out, "thinking.type", "disabled")
case "minimal":
out, _ = sjson.Set(out, "thinking.budget_tokens", 1024)
case "low":
out, _ = sjson.Set(out, "thinking.budget_tokens", 4096)
case "medium":
out, _ = sjson.Set(out, "thinking.budget_tokens", 8192)
case "high":
out, _ = sjson.Set(out, "thinking.budget_tokens", 24576)
if v := root.Get("reasoning.effort"); v.Exists() && util.ModelSupportsThinking(modelName) && !util.ModelUsesThinkingLevels(modelName) {
effort := strings.ToLower(strings.TrimSpace(v.String()))
if effort != "" {
budget, ok := util.ThinkingEffortToBudget(modelName, effort)
if ok {
switch budget {
case 0:
out, _ = sjson.Set(out, "thinking.type", "disabled")
case -1:
out, _ = sjson.Set(out, "thinking.type", "enabled")
default:
if budget > 0 {
out, _ = sjson.Set(out, "thinking.type", "enabled")
out, _ = sjson.Set(out, "thinking.budget_tokens", budget)
}
}
}
}
}

View File

@@ -12,6 +12,7 @@ import (
"strings"
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
@@ -214,7 +215,22 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
// Add additional configuration parameters for the Codex API.
template, _ = sjson.Set(template, "parallel_tool_calls", true)
template, _ = sjson.Set(template, "reasoning.effort", "medium")
// Convert thinking.budget_tokens to reasoning.effort for level-based models
reasoningEffort := "medium" // default
if thinking := rootResult.Get("thinking"); thinking.Exists() && thinking.IsObject() {
if thinking.Get("type").String() == "enabled" {
if util.ModelUsesThinkingLevels(modelName) {
if budgetTokens := thinking.Get("budget_tokens"); budgetTokens.Exists() {
budget := int(budgetTokens.Int())
if effort, ok := util.OpenAIThinkingBudgetToEffort(modelName, budget); ok && effort != "" {
reasoningEffort = effort
}
}
}
}
}
template, _ = sjson.Set(template, "reasoning.effort", reasoningEffort)
template, _ = sjson.Set(template, "reasoning.summary", "auto")
template, _ = sjson.Set(template, "stream", true)
template, _ = sjson.Set(template, "store", false)

View File

@@ -245,7 +245,22 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
// Fixed flags aligning with Codex expectations
out, _ = sjson.Set(out, "parallel_tool_calls", true)
out, _ = sjson.Set(out, "reasoning.effort", "medium")
// Convert thinkingBudget to reasoning.effort for level-based models
reasoningEffort := "medium" // default
if genConfig := root.Get("generationConfig"); genConfig.Exists() {
if thinkingConfig := genConfig.Get("thinkingConfig"); thinkingConfig.Exists() && thinkingConfig.IsObject() {
if util.ModelUsesThinkingLevels(modelName) {
if thinkingBudget := thinkingConfig.Get("thinkingBudget"); thinkingBudget.Exists() {
budget := int(thinkingBudget.Int())
if effort, ok := util.OpenAIThinkingBudgetToEffort(modelName, budget); ok && effort != "" {
reasoningEffort = effort
}
}
}
}
}
out, _ = sjson.Set(out, "reasoning.effort", reasoningEffort)
out, _ = sjson.Set(out, "reasoning.summary", "auto")
out, _ = sjson.Set(out, "stream", true)
out, _ = sjson.Set(out, "store", false)

View File

@@ -39,31 +39,13 @@ func ConvertOpenAIRequestToGeminiCLI(modelName string, inputRawJSON []byte, _ bo
// Note: OpenAI official fields take precedence over extra_body.google.thinking_config
re := gjson.GetBytes(rawJSON, "reasoning_effort")
hasOfficialThinking := re.Exists()
if hasOfficialThinking && util.ModelSupportsThinking(modelName) {
switch re.String() {
case "none":
out, _ = sjson.DeleteBytes(out, "request.generationConfig.thinkingConfig.include_thoughts")
out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.thinkingBudget", 0)
case "auto":
out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.thinkingBudget", -1)
out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.include_thoughts", true)
case "low":
out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.thinkingBudget", 1024)
out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.include_thoughts", true)
case "medium":
out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.thinkingBudget", 8192)
out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.include_thoughts", true)
case "high":
out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.thinkingBudget", 32768)
out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.include_thoughts", true)
default:
out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.thinkingBudget", -1)
out, _ = sjson.SetBytes(out, "request.generationConfig.thinkingConfig.include_thoughts", true)
}
if hasOfficialThinking && util.ModelSupportsThinking(modelName) && !util.ModelUsesThinkingLevels(modelName) {
out = util.ApplyReasoningEffortToGeminiCLI(out, re.String())
}
// Cherry Studio extension extra_body.google.thinking_config (effective only when official fields are absent)
if !hasOfficialThinking && util.ModelSupportsThinking(modelName) {
// Only apply for models that use numeric budgets, not discrete levels.
if !hasOfficialThinking && util.ModelSupportsThinking(modelName) && !util.ModelUsesThinkingLevels(modelName) {
if tc := gjson.GetBytes(rawJSON, "extra_body.google.thinking_config"); tc.Exists() && tc.IsObject() {
var setBudget bool
var budget int

View File

@@ -154,7 +154,8 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
}
// Map Anthropic thinking -> Gemini thinkingBudget/include_thoughts when enabled
if t := gjson.GetBytes(rawJSON, "thinking"); t.Exists() && t.IsObject() && util.ModelSupportsThinking(modelName) {
// Only apply for models that use numeric budgets, not discrete levels.
if t := gjson.GetBytes(rawJSON, "thinking"); t.Exists() && t.IsObject() && util.ModelSupportsThinking(modelName) && !util.ModelUsesThinkingLevels(modelName) {
if t.Get("type").String() == "enabled" {
if b := t.Get("budget_tokens"); b.Exists() && b.Type == gjson.Number {
budget := int(b.Int())

View File

@@ -37,33 +37,17 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
// Reasoning effort -> thinkingBudget/include_thoughts
// Note: OpenAI official fields take precedence over extra_body.google.thinking_config
// Only convert for models that use numeric budgets (not discrete levels) to avoid
// incorrectly applying thinkingBudget for level-based models like gpt-5.
re := gjson.GetBytes(rawJSON, "reasoning_effort")
hasOfficialThinking := re.Exists()
if hasOfficialThinking && util.ModelSupportsThinking(modelName) {
switch re.String() {
case "none":
out, _ = sjson.DeleteBytes(out, "generationConfig.thinkingConfig.include_thoughts")
out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.thinkingBudget", 0)
case "auto":
out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.thinkingBudget", -1)
out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.include_thoughts", true)
case "low":
out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.thinkingBudget", 1024)
out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.include_thoughts", true)
case "medium":
out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.thinkingBudget", 8192)
out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.include_thoughts", true)
case "high":
out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.thinkingBudget", 32768)
out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.include_thoughts", true)
default:
out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.thinkingBudget", -1)
out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.include_thoughts", true)
}
if hasOfficialThinking && util.ModelSupportsThinking(modelName) && !util.ModelUsesThinkingLevels(modelName) {
out = util.ApplyReasoningEffortToGemini(out, re.String())
}
// Cherry Studio extension extra_body.google.thinking_config (effective only when official fields are absent)
if !hasOfficialThinking && util.ModelSupportsThinking(modelName) {
// Only apply for models that use numeric budgets, not discrete levels.
if !hasOfficialThinking && util.ModelSupportsThinking(modelName) && !util.ModelUsesThinkingLevels(modelName) {
if tc := gjson.GetBytes(rawJSON, "extra_body.google.thinking_config"); tc.Exists() && tc.IsObject() {
var setBudget bool
var budget int

View File

@@ -389,36 +389,16 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte
}
// OpenAI official reasoning fields take precedence
// Only convert for models that use numeric budgets (not discrete levels).
hasOfficialThinking := root.Get("reasoning.effort").Exists()
if hasOfficialThinking && util.ModelSupportsThinking(modelName) {
if hasOfficialThinking && util.ModelSupportsThinking(modelName) && !util.ModelUsesThinkingLevels(modelName) {
reasoningEffort := root.Get("reasoning.effort")
switch reasoningEffort.String() {
case "none":
out, _ = sjson.Set(out, "generationConfig.thinkingConfig.include_thoughts", false)
out, _ = sjson.Set(out, "generationConfig.thinkingConfig.thinkingBudget", 0)
case "auto":
out, _ = sjson.Set(out, "generationConfig.thinkingConfig.thinkingBudget", -1)
out, _ = sjson.Set(out, "generationConfig.thinkingConfig.include_thoughts", true)
case "minimal":
out, _ = sjson.Set(out, "generationConfig.thinkingConfig.thinkingBudget", 1024)
out, _ = sjson.Set(out, "generationConfig.thinkingConfig.include_thoughts", true)
case "low":
out, _ = sjson.Set(out, "generationConfig.thinkingConfig.thinkingBudget", 4096)
out, _ = sjson.Set(out, "generationConfig.thinkingConfig.include_thoughts", true)
case "medium":
out, _ = sjson.Set(out, "generationConfig.thinkingConfig.thinkingBudget", 8192)
out, _ = sjson.Set(out, "generationConfig.thinkingConfig.include_thoughts", true)
case "high":
out, _ = sjson.Set(out, "generationConfig.thinkingConfig.thinkingBudget", 32768)
out, _ = sjson.Set(out, "generationConfig.thinkingConfig.include_thoughts", true)
default:
out, _ = sjson.Set(out, "generationConfig.thinkingConfig.thinkingBudget", -1)
out, _ = sjson.Set(out, "generationConfig.thinkingConfig.include_thoughts", true)
}
out = string(util.ApplyReasoningEffortToGemini([]byte(out), reasoningEffort.String()))
}
// Cherry Studio extension (applies only when official fields are missing)
if !hasOfficialThinking && util.ModelSupportsThinking(modelName) {
// Only apply for models that use numeric budgets, not discrete levels.
if !hasOfficialThinking && util.ModelSupportsThinking(modelName) && !util.ModelUsesThinkingLevels(modelName) {
if tc := root.Get("extra_body.google.thinking_config"); tc.Exists() && tc.IsObject() {
var setBudget bool
var budget int

View File

@@ -10,6 +10,7 @@ import (
"encoding/json"
"strings"
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
@@ -60,6 +61,18 @@ func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream
// Stream
out, _ = sjson.Set(out, "stream", stream)
// Thinking: Convert Claude thinking.budget_tokens to OpenAI reasoning_effort
if thinking := root.Get("thinking"); thinking.Exists() && thinking.IsObject() {
if thinkingType := thinking.Get("type"); thinkingType.Exists() && thinkingType.String() == "enabled" {
if budgetTokens := thinking.Get("budget_tokens"); budgetTokens.Exists() {
budget := int(budgetTokens.Int())
if effort, ok := util.OpenAIThinkingBudgetToEffort(modelName, budget); ok && effort != "" {
out, _ = sjson.Set(out, "reasoning_effort", effort)
}
}
}
}
// Process messages and system
var messagesJSON = "[]"

View File

@@ -13,6 +13,7 @@ import (
"math/big"
"strings"
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
@@ -76,6 +77,17 @@ func ConvertGeminiRequestToOpenAI(modelName string, inputRawJSON []byte, stream
out, _ = sjson.Set(out, "stop", stops)
}
}
// Convert thinkingBudget to reasoning_effort
// Always perform conversion to support allowCompat models that may not be in registry
if thinkingConfig := genConfig.Get("thinkingConfig"); thinkingConfig.Exists() && thinkingConfig.IsObject() {
if thinkingBudget := thinkingConfig.Get("thinkingBudget"); thinkingBudget.Exists() {
budget := int(thinkingBudget.Int())
if effort, ok := util.OpenAIThinkingBudgetToEffort(modelName, budget); ok && effort != "" {
out, _ = sjson.Set(out, "reasoning_effort", effort)
}
}
}
}
// Stream parameter

View File

@@ -2,6 +2,7 @@ package responses
import (
"bytes"
"strings"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
@@ -64,7 +65,7 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu
}
switch itemType {
case "message":
case "message", "":
// Handle regular message conversion
role := item.Get("role").String()
message := `{"role":"","content":""}`
@@ -106,6 +107,8 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu
if len(toolCalls) > 0 {
message, _ = sjson.Set(message, "tool_calls", toolCalls)
}
} else if content.Type == gjson.String {
message, _ = sjson.Set(message, "content", content.String())
}
out, _ = sjson.SetRaw(out, "messages.-1", message)
@@ -189,23 +192,9 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu
}
if reasoningEffort := root.Get("reasoning.effort"); reasoningEffort.Exists() {
switch reasoningEffort.String() {
case "none":
out, _ = sjson.Set(out, "reasoning_effort", "none")
case "auto":
out, _ = sjson.Set(out, "reasoning_effort", "auto")
case "minimal":
out, _ = sjson.Set(out, "reasoning_effort", "low")
case "low":
out, _ = sjson.Set(out, "reasoning_effort", "low")
case "medium":
out, _ = sjson.Set(out, "reasoning_effort", "medium")
case "high":
out, _ = sjson.Set(out, "reasoning_effort", "high")
case "xhigh":
out, _ = sjson.Set(out, "reasoning_effort", "xhigh")
default:
out, _ = sjson.Set(out, "reasoning_effort", "auto")
effort := strings.ToLower(strings.TrimSpace(reasoningEffort.String()))
if effort != "" {
out, _ = sjson.Set(out, "reasoning_effort", effort)
}
}

View File

@@ -152,6 +152,71 @@ func NormalizeGeminiCLIThinkingBudget(model string, body []byte) []byte {
return updated
}
// ReasoningEffortBudgetMapping defines the thinkingBudget values for each reasoning effort level.
var ReasoningEffortBudgetMapping = map[string]int{
"none": 0,
"auto": -1,
"minimal": 512,
"low": 1024,
"medium": 8192,
"high": 24576,
"xhigh": 32768,
}
// ApplyReasoningEffortToGemini applies OpenAI reasoning_effort to Gemini thinkingConfig
// for standard Gemini API format (generationConfig.thinkingConfig path).
// Returns the modified body with thinkingBudget and include_thoughts set.
func ApplyReasoningEffortToGemini(body []byte, effort string) []byte {
normalized := strings.ToLower(strings.TrimSpace(effort))
if normalized == "" {
return body
}
budgetPath := "generationConfig.thinkingConfig.thinkingBudget"
includePath := "generationConfig.thinkingConfig.include_thoughts"
if normalized == "none" {
body, _ = sjson.DeleteBytes(body, "generationConfig.thinkingConfig")
return body
}
budget, ok := ReasoningEffortBudgetMapping[normalized]
if !ok {
return body
}
body, _ = sjson.SetBytes(body, budgetPath, budget)
body, _ = sjson.SetBytes(body, includePath, true)
return body
}
// ApplyReasoningEffortToGeminiCLI applies OpenAI reasoning_effort to Gemini CLI thinkingConfig
// for Gemini CLI API format (request.generationConfig.thinkingConfig path).
// Returns the modified body with thinkingBudget and include_thoughts set.
func ApplyReasoningEffortToGeminiCLI(body []byte, effort string) []byte {
normalized := strings.ToLower(strings.TrimSpace(effort))
if normalized == "" {
return body
}
budgetPath := "request.generationConfig.thinkingConfig.thinkingBudget"
includePath := "request.generationConfig.thinkingConfig.include_thoughts"
if normalized == "none" {
body, _ = sjson.DeleteBytes(body, "request.generationConfig.thinkingConfig")
return body
}
budget, ok := ReasoningEffortBudgetMapping[normalized]
if !ok {
return body
}
body, _ = sjson.SetBytes(body, budgetPath, budget)
body, _ = sjson.SetBytes(body, includePath, true)
return body
}
// ConvertThinkingLevelToBudget checks for "generationConfig.thinkingConfig.thinkingLevel"
// and converts it to "thinkingBudget".
// "high" -> 32768

View File

@@ -5,15 +5,18 @@ package util
//
// Ranges:
// - 0 -> "none"
// - -1 -> "auto"
// - 1..1024 -> "low"
// - 1025..8192 -> "medium"
// - 8193..24576 -> "high"
// - 24577.. -> highest supported level for the model (defaults to "xhigh")
//
// Negative values (except the dynamic -1 handled elsewhere) are treated as unsupported.
// Negative values other than -1 are treated as unsupported.
func OpenAIThinkingBudgetToEffort(model string, budget int) (string, bool) {
switch {
case budget < 0:
case budget == -1:
return "auto", true
case budget < -1:
return "", false
case budget == 0:
return "none", true

View File

@@ -5,6 +5,7 @@ package handlers
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strings"
@@ -117,6 +118,16 @@ func (h *BaseAPIHandler) GetContextWithCancel(handler interfaces.APIHandler, c *
newCtx = context.WithValue(newCtx, "handler", handler)
return newCtx, func(params ...interface{}) {
if h.Cfg.RequestLog && len(params) == 1 {
if existing, exists := c.Get("API_RESPONSE"); exists {
if existingBytes, ok := existing.([]byte); ok && len(bytes.TrimSpace(existingBytes)) > 0 {
switch params[0].(type) {
case error, string:
cancel()
return
}
}
}
var payload []byte
switch data := params[0].(type) {
case []byte:
@@ -437,12 +448,53 @@ func (h *BaseAPIHandler) WriteErrorResponse(c *gin.Context, msg *interfaces.Erro
}
}
}
c.Status(status)
errText := http.StatusText(status)
if msg != nil && msg.Error != nil {
_, _ = c.Writer.Write([]byte(msg.Error.Error()))
} else {
_, _ = c.Writer.Write([]byte(http.StatusText(status)))
if v := strings.TrimSpace(msg.Error.Error()); v != "" {
errText = v
}
}
// Prefer preserving upstream JSON error bodies when possible.
buildJSONBody := func() []byte {
trimmed := strings.TrimSpace(errText)
if trimmed != "" && json.Valid([]byte(trimmed)) {
return []byte(trimmed)
}
errType := "invalid_request_error"
switch status {
case http.StatusUnauthorized:
errType = "authentication_error"
case http.StatusForbidden:
errType = "permission_error"
case http.StatusTooManyRequests:
errType = "rate_limit_error"
default:
if status >= http.StatusInternalServerError {
errType = "server_error"
}
}
payload, err := json.Marshal(ErrorResponse{
Error: ErrorDetail{
Message: errText,
Type: errType,
},
})
if err != nil {
return []byte(fmt.Sprintf(`{"error":{"message":%q,"type":"server_error"}}`, errText))
}
return payload
}
body := buildJSONBody()
c.Set("API_RESPONSE", bytes.Clone(body))
if !c.Writer.Written() {
c.Writer.Header().Set("Content-Type", "application/json")
}
c.Status(status)
_, _ = c.Writer.Write(body)
}
func (h *BaseAPIHandler) LoggingAPIResponseError(ctx context.Context, err *interfaces.ErrorMessage) {

View File

@@ -375,10 +375,19 @@ func (m *Manager) executeWithProvider(ctx context.Context, provider string, req
}
accountType, accountInfo := auth.AccountInfo()
proxyInfo := auth.ProxyInfo()
if accountType == "api_key" {
log.Debugf("Use API key %s for model %s", util.HideAPIKey(accountInfo), req.Model)
if proxyInfo != "" {
log.Debugf("Use API key %s for model %s %s", util.HideAPIKey(accountInfo), req.Model, proxyInfo)
} else {
log.Debugf("Use API key %s for model %s", util.HideAPIKey(accountInfo), req.Model)
}
} else if accountType == "oauth" {
log.Debugf("Use OAuth %s for model %s", accountInfo, req.Model)
if proxyInfo != "" {
log.Debugf("Use OAuth %s for model %s %s", accountInfo, req.Model, proxyInfo)
} else {
log.Debugf("Use OAuth %s for model %s", accountInfo, req.Model)
}
}
tried[auth.ID] = struct{}{}
@@ -423,10 +432,19 @@ func (m *Manager) executeCountWithProvider(ctx context.Context, provider string,
}
accountType, accountInfo := auth.AccountInfo()
proxyInfo := auth.ProxyInfo()
if accountType == "api_key" {
log.Debugf("Use API key %s for model %s", util.HideAPIKey(accountInfo), req.Model)
if proxyInfo != "" {
log.Debugf("Use API key %s for model %s %s", util.HideAPIKey(accountInfo), req.Model, proxyInfo)
} else {
log.Debugf("Use API key %s for model %s", util.HideAPIKey(accountInfo), req.Model)
}
} else if accountType == "oauth" {
log.Debugf("Use OAuth %s for model %s", accountInfo, req.Model)
if proxyInfo != "" {
log.Debugf("Use OAuth %s for model %s %s", accountInfo, req.Model, proxyInfo)
} else {
log.Debugf("Use OAuth %s for model %s", accountInfo, req.Model)
}
}
tried[auth.ID] = struct{}{}
@@ -471,10 +489,19 @@ func (m *Manager) executeStreamWithProvider(ctx context.Context, provider string
}
accountType, accountInfo := auth.AccountInfo()
proxyInfo := auth.ProxyInfo()
if accountType == "api_key" {
log.Debugf("Use API key %s for model %s", util.HideAPIKey(accountInfo), req.Model)
if proxyInfo != "" {
log.Debugf("Use API key %s for model %s %s", util.HideAPIKey(accountInfo), req.Model, proxyInfo)
} else {
log.Debugf("Use API key %s for model %s", util.HideAPIKey(accountInfo), req.Model)
}
} else if accountType == "oauth" {
log.Debugf("Use OAuth %s for model %s", accountInfo, req.Model)
if proxyInfo != "" {
log.Debugf("Use OAuth %s for model %s %s", accountInfo, req.Model, proxyInfo)
} else {
log.Debugf("Use OAuth %s for model %s", accountInfo, req.Model)
}
}
tried[auth.ID] = struct{}{}

View File

@@ -157,6 +157,20 @@ func (m *ModelState) Clone() *ModelState {
return &copyState
}
func (a *Auth) ProxyInfo() string {
if a == nil {
return ""
}
proxyStr := strings.TrimSpace(a.ProxyURL)
if proxyStr == "" {
return ""
}
if idx := strings.Index(proxyStr, "://"); idx > 0 {
return "via " + proxyStr[:idx] + " proxy"
}
return "via proxy"
}
func (a *Auth) AccountInfo() (string, string) {
if a == nil {
return "", ""

View File

@@ -2,7 +2,6 @@ package test
import (
"fmt"
"net/http"
"strings"
"testing"
"time"
@@ -10,20 +9,20 @@ import (
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/translator"
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor"
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// statusErr mirrors executor.statusErr to keep validation behavior aligned.
type statusErr struct {
code int
msg string
// isOpenAICompatModel returns true if the model is configured as an OpenAI-compatible
// model that should have reasoning effort passed through even if not in registry.
// This simulates the allowCompat behavior from OpenAICompatExecutor.
func isOpenAICompatModel(model string) bool {
return model == "openai-compat"
}
func (e statusErr) Error() string { return e.msg }
// registerCoreModels loads representative models across providers into the registry
// so NormalizeThinkingBudget and level validation use real ranges.
func registerCoreModels(t *testing.T) func() {
@@ -34,14 +33,62 @@ func registerCoreModels(t *testing.T) func() {
reg.RegisterClient(uid+"-claude", "claude", registry.GetClaudeModels())
reg.RegisterClient(uid+"-openai", "codex", registry.GetOpenAIModels())
reg.RegisterClient(uid+"-qwen", "qwen", registry.GetQwenModels())
// Custom openai-compatible model with forced thinking suffix passthrough.
// No Thinking field - simulates an external model added via openai-compat
// where the registry has no knowledge of its thinking capabilities.
// The allowCompat flag should preserve reasoning effort for such models.
customOpenAIModels := []*registry.ModelInfo{
{
ID: "openai-compat",
Object: "model",
Created: 1700000000,
OwnedBy: "custom-provider",
Type: "openai",
DisplayName: "OpenAI Compatible Model",
Description: "OpenAI-compatible model with forced thinking suffix support",
},
}
reg.RegisterClient(uid+"-custom-openai", "codex", customOpenAIModels)
return func() {
reg.UnregisterClient(uid + "-gemini")
reg.UnregisterClient(uid + "-claude")
reg.UnregisterClient(uid + "-openai")
reg.UnregisterClient(uid + "-qwen")
reg.UnregisterClient(uid + "-custom-openai")
}
}
var (
thinkingTestModels = []string{
"gpt-5", // level-based thinking model
"gemini-2.5-pro", // numeric-budget thinking model
"qwen3-code-plus", // no thinking support
"openai-compat", // allowCompat=true (OpenAI-compatible channel)
}
thinkingTestFromProtocols = []string{"openai", "claude", "gemini", "openai-response"}
thinkingTestToProtocols = []string{"gemini", "claude", "openai", "codex"}
// Numeric budgets and their level equivalents:
// -1 -> auto
// 0 -> none
// 1..1024 -> low
// 1025..8192 -> medium
// 8193..24576 -> high
// >24576 -> model highest level (right-most in Levels)
thinkingNumericSamples = []int{-1, 0, 1023, 1025, 8193, 64000}
// Levels and their numeric equivalents:
// auto -> -1
// none -> 0
// minimal -> 512
// low -> 1024
// medium -> 8192
// high -> 24576
// xhigh -> 32768
// invalid -> invalid (no mapping)
thinkingLevelSamples = []string{"auto", "none", "minimal", "low", "medium", "high", "xhigh", "invalid"}
)
func buildRawPayload(fromProtocol, modelWithSuffix string) []byte {
switch fromProtocol {
case "gemini":
@@ -53,141 +100,10 @@ func buildRawPayload(fromProtocol, modelWithSuffix string) []byte {
}
}
// applyThinkingMetadataLocal mirrors executor.applyThinkingMetadata.
func applyThinkingMetadataLocal(payload []byte, metadata map[string]any, model string) []byte {
budgetOverride, includeOverride, ok := util.ResolveThinkingConfigFromMetadata(model, metadata)
if !ok || (budgetOverride == nil && includeOverride == nil) {
return payload
}
if !util.ModelSupportsThinking(model) {
return payload
}
if budgetOverride != nil {
norm := util.NormalizeThinkingBudget(model, *budgetOverride)
budgetOverride = &norm
}
return util.ApplyGeminiThinkingConfig(payload, budgetOverride, includeOverride)
}
// applyReasoningEffortMetadataLocal mirrors executor.applyReasoningEffortMetadata.
func applyReasoningEffortMetadataLocal(payload []byte, metadata map[string]any, model, field string) []byte {
if len(metadata) == 0 {
return payload
}
if !util.ModelSupportsThinking(model) {
return payload
}
if field == "" {
return payload
}
if effort, ok := util.ReasoningEffortFromMetadata(metadata); ok && effort != "" {
if updated, err := sjson.SetBytes(payload, field, effort); err == nil {
return updated
}
}
if util.ModelUsesThinkingLevels(model) {
if budget, _, _, matched := util.ThinkingFromMetadata(metadata); matched && budget != nil {
if effort, ok := util.OpenAIThinkingBudgetToEffort(model, *budget); ok && effort != "" {
if updated, err := sjson.SetBytes(payload, field, effort); err == nil {
return updated
}
}
}
}
return payload
}
// normalizeThinkingConfigLocal mirrors executor.normalizeThinkingConfig.
func normalizeThinkingConfigLocal(payload []byte, model string) []byte {
if len(payload) == 0 || model == "" {
return payload
}
if !util.ModelSupportsThinking(model) {
return stripThinkingFieldsLocal(payload, false)
}
if util.ModelUsesThinkingLevels(model) {
return normalizeReasoningEffortLevelLocal(payload, model)
}
// Model supports thinking but uses numeric budgets, not levels.
// Strip effort string fields since they are not applicable.
return stripThinkingFieldsLocal(payload, true)
}
// stripThinkingFieldsLocal mirrors executor.stripThinkingFields.
func stripThinkingFieldsLocal(payload []byte, effortOnly bool) []byte {
fieldsToRemove := []string{
"reasoning_effort",
"reasoning.effort",
}
if !effortOnly {
fieldsToRemove = append([]string{"reasoning"}, fieldsToRemove...)
}
out := payload
for _, field := range fieldsToRemove {
if gjson.GetBytes(out, field).Exists() {
out, _ = sjson.DeleteBytes(out, field)
}
}
return out
}
// normalizeReasoningEffortLevelLocal mirrors executor.normalizeReasoningEffortLevel.
func normalizeReasoningEffortLevelLocal(payload []byte, model string) []byte {
out := payload
if effort := gjson.GetBytes(out, "reasoning_effort"); effort.Exists() {
if normalized, ok := util.NormalizeReasoningEffortLevel(model, effort.String()); ok {
out, _ = sjson.SetBytes(out, "reasoning_effort", normalized)
}
}
if effort := gjson.GetBytes(out, "reasoning.effort"); effort.Exists() {
if normalized, ok := util.NormalizeReasoningEffortLevel(model, effort.String()); ok {
out, _ = sjson.SetBytes(out, "reasoning.effort", normalized)
}
}
return out
}
// validateThinkingConfigLocal mirrors executor.validateThinkingConfig.
func validateThinkingConfigLocal(payload []byte, model string) error {
if len(payload) == 0 || model == "" {
return nil
}
if !util.ModelSupportsThinking(model) || !util.ModelUsesThinkingLevels(model) {
return nil
}
levels := util.GetModelThinkingLevels(model)
checkField := func(path string) error {
if effort := gjson.GetBytes(payload, path); effort.Exists() {
if _, ok := util.NormalizeReasoningEffortLevel(model, effort.String()); !ok {
return statusErr{
code: http.StatusBadRequest,
msg: fmt.Sprintf("unsupported reasoning effort level %q for model %s (supported: %s)", effort.String(), model, strings.Join(levels, ", ")),
}
}
}
return nil
}
if err := checkField("reasoning_effort"); err != nil {
return err
}
if err := checkField("reasoning.effort"); err != nil {
return err
}
return nil
}
// normalizeCodexPayload mirrors codex_executor's reasoning + streaming tweaks.
func normalizeCodexPayload(body []byte, upstreamModel string) ([]byte, error) {
body = normalizeThinkingConfigLocal(body, upstreamModel)
if err := validateThinkingConfigLocal(body, upstreamModel); err != nil {
func normalizeCodexPayload(body []byte, upstreamModel string, allowCompat bool) ([]byte, error) {
body = executor.NormalizeThinkingConfig(body, upstreamModel, allowCompat)
if err := executor.ValidateThinkingConfig(body, upstreamModel); err != nil {
return body, err
}
body, _ = sjson.SetBytes(body, "model", upstreamModel)
@@ -214,9 +130,10 @@ func buildBodyForProtocol(t *testing.T, fromProtocol, toProtocol, modelWithSuffi
)
var err error
allowCompat := isOpenAICompatModel(normalizedModel)
switch toProtocol {
case "gemini":
body = applyThinkingMetadataLocal(body, metadata, normalizedModel)
body = executor.ApplyThinkingMetadata(body, metadata, normalizedModel)
body = util.ApplyDefaultThinkingIfNeeded(normalizedModel, body)
body = util.NormalizeGeminiThinkingBudget(normalizedModel, body)
body = util.StripThinkingConfigIfUnsupported(normalizedModel, body)
@@ -225,13 +142,14 @@ func buildBodyForProtocol(t *testing.T, fromProtocol, toProtocol, modelWithSuffi
body = util.ApplyClaudeThinkingConfig(body, budget)
}
case "openai":
body = applyReasoningEffortMetadataLocal(body, metadata, normalizedModel, "reasoning_effort")
body = normalizeThinkingConfigLocal(body, upstreamModel)
err = validateThinkingConfigLocal(body, upstreamModel)
body = executor.ApplyReasoningEffortMetadata(body, metadata, normalizedModel, "reasoning_effort", allowCompat)
body = executor.NormalizeThinkingConfig(body, upstreamModel, allowCompat)
err = executor.ValidateThinkingConfig(body, upstreamModel)
case "codex": // OpenAI responses / codex
body = applyReasoningEffortMetadataLocal(body, metadata, normalizedModel, "reasoning.effort")
// Codex does not support allowCompat; always use false.
body = executor.ApplyReasoningEffortMetadata(body, metadata, normalizedModel, "reasoning.effort", false)
// Mirror CodexExecutor final normalization and model override so tests log the final body.
body, err = normalizeCodexPayload(body, upstreamModel)
body, err = normalizeCodexPayload(body, upstreamModel, false)
default:
}
@@ -287,83 +205,46 @@ func TestThinkingConversionsAcrossProtocolsAndModels(t *testing.T) {
cleanup := registerCoreModels(t)
defer cleanup()
models := []string{
"gpt-5", // supports levels (low/medium/high)
"gemini-2.5-pro", // supports numeric budget
"qwen3-coder-flash", // no thinking support
}
fromProtocols := []string{"openai", "claude", "gemini", "openai-response"}
toProtocols := []string{"gemini", "claude", "openai", "codex"}
type scenario struct {
name string
modelSuffix string
expectFn func(info *registry.ModelInfo) (present bool, budget int64)
}
buildBudgetFn := func(raw int) func(info *registry.ModelInfo) (bool, int64) {
return func(info *registry.ModelInfo) (bool, int64) {
if info == nil || info.Thinking == nil {
return false, 0
}
return true, int64(util.NormalizeThinkingBudget(info.ID, raw))
numericName := func(budget int) string {
if budget < 0 {
return "numeric-neg1"
}
return fmt.Sprintf("numeric-%d", budget)
}
levelBudgetFn := func(level string) func(info *registry.ModelInfo) (bool, int64) {
return func(info *registry.ModelInfo) (bool, int64) {
if info == nil || info.Thinking == nil {
return false, 0
}
if b, ok := util.ThinkingEffortToBudget(info.ID, level); ok {
return true, int64(b)
}
return false, 0
}
}
for _, model := range thinkingTestModels {
_ = registry.GetGlobalRegistry().GetModelInfo(model)
for _, model := range models {
info := registry.GetGlobalRegistry().GetModelInfo(model)
min, max := 0, 0
if info != nil && info.Thinking != nil {
min = info.Thinking.Min
max = info.Thinking.Max
}
for _, from := range fromProtocols {
for _, from := range thinkingTestFromProtocols {
// Scenario selection follows protocol semantics:
// - OpenAI-style protocols (openai/openai-response) express thinking as levels.
// - Claude/Gemini-style protocols express thinking as numeric budgets.
cases := []scenario{
{name: "no-suffix", modelSuffix: model, expectFn: func(_ *registry.ModelInfo) (bool, int64) { return false, 0 }},
{name: "no-suffix", modelSuffix: model},
}
if from == "openai" || from == "openai-response" {
cases = append(cases,
scenario{name: "level-low", modelSuffix: fmt.Sprintf("%s(low)", model), expectFn: levelBudgetFn("low")},
scenario{name: "level-high", modelSuffix: fmt.Sprintf("%s(high)", model), expectFn: levelBudgetFn("high")},
scenario{name: "level-auto", modelSuffix: fmt.Sprintf("%s(auto)", model), expectFn: levelBudgetFn("auto")},
)
for _, lvl := range thinkingLevelSamples {
cases = append(cases, scenario{
name: "level-" + lvl,
modelSuffix: fmt.Sprintf("%s(%s)", model, lvl),
})
}
} else { // claude or gemini
if util.ModelUsesThinkingLevels(model) {
// Numeric budgets for level-based models are mapped into levels when needed.
cases = append(cases,
scenario{name: "numeric-0", modelSuffix: fmt.Sprintf("%s(0)", model), expectFn: buildBudgetFn(0)},
scenario{name: "numeric-1024", modelSuffix: fmt.Sprintf("%s(1024)", model), expectFn: buildBudgetFn(1024)},
scenario{name: "numeric-1025", modelSuffix: fmt.Sprintf("%s(1025)", model), expectFn: buildBudgetFn(1025)},
scenario{name: "numeric-8192", modelSuffix: fmt.Sprintf("%s(8192)", model), expectFn: buildBudgetFn(8192)},
scenario{name: "numeric-8193", modelSuffix: fmt.Sprintf("%s(8193)", model), expectFn: buildBudgetFn(8193)},
scenario{name: "numeric-24576", modelSuffix: fmt.Sprintf("%s(24576)", model), expectFn: buildBudgetFn(24576)},
scenario{name: "numeric-24577", modelSuffix: fmt.Sprintf("%s(24577)", model), expectFn: buildBudgetFn(24577)},
)
} else {
cases = append(cases,
scenario{name: "numeric-below-min", modelSuffix: fmt.Sprintf("%s(%d)", model, min-10), expectFn: buildBudgetFn(min - 10)},
scenario{name: "numeric-above-max", modelSuffix: fmt.Sprintf("%s(%d)", model, max+10), expectFn: buildBudgetFn(max + 10)},
)
for _, budget := range thinkingNumericSamples {
budget := budget
cases = append(cases, scenario{
name: numericName(budget),
modelSuffix: fmt.Sprintf("%s(%d)", model, budget),
})
}
}
for _, to := range toProtocols {
for _, to := range thinkingTestToProtocols {
if from == to {
continue
}
@@ -402,7 +283,22 @@ func TestThinkingConversionsAcrossProtocolsAndModels(t *testing.T) {
}
return true, fmt.Sprintf("%d", *budget), false
case "openai":
if !util.ModelSupportsThinking(normalizedModel) {
allowCompat := isOpenAICompatModel(normalizedModel)
if !util.ModelSupportsThinking(normalizedModel) && !allowCompat {
return false, "", false
}
// For allowCompat models, pass through effort directly without validation
if allowCompat {
effort, ok := util.ReasoningEffortFromMetadata(metadata)
if ok && strings.TrimSpace(effort) != "" {
return true, strings.ToLower(strings.TrimSpace(effort)), false
}
// Check numeric budget fallback for allowCompat
if budget, _, _, matched := util.ThinkingFromMetadata(metadata); matched && budget != nil {
if mapped, okMap := util.OpenAIThinkingBudgetToEffort(normalizedModel, *budget); okMap && mapped != "" {
return true, mapped, false
}
}
return false, "", false
}
if !util.ModelUsesThinkingLevels(normalizedModel) {
@@ -427,14 +323,8 @@ func TestThinkingConversionsAcrossProtocolsAndModels(t *testing.T) {
}
return false, "", true // validation would fail
case "codex":
if !util.ModelSupportsThinking(normalizedModel) {
return false, "", false
}
if !util.ModelUsesThinkingLevels(normalizedModel) {
// Non-levels models don't support effort strings in codex
if from != "openai-response" {
return false, "", false
}
// Codex does not support allowCompat; require thinking-capable level models.
if !util.ModelSupportsThinking(normalizedModel) || !util.ModelUsesThinkingLevels(normalizedModel) {
return false, "", false
}
effort, ok := util.ReasoningEffortFromMetadata(metadata)
@@ -523,6 +413,314 @@ func TestThinkingConversionsAcrossProtocolsAndModels(t *testing.T) {
}
}
// buildRawPayloadWithThinking creates a payload with thinking parameters already in the body.
// This tests the path where thinking comes from the raw payload, not model suffix.
func buildRawPayloadWithThinking(fromProtocol, model string, thinkingParam any) []byte {
switch fromProtocol {
case "gemini":
base := fmt.Sprintf(`{"model":"%s","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`, model)
if budget, ok := thinkingParam.(int); ok {
base, _ = sjson.Set(base, "generationConfig.thinkingConfig.thinkingBudget", budget)
}
return []byte(base)
case "openai-response":
base := fmt.Sprintf(`{"model":"%s","input":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`, model)
if effort, ok := thinkingParam.(string); ok && effort != "" {
base, _ = sjson.Set(base, "reasoning.effort", effort)
}
return []byte(base)
case "openai":
base := fmt.Sprintf(`{"model":"%s","messages":[{"role":"user","content":"hi"}]}`, model)
if effort, ok := thinkingParam.(string); ok && effort != "" {
base, _ = sjson.Set(base, "reasoning_effort", effort)
}
return []byte(base)
case "claude":
base := fmt.Sprintf(`{"model":"%s","messages":[{"role":"user","content":"hi"}]}`, model)
if budget, ok := thinkingParam.(int); ok {
base, _ = sjson.Set(base, "thinking.type", "enabled")
base, _ = sjson.Set(base, "thinking.budget_tokens", budget)
}
return []byte(base)
default:
return []byte(fmt.Sprintf(`{"model":"%s","messages":[{"role":"user","content":"hi"}]}`, model))
}
}
// buildBodyForProtocolWithRawThinking translates payload with raw thinking params.
func buildBodyForProtocolWithRawThinking(t *testing.T, fromProtocol, toProtocol, model string, thinkingParam any) ([]byte, error) {
t.Helper()
raw := buildRawPayloadWithThinking(fromProtocol, model, thinkingParam)
stream := fromProtocol != toProtocol
body := sdktranslator.TranslateRequest(
sdktranslator.FromString(fromProtocol),
sdktranslator.FromString(toProtocol),
model,
raw,
stream,
)
var err error
allowCompat := isOpenAICompatModel(model)
switch toProtocol {
case "gemini":
body = util.ApplyDefaultThinkingIfNeeded(model, body)
body = util.NormalizeGeminiThinkingBudget(model, body)
body = util.StripThinkingConfigIfUnsupported(model, body)
case "claude":
// For raw payload, Claude thinking is passed through by translator
// No additional processing needed as thinking is already in body
case "openai":
body = executor.NormalizeThinkingConfig(body, model, allowCompat)
err = executor.ValidateThinkingConfig(body, model)
case "codex":
// Codex does not support allowCompat; always use false.
body, err = normalizeCodexPayload(body, model, false)
}
body, _ = sjson.SetBytes(body, "model", model)
body = filterThinkingBody(toProtocol, body, model, model)
return body, err
}
func TestRawPayloadThinkingConversions(t *testing.T) {
cleanup := registerCoreModels(t)
defer cleanup()
type scenario struct {
name string
thinkingParam any // int for budget, string for effort level
}
numericName := func(budget int) string {
if budget < 0 {
return "budget-neg1"
}
return fmt.Sprintf("budget-%d", budget)
}
for _, model := range thinkingTestModels {
supportsThinking := util.ModelSupportsThinking(model)
usesLevels := util.ModelUsesThinkingLevels(model)
allowCompat := isOpenAICompatModel(model)
for _, from := range thinkingTestFromProtocols {
var cases []scenario
switch from {
case "openai", "openai-response":
cases = []scenario{
{name: "no-thinking", thinkingParam: nil},
}
for _, lvl := range thinkingLevelSamples {
cases = append(cases, scenario{
name: "effort-" + lvl,
thinkingParam: lvl,
})
}
case "gemini", "claude":
cases = []scenario{
{name: "no-thinking", thinkingParam: nil},
}
for _, budget := range thinkingNumericSamples {
budget := budget
cases = append(cases, scenario{
name: numericName(budget),
thinkingParam: budget,
})
}
}
for _, to := range thinkingTestToProtocols {
if from == to {
continue
}
t.Logf("═══════════════════════════════════════════════════════════════════════════════")
t.Logf(" RAW PAYLOAD: %s -> %s | model: %s", from, to, model)
t.Logf("═══════════════════════════════════════════════════════════════════════════════")
for _, cs := range cases {
from := from
to := to
cs := cs
testName := fmt.Sprintf("raw/%s->%s/%s/%s", from, to, model, cs.name)
t.Run(testName, func(t *testing.T) {
expectPresent, expectValue, expectErr := func() (bool, string, bool) {
if cs.thinkingParam == nil {
if to == "codex" && from != "openai-response" && supportsThinking && usesLevels {
// Codex translators default reasoning.effort to "medium" for thinking-capable level models
return true, "medium", false
}
return false, "", false
}
switch to {
case "gemini":
if !supportsThinking || usesLevels {
return false, "", false
}
// Gemini expects numeric budget (only for non-level models)
if budget, ok := cs.thinkingParam.(int); ok {
norm := util.NormalizeThinkingBudget(model, budget)
return true, fmt.Sprintf("%d", norm), false
}
// Convert effort level to budget for non-level models only
if effort, ok := cs.thinkingParam.(string); ok && effort != "" {
// "none" disables thinking - no thinkingBudget in output
if strings.ToLower(effort) == "none" {
return false, "", false
}
if budget, okB := util.ThinkingEffortToBudget(model, effort); okB {
// ThinkingEffortToBudget already returns normalized budget
return true, fmt.Sprintf("%d", budget), false
}
// Invalid effort does not map to a budget
return false, "", false
}
return false, "", false
case "claude":
if !supportsThinking || usesLevels {
return false, "", false
}
// Claude expects numeric budget (only for non-level models)
if budget, ok := cs.thinkingParam.(int); ok && budget > 0 {
norm := util.NormalizeThinkingBudget(model, budget)
return true, fmt.Sprintf("%d", norm), false
}
// Convert effort level to budget for non-level models only
if effort, ok := cs.thinkingParam.(string); ok && effort != "" {
// "none" and "auto" don't produce budget_tokens
lower := strings.ToLower(effort)
if lower == "none" || lower == "auto" {
return false, "", false
}
if budget, okB := util.ThinkingEffortToBudget(model, effort); okB {
// ThinkingEffortToBudget already returns normalized budget
return true, fmt.Sprintf("%d", budget), false
}
// Invalid effort - claude sets thinking.type:enabled but no budget_tokens
return false, "", false
}
return false, "", false
case "openai":
if allowCompat {
if effort, ok := cs.thinkingParam.(string); ok && strings.TrimSpace(effort) != "" {
normalized := strings.ToLower(strings.TrimSpace(effort))
return true, normalized, false
}
if budget, ok := cs.thinkingParam.(int); ok {
if mapped, okM := util.OpenAIThinkingBudgetToEffort(model, budget); okM && mapped != "" {
return true, mapped, false
}
}
return false, "", false
}
if !supportsThinking || !usesLevels {
return false, "", false
}
if effort, ok := cs.thinkingParam.(string); ok && effort != "" {
if normalized, okN := util.NormalizeReasoningEffortLevel(model, effort); okN {
return true, normalized, false
}
return false, "", true // invalid level
}
if budget, ok := cs.thinkingParam.(int); ok {
if mapped, okM := util.OpenAIThinkingBudgetToEffort(model, budget); okM && mapped != "" {
// Check if the mapped effort is valid for this model
if _, validLevel := util.NormalizeReasoningEffortLevel(model, mapped); !validLevel {
return true, mapped, true // expect validation error
}
return true, mapped, false
}
}
return false, "", false
case "codex":
// Codex does not support allowCompat; require thinking-capable level models.
if !supportsThinking || !usesLevels {
return false, "", false
}
if effort, ok := cs.thinkingParam.(string); ok && effort != "" {
if normalized, okN := util.NormalizeReasoningEffortLevel(model, effort); okN {
return true, normalized, false
}
return false, "", true
}
if budget, ok := cs.thinkingParam.(int); ok {
if mapped, okM := util.OpenAIThinkingBudgetToEffort(model, budget); okM && mapped != "" {
// Check if the mapped effort is valid for this model
if _, validLevel := util.NormalizeReasoningEffortLevel(model, mapped); !validLevel {
return true, mapped, true // expect validation error
}
return true, mapped, false
}
}
if from != "openai-response" {
// Codex translators default reasoning.effort to "medium" for thinking-capable models
return true, "medium", false
}
return false, "", false
}
return false, "", false
}()
body, err := buildBodyForProtocolWithRawThinking(t, from, to, model, cs.thinkingParam)
actualPresent, actualValue := func() (bool, string) {
path := ""
switch to {
case "gemini":
path = "generationConfig.thinkingConfig.thinkingBudget"
case "claude":
path = "thinking.budget_tokens"
case "openai":
path = "reasoning_effort"
case "codex":
path = "reasoning.effort"
}
if path == "" {
return false, ""
}
val := gjson.GetBytes(body, path)
if to == "codex" && !val.Exists() {
reasoning := gjson.GetBytes(body, "reasoning")
if reasoning.Exists() {
val = reasoning.Get("effort")
}
}
if !val.Exists() {
return false, ""
}
if val.Type == gjson.Number {
return true, fmt.Sprintf("%d", val.Int())
}
return true, val.String()
}()
t.Logf("from=%s to=%s model=%s param=%v present(expect=%v got=%v) value(expect=%s got=%s) err(expect=%v got=%v) body=%s",
from, to, model, cs.thinkingParam, expectPresent, actualPresent, expectValue, actualValue, expectErr, err != nil, string(body))
if expectErr {
if err == nil {
t.Fatalf("expected validation error but got none, body=%s", string(body))
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v body=%s", err, string(body))
}
if expectPresent != actualPresent {
t.Fatalf("presence mismatch: expect %v got %v body=%s", expectPresent, actualPresent, string(body))
}
if expectPresent && expectValue != actualValue {
t.Fatalf("value mismatch: expect %s got %s body=%s", expectValue, actualValue, string(body))
}
})
}
}
}
}
}
func TestOpenAIThinkingBudgetToEffortRanges(t *testing.T) {
cleanup := registerCoreModels(t)
defer cleanup()
@@ -534,6 +732,7 @@ func TestOpenAIThinkingBudgetToEffortRanges(t *testing.T) {
want string
ok bool
}{
{name: "dynamic-auto", model: "gpt-5", budget: -1, want: "auto", ok: true},
{name: "zero-none", model: "gpt-5", budget: 0, want: "none", ok: true},
{name: "low-min", model: "gpt-5", budget: 1, want: "low", ok: true},
{name: "low-max", model: "gpt-5", budget: 1024, want: "low", ok: true},
@@ -541,7 +740,7 @@ func TestOpenAIThinkingBudgetToEffortRanges(t *testing.T) {
{name: "medium-max", model: "gpt-5", budget: 8192, want: "medium", ok: true},
{name: "high-min", model: "gpt-5", budget: 8193, want: "high", ok: true},
{name: "high-max", model: "gpt-5", budget: 24576, want: "high", ok: true},
{name: "over-max-clamps-to-highest", model: "gpt-5", budget: 24577, want: "high", ok: true},
{name: "over-max-clamps-to-highest", model: "gpt-5", budget: 64000, want: "high", ok: true},
{name: "over-max-xhigh-model", model: "gpt-5.2", budget: 50000, want: "xhigh", ok: true},
{name: "negative-unsupported", model: "gpt-5", budget: -5, want: "", ok: false},
}